diff --git a/README.md b/README.md index 7aa629d..15f02fc 100644 --- a/README.md +++ b/README.md @@ -506,3 +506,7 @@ def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Di } ``` - 新增加的插件请配置在`package.json`中的末尾,这样可被识别为最新增加,可用于用户排序。 + +### 10. 如何开发V2版本的插件以及实现插件多版本兼容? + +- 请参阅 [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..05c11de --- /dev/null +++ b/docs/V2_Plugin_Development.md @@ -0,0 +1,573 @@ +# 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 + } + } + ``` + +### 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 +``` + +#### `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.schemas import ServiceInfo +from app.schemas.types import SystemConfigKey, ModuleType + +TConf = TypeVar("TConf") + +class ServiceBaseHelper(Generic[TConf]): + """ + 通用服务帮助类,抽象获取配置和服务实例的通用逻辑 + """ + + 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.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 { + 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() + modules = self.modulemanager.get_running_type_modules(self.module_type) + for module in modules: + 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, 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 + ) 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 + 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 typing import Optional + +from app.helper.service import ServiceBaseHelper +from app.schemas import DownloaderConf, ServiceInfo +from app.schemas.types import SystemConfigKey, ModuleType + + +class DownloaderHelper(ServiceBaseHelper[DownloaderConf]): + """ + 下载器帮助类 + """ + + def __init__(self): + super().__init__( + config_key=SystemConfigKey.Downloaders, + conf_type=DownloaderConf, + module_type=ModuleType.Downloader + ) + + def is_downloader( + self, + service_type: Optional[str] = None, + service: Optional[ServiceInfo] = None, + name: Optional[str] = None, + ) -> bool: + """ + 通用的下载器类型判断方法 + + :param service_type: 下载器的类型名称(如 'qbittorrent', 'transmission') + :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) +``` + +#### `MediaServerHelper` +用于管理媒体服务器服务。 + +```python +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): + super().__init__( + config_key=SystemConfigKey.MediaServers, + conf_type=MediaServerConf, + 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 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): + super().__init__( + config_key=SystemConfigKey.Notifications, + conf_type=NotificationConf, + 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 在插件中使用服务帮助类 + +通过这些帮助类,插件可以方便地获取和管理各种服务。以下是 `DownloaderHelper` 的使用示例,包括类型检查服务和监听模块重载事件的两种方法。 + +#### 获取下载器选项 + +插件可以通过 `DownloaderHelper` 获取所有可用的下载器配置,并生成选项列表供用户选择。 + +```python +from app.helper.downloader 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() + ] +``` + +#### 获取特定下载器服务 + +根据用户选择的下载器名称,插件可以获取对应的服务实例,并执行相应的操作。以下展示了两种方法: + +1. **使用事件监听进行模块重载,从而保持服务实例共享** + + 如果外部模块进行了重载,需要监听模块重载事件以重置下载器服务。 + + ```python + from typing import Optional, Union + 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() + self._downloader = None + self.__setup_downloader(config.get("downloader_name")) + + def __setup_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 + + @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_downloader(service_type="qbittorrent", service=downloader): + # 处理 qbittorrent 类型 + return True + elif self.downloaderhelper.is_downloader(service_type="transmission", service=downloader): + # 处理 transmission 类型 + return True + return False + ``` + +2. **使用 Property 实现服务实例共享** + + 通过 `Property` 方法,从而保持服务实例共享,而无需通过事件监听。 + + ```python + from typing import Optional, Union + 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() + self.downloader_name = config.get("downloader_name") + + @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_downloader(service_type="qbittorrent", service=self.service_info): + # 处理 qbittorrent 类型 + return True + elif self.downloaderhelper.is_downloader(service_type="transmission", service=self.service_info): + # 处理 transmission 类型 + return True + return False + ``` + +### 2.4 服务封装的优势 + +- **统一管理**:通过 `ServiceBaseHelper`,不同类型的服务配置和实例管理变得统一和简洁。 +- **灵活扩展**:新增服务类型时,只需创建相应的帮助类,无需修改现有逻辑。 +- **便捷调用**:插件可以轻松获取所需的服务实例,简化了服务的调用过程。 + +### 2.5 从 V1 升级到 V2 的注意事项 + +- **使用帮助类**:确保插件中使用了新的服务帮助类,如 `DownloaderHelper`、`MediaServerHelper`、`NotificationHelper` 等,而不是直接操作服务实例。 +- **更新依赖**:检查并更新 `requirements.txt` 中的依赖,确保与 V2 的服务封装兼容。 +- **测试插件**:在 V2 环境中全面测试插件,确保所有服务调用正常工作。 \ No newline at end of file diff --git a/icons/Dingding_A.png b/icons/Dingding_A.png new file mode 100644 index 0000000..bfa27a6 Binary files /dev/null and b/icons/Dingding_A.png differ diff --git a/icons/bangumi_b.png b/icons/bangumi_b.png new file mode 100644 index 0000000..a6fffff Binary files /dev/null and b/icons/bangumi_b.png differ diff --git a/package.json b/package.json index a2495a3..f412f4d 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,13 @@ "name": "站点自动签到", "description": "自动模拟登录、签到站点。", "labels": "站点", - "version": "2.4", + "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": "修复签到报错问题", @@ -25,17 +27,22 @@ "version": "1.0", "icon": "world.png", "author": "lightolly", - "level": 2 + "level": 2, + "v2": true }, "SiteStatistic": { "name": "站点数据统计", "description": "自动统计和展示站点数据。", "labels": "站点,仪表板", - "version": "3.9.1", + "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地址变化", @@ -60,17 +67,21 @@ "version": "1.2", "icon": "Chrome_A.png", "author": "thsrite", - "level": 2 + "level": 2, + "v2": true }, "DoubanSync": { "name": "豆瓣想看", "description": "同步豆瓣想看数据,自动添加订阅。", "labels": "订阅", - "version": "1.8", + "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安全性", "v1.6": "同步历史记录支持手动删除,需要主程序升级至v1.8.4+版本", @@ -111,6 +122,7 @@ "icon": "movie.jpg", "author": "jxxghp", "level": 2, + "v2": true, "history": { "v1.9.1": "优化媒体类型的判断处理", "v1.9": "增强API安全性", @@ -143,11 +155,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": "支持手动删除订阅历史记录(本次更新之后的历史)" @@ -157,11 +170,13 @@ "name": "自定义Hosts", "description": "修改系统hosts文件,加速网络访问。", "labels": "网络", - "version": "1.1", + "version": "1.2", "icon": "hosts.png", "author": "thsrite", "level": 1, + "v2": true, "history": { + "v1.2": "支持写入注释", "v1.1": "关闭插件时自动恢复系统hosts" } }, @@ -169,10 +184,15 @@ "name": "播放限速", "description": "外网播放媒体库视频时,自动对下载器进行限速。", "labels": "网络", - "version": "1.1", + "version": "1.3", "icon": "Librespeed_A.png", "author": "Shurelol", - "level": 1 + "level": 1, + "history": { + "v1.3": "修复bug;增加预留带宽设置", + "v1.2.1": "修复多下载器时限速比例计算错误问题", + "v1.2": "增加不限速路径配置,以应对网盘直链播放的情况" + } }, "CloudflareSpeedTest": { "name": "Cloudflare IP优选", @@ -182,6 +202,7 @@ "icon": "cloudflare.jpg", "author": "thsrite", "level": 1, + "v2": true, "history": { "v1.4": "修复立即运行一次", "v1.3": "调整插件开启状态判断条件", @@ -205,11 +226,12 @@ "name": "媒体库服务器通知", "description": "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。", "labels": "消息通知,媒体库", - "version": "1.2", + "version": "1.3", "icon": "mediaplay.png", "author": "jxxghp", "level": 1, "history": { + "v1.3": "兼容处理Emby部分客户端暂停重复推送停止播放webhook的场景", "v1.2": "播放通知增加超链接跳转(需要v1.9.4+)" } }, @@ -225,10 +247,15 @@ "WebHook": { "name": "Webhook", "description": "事件发生时向第三方地址发送请求。", - "version": "1.0", + "version": "1.1", "icon": "webhook.png", "author": "jxxghp", - "level": 1 + "level": 1, + "v2": true, + "history": { + "v1.1": "兼容MoviePilot V2 版本", + "v1.0": "新增Webhook插件,支持事件发生时向第三方地址发送请求" + } }, "ChatGPT": { "name": "ChatGPT", @@ -265,7 +292,7 @@ "author": "thsrite", "level": 1, "history": { - "v1.3":"去除已废弃的环境变量引用", + "v1.3": "去除已废弃的环境变量引用", "v1.2": "增强API安全性" } }, @@ -273,11 +300,14 @@ "name": "IYUU自动辅种", "description": "基于IYUU官方Api实现自动辅种。", "labels": "做种,IYUU", - "version": "1.9.3", + "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,种子下载失败的问题", "v1.9.2": "适配馒头使用API下载种子", "v1.9.1": "支持自定义辅种的种子分类", @@ -305,13 +335,16 @@ }, "VCBAnimeMonitor": { "name": "整理VCB动漫压制组作品", - "description": "提高部分VCB-Studio作品的识别准确率,将VCB-Studio的作品统一转移到指定目录同时进行刮削整理", + "description": "一款辅助整理&提高识别VCB-Stuido动漫压制组作品的插件", "labels": "文件整理,识别", - "version": "1.8", + "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": "修复偶尔安装失败问题" } @@ -320,11 +353,13 @@ "name": "自动转移做种", "description": "定期转移下载器中的做种任务到另一个下载器。", "labels": "做种", - "version": "1.4", + "version": "1.6", "icon": "seed.png", "author": "jxxghp", "level": 2, "history": { + "v1.6": "支持根据种子类别进行转移,并允许修改转移后的默认标签", + "v1.5": "修复在转移时只保留了第一个tracker,导致红种问题。此修复确保保留所有的tracker,以提高在不同网络条件下的可达性", "v1.4": "支持自动删除源下载器在目的下载器中存在的种子" } }, @@ -346,20 +381,28 @@ "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": "站点刷流", "description": "自动托管刷流,将会提高对应站点的访问频率。", "labels": "刷流,仪表板", - "version": "3.3", + "version": "3.8", "icon": "brush.jpg", "author": "jxxghp,InfinityPacer", "level": 2, "history": { + "v3.8": "添加自动归档记录天数配置项,支持定时归档已删除数据", + "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+版本", @@ -378,7 +421,8 @@ "version": "1.1", "icon": "downloadmsg.png", "author": "thsrite", - "level": 2 + "level": 2, + "v2": true }, "AutoClean": { "name": "定时清理媒体库", @@ -397,6 +441,7 @@ "icon": "invites.png", "author": "thsrite", "level": 2, + "v2": true, "history": { "v1.4": "自定义保留消息天数" } @@ -443,16 +488,8 @@ "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 + "level": 1, + "v2": true }, "PushDeerMsg": { "name": "PushDeer消息推送", @@ -461,7 +498,8 @@ "version": "1.1", "icon": "pushdeer.png", "author": "jxxghp", - "level": 1 + "level": 1, + "v2": true }, "ConfigCenter": { "name": "配置中心", @@ -483,16 +521,26 @@ "version": "1.0", "icon": "Wecom_A.png", "author": "叮叮当", - "level": 1 + "level": 1, + "v2": true }, "EpisodeGroupMeta": { "name": "TMDB剧集组刮削", "description": "从TMDB剧集组刮削季集的实际顺序。", "labels": "刮削", - "version": "1.1", + "version": "2.6", "icon": "Element_A.png", "author": "叮叮当", - "level": 1 + "level": 1, + "v2": true, + "history": { + "v2.6": "修复无法获取媒体库中季0的问题", + "v2.5": "修复当媒体服务器中剧集的季不完整时会中断的问题", + "v2.3": "修复v2版本无法读取媒体库的问题", + "v2.2": "修复v2版本无法读取数据的问题", + "v2.1": "增加发送通知提醒选择剧集组", + "v2.0": "增加手动选择剧集组的功能" + } }, "CustomIndexer": { "name": "自定义索引站点", @@ -501,7 +549,8 @@ "version": "1.0", "icon": "spider.png", "author": "jxxghp", - "level": 1 + "level": 1, + "v2": true }, "FFmpegThumb": { "name": "FFmpeg缩略图", @@ -519,7 +568,8 @@ "version": "1.0", "icon": "Pushplus_A.png", "author": "cheng", - "level": 1 + "level": 1, + "v2": true }, "DownloadSiteTag": { "name": "下载任务分类与标签", @@ -541,6 +591,7 @@ "icon": "Ombi_A.png", "author": "DzAvril", "level": 1, + "v2": true, "history": { "v2.2": "修复直接删除文件夹导致的插件崩溃的bug", "v2.1": "联动删除历史记录", @@ -559,6 +610,7 @@ "icon": "Linkace_C.png", "author": "jxxghp", "level": 1, + "v2": true, "history": { "v1.6": "增强API安全性" } @@ -570,7 +622,8 @@ "version": "1.2", "icon": "Bookstack_A.png", "author": "jxxghp", - "level": 1 + "level": 1, + "v2": true }, "RemoteIdentifiers": { "name": "共享识别词", @@ -579,7 +632,8 @@ "version": "2.2", "icon": "words.png", "author": "honue", - "level": 1 + "level": 1, + "v2": true }, "NeoDBSync": { "name": "NeoDB 想看", @@ -589,6 +643,7 @@ "icon": "NeoDB.jpeg", "author": "hcplantern", "level": 1, + "v2": true, "history": { "v1.1": "直接添加订阅,不提前进行搜索下载" } @@ -643,7 +698,8 @@ "version": "1.1", "icon": "ipAddress.png", "author": "DzAvril", - "level": 1 + "level": 1, + "v2": true }, "TrackerEditor": { "name": "Tracker替换", @@ -652,7 +708,8 @@ "version": "1.5", "icon": "trackereditor_A.png", "author": "honue", - "level": 1 + "level": 1, + "v2": true }, "ContractCheck": { "name": "契约检查", @@ -666,7 +723,8 @@ "v1.4": "支持仪表板组件显示", "v1.3": "修复观众做种数据异常问题", "v1.2": "修复契约检查无数据返回的问题" - } + }, + "v2": true }, "FeiShuMsg": { "name": "飞书机器人消息通知", @@ -675,7 +733,8 @@ "version": "1.0", "icon": "FeiShu_A.png", "author": "InfinityPacer", - "level": 2 + "level": 2, + "v2": true }, "IyuuAuth": { "name": "IYUU站点绑定", @@ -685,6 +744,7 @@ "icon": "Iyuu_A.png", "author": "jxxghp", "level": 1, + "v2": true, "history": { "v1.1": "修复IYUU站点绑定失败问题" } @@ -696,17 +756,20 @@ "version": "1.0", "icon": "Ntfy_A.png", "author": "lethargicScribe", - "level": 1 + "level": 1, + "v2": true }, "TmdbWallpaper": { "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每日壁纸时文件名错乱的问题" } }, @@ -714,10 +777,14 @@ "name": "MoviePilot服务器监控", "description": "在仪表板中实时显示MoviePilot公共服务器状态。", "labels": "仪表板", - "version": "1.0", + "version": "1.1", "icon": "Duplicati_A.png", "author": "jxxghp", - "level": 1 + "level": 1, + "v2": true, + "history": { + "v1.1": "增加详情界面显示" + } }, "CleanInvalidSeed": { "name": "清理QB无效做种", @@ -751,6 +818,7 @@ "icon": "TrendingShow.jpg", "author": "jxxghp", "level": 1, + "v2": true, "history": { "v1.3": "调整组件大小", "v1.2": "不同屏幕大小,支持分开设置" @@ -763,17 +831,20 @@ "version": "1.1", "icon": "Calibre_B.png", "author": "jxxghp", - "level": 1 + "level": 1, + "v2": true }, "ZvideoHelper": { "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": "支持将极影视评分修改为豆瓣评分", @@ -788,5 +859,77 @@ "icon": "Mosquitto_A.png", "author": "blacklips", "level": 1 + }, + "DingdingMsg": { + "name": "钉钉机器人", + "description": "支持使用钉钉机器人发送消息通知。", + "labels": "消息通知,钉钉机器人", + "version": "1.12", + "icon": "Dingding_A.png", + "author": "nnlegenda", + "level": 1, + "v2": true + }, + "DynamicWeChat": { + "name": "动态企微可信IP", + "description": "修改企微应用可信IP,支持Srever酱等第三方通知。验证码以?结尾发送到企业微信应用", + "labels": "消息通知", + "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进入正式版,显示了一个没用的参数" + } + }, + "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" + } + }, + "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变动的问题,增加开关选项" + } + }, + "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/package.v2.json b/package.v2.json new file mode 100644 index 0000000..45d2514 --- /dev/null +++ b/package.v2.json @@ -0,0 +1,298 @@ +{ + "SiteStatistic": { + "name": "站点数据统计", + "description": "站点统计数据图表。", + "labels": "站点,仪表板", + "version": "1.4.1", + "icon": "statistic.png", + "author": "lightolly,jxxghp", + "level": 2, + "history": { + "v1.4.1": "支持数据刷新时发送消息通知", + "v1.3": "远程刷新命令移植到主程序", + "v1.2": "继续修复增量数据统计问题", + "v1.1": "修复增量数据统计问题", + "v1.0": "MoviePilot V2 版本站点数据统计插件" + } + }, + "BrushFlow": { + "name": "站点刷流", + "description": "自动托管刷流,将会提高对应站点的访问频率。", + "labels": "刷流,仪表板", + "version": "4.0.1", + "icon": "brush.jpg", + "author": "jxxghp,InfinityPacer", + "level": 2, + "history": { + "v4.0.1": "NexusPHP 站点支持自动跳过下载提示页调整为站点独立配置项", + "v4.0": "NexusPHP 站点支持自动跳过下载提示页", + "v3.9": "MoviePilot V2 版本站点刷流插件" + } + }, + "AutoSignIn": { + "name": "站点自动签到", + "description": "自动模拟登录、签到站点。", + "labels": "站点", + "version": "2.5", + "icon": "signin.png", + "author": "thsrite", + "level": 2, + "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 版本下载任务分类与标签插件" + } + }, + "MediaServerRefresh": { + "name": "媒体库服务器刷新", + "description": "入库后自动刷新Emby/Jellyfin/Plex服务器海报墙。", + "labels": "媒体库", + "version": "1.3.1", + "icon": "refresh2.png", + "author": "jxxghp", + "level": 1, + "history": { + "v1.3": "MoviePilot V2 版本媒体库服务器刷新插件", + "v1.3.1": "修复兼容性问题" + } + }, + "MediaServerMsg": { + "name": "媒体库服务器通知", + "description": "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。", + "labels": "消息通知,媒体库", + "version": "1.5", + "icon": "mediaplay.png", + "author": "jxxghp", + "level": 1, + "history": { + "v1.5": "支持独立控制媒体服务器通知", + "v1.4": "MoviePilot V2 版本媒体库服务器通知插件" + } + }, + "ChatGPT": { + "name": "ChatGPT", + "description": "消息交互支持与ChatGPT对话。", + "labels": "消息通知,识别", + "version": "2.0.1", + "icon": "Chatgpt_A.png", + "author": "jxxghp", + "level": 1, + "history": { + "v2.0.1": "修复辅助识别", + "v2.0": "适配MoviePilot V2 版本,采用链式事件机制" + } + }, + "TorrentTransfer": { + "name": "自动转移做种", + "description": "定期转移下载器中的做种任务到另一个下载器。", + "labels": "做种", + "version": "1.7.1", + "icon": "seed.png", + "author": "jxxghp", + "level": 2, + "history": { + "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 版本" + } + }, + "FFmpegThumb": { + "name": "FFmpeg缩略图", + "description": "TheMovieDb没有背景图片时使用FFmpeg截取视频文件缩略图", + "labels": "刮削", + "version": "2.0", + "icon": "ffmpeg.png", + "author": "jxxghp", + "level": 1, + "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文件读取失败时任务中断问题" + } + }, + "PersonMeta": { + "name": "演职人员刮削", + "description": "刮削演职人员图片以及中文名称。", + "labels": "媒体库,刮削", + "version": "2.0.1", + "icon": "actor.png", + "author": "jxxghp", + "level": 1, + "history": { + "v2.0": "兼容MoviePilot V2 版本", + "v1.4": "人物图片调整为优先从TMDB获取,避免douban图片CDN加载过慢的问题", + "v1.3": "修复v1.8.5版本后刮削报错问题" + } + }, + "SpeedLimiter": { + "name": "播放限速", + "description": "外网播放媒体库视频时,自动对下载器进行限速。", + "labels": "网络", + "version": "2.1", + "icon": "Librespeed_A.png", + "author": "Shurelol", + "level": 1, + "history": { + "v2.1": "修复表单参数", + "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 版本" + } + }, + "TorrentRemover": { + "name": "自动删种", + "description": "自动删除下载器中的下载任务。", + "labels": "做种", + "version": "2.1.1", + "icon": "delete.jpg", + "author": "jxxghp", + "level": 2, + "history": { + "v2.1.1": "修复兼容MoviePilot V2 版本", + "v2.0": "兼容MoviePilot V2 版本" + } + }, + "IYUUAutoSeed": { + "name": "IYUU自动辅种", + "description": "基于IYUU官方Api实现自动辅种。", + "labels": "做种,IYUU", + "version": "2.2", + "icon": "IYUU.png", + "author": "jxxghp", + "level": 2, + "history": { + "v2.2": "修复种子校验服务未生效", + "v2.1": "调整IYUU最新域名", + "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 版本" + } + }, + "HistoryToV2": { + "name": "历史记录迁移", + "description": "将MoviePilot V1版本的整理历史记录迁移至V2版本。", + "labels": "整理,历史记录", + "version": "1.1", + "icon": "Moviepilot_A.png", + "author": "jxxghp", + "level": 1, + "history": { + "v1.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" + } + }, + "ChineseSubFinder": { + "name": "ChineseSubFinder", + "description": "整理入库时通知ChineseSubFinder下载字幕。", + "labels": "字幕", + "version": "2.0", + "icon": "chinesesubfinder.png", + "author": "jxxghp", + "level": 1, + "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" + } + }, + "PlayletCategory": { + "name": "短剧自动分类", + "description": "网络短剧自动整理到独立的分类目录。", + "labels": "文件整理", + "version": "2.1", + "icon": "Amule_A.png", + "author": "jxxghp,longqiuyu", + "level": 1, + "history": { + "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/autoclean/__init__.py b/plugins.v2/autoclean/__init__.py new file mode 100644 index 0000000..7d909ac --- /dev/null +++ b/plugins.v2/autoclean/__init__.py @@ -0,0 +1,605 @@ +import time +from collections import defaultdict +from datetime import datetime, timedelta +from typing import Any, List, Dict, Tuple, Optional + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +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.log import logger +from app.plugins import _PluginBase +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 = "2.0" + # 插件作者 + 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"]: + dest_fileitem = schemas.FileItem(**history.dest_fileitem) + StorageChain().delete_file(dest_fileitem) + # 删除记录 + self._transferhis.delete(history.id) + # 删除源文件 + if clean_type in ["src", "all"]: + src_fileitem = schemas.FileItem(**history.src_fileitem) + StorageChain().delete_file(src_fileitem) + # 发送事件 + 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/autosignin/__init__.py b/plugins.v2/autosignin/__init__.py new file mode 100644 index 0000000..4574daf --- /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.5" + # 插件作者 + 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}' diff --git a/plugins.v2/brushflow/__init__.py b/plugins.v2/brushflow/__init__.py new file mode 100644 index 0000000..08876ad --- /dev/null +++ b/plugins.v2/brushflow/__init__.py @@ -0,0 +1,3855 @@ +import base64 +import json +import random +import re +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, parse_qsl, urlencode, urlunparse + +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.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, ServiceInfo +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") + 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.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_delete = config.get("proxy_delete", False) + 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 = "刷流" + # 站点独立配置 + 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_delete", + "qb_category", + "site_hr_active", + "site_skip_tips" + # 当新增支持字段时,仅在此处添加字段名 + } + 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.v2/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", + "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_delete": false, + "qb_category": "刷流", + "site_hr_active": true, + "site_skip_tips": 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 = "4.0.1" + # 插件作者 + plugin_author = "jxxghp,InfinityPacer" + # 作者主页 + author_url = "https://github.com/InfinityPacer" + # 插件配置项ID前缀 + plugin_config_prefix = "brushflow_" + # 加载顺序 + plugin_order = 21 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + sites_helper = None + site_oper = None + torrents_chain = None + subscribe_oper = None + downloader_helper = None + # 刷流配置 + _brush_config = None + # Brush任务是否启动 + _task_brush_enable = False + # 订阅缓存信息 + _subscribe_infos = None + # Brush定时 + _brush_interval = 10 + # Check定时 + _check_interval = 5 + # 退出事件 + _event = threading.Event() + _scheduler = None + # tabs + _tabs = None + + # endregion + + def init_plugin(self, config: dict = None): + 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: + 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.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] + ] + + self.__update_config() + + if brush_config.clear_task: + self.__clear_tasks() + brush_config.clear_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 brush_config.brushsites: + logger.info(f"站点刷流定时服务停止,没有配置站点") + + # 如果开启&存在站点时,才需要启用后台任务 + self._task_brush_enable = brush_config.enabled and brush_config.brushsites + + # 如果下载器都没有配置,那么这里也不需要继续 + if not brush_config.downloader: + brush_config.enabled = False + self.__update_config() + logger.info(f"站点刷流服务停止,没有配置下载器") + return + + if not self.service_info: + return + + # 检查是否启用了一次性任务 + if brush_config.onlyonce: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + logger.info(f"站点刷流服务启动,立即运行一次") + self._scheduler.add_job(self.brush, "date", + run_date=datetime.now( + tz=pytz.timezone(settings.TZ) + ) + timedelta(seconds=3), + name="站点刷流服务") + + logger.info(f"站点刷流检查服务启动,立即运行一次") + self._scheduler.add_job(self.check, "date", + run_date=datetime.now( + tz=pytz.timezone(settings.TZ) + ) + timedelta(seconds=3), + name="站点刷流检查服务") + + # 关闭一次性开关 + brush_config.onlyonce = False + self.__update_config() + + # 存在任务则启动任务 + if self._scheduler.get_jobs(): + # 启动服务 + 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 + + @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"站点刷流定时服务启动,时间间隔 {self._brush_interval} 分钟") + services.append({ + "id": "BrushFlow", + "name": "站点刷流服务", + "trigger": "interval", + "func": self.brush, + "kwargs": {"minutes": self._brush_interval} + }) + + if brush_config.enabled: + logger.info(f"站点刷流检查定时服务启动,时间间隔 {self._check_interval} 分钟") + services.append({ + "id": "BrushFlowCheck", + "name": "站点刷流检查服务", + "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, key: str, **kwargs) -> 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.sites_helper.get_indexers()] + # 下载器选项 + downloader_options = [{"title": config.name, "value": config.name} + for config in self.downloader_helper.get_configs().values()] + 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': downloader_options + } + } + ] + }, + { + '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': 'proxy_delete', + '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': 'enable_site_config', + 'label': '站点独立配置', + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "dialog_closed", + "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.v2/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.v2/brushflowlowfreq/README.md', + 'target': '_blank' + }, + 'content': [ + { + 'component': 'u', + 'text': 'README' + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": True, + "onlyonce": False, + "clear_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_delete": False, + "freeleech": "free", + "hr": "yes", + "enable_site_config": 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 or not self.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.site_oper.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.site_oper.get(siteid) + if not siteinfo: + logger.warning(f"站点不存在:{siteid}") + return True + + logger.info(f"开始获取站点 {siteinfo.name} 的新种子 ...") + torrents = self.torrents_chain.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.warning(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.PluginTriggered, data={ + "plugin_id": self.__class__.__name__, + "event_name": "brushflow_download_added", + "hash": hash_string, + "data": torrent_task, + "downloader": self.service_info.name + }) + 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.warning(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 or not self.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.downloader + seeding_torrents, error = downloader.get_torrents() + if error: + logger.warning("连接下载器出错,将在下个时间周期重试") + 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 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): + 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 not self.downloader_helper.is_downloader("qbittorrent", service=self.service_info): + 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: + """ + 根据条件删除种子并获取已删除列表 + """ + 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种子后删除种子并获取已删除列表 + """ + 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.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): + """ + 处理已经被删除,但是任务记录中还没有被标记删除的种子 + """ + # 先通过获取的全量种子,判断已经被删除,但是任务记录中还没有被标记删除的种子 + 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, + "delete_except_tags": brush_config.delete_except_tags, + "except_subscribe": brush_config.except_subscribe, + "brush_sequential": brush_config.brush_sequential, + "proxy_delete": brush_config.proxy_delete, + "active_time_range": brush_config.active_time_range, + "qb_category": brush_config.qb_category, + "enable_site_config": brush_config.enable_site_config, + "site_config": brush_config.site_config, + "_tabs": self._tabs + } + + # 使用update_config方法或其等效方法更新配置 + self.update_config(config_mapping) + + @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 __reset_download_url(self, torrent_url, site_id) -> str: + """ + 处理下载地址 + """ + try: + # 检查 torrent_url 是否为有效的下载 URL,并且 site 是 NexusPHP + 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 + 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 = 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)}") + return torrent_url + + 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.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 + + 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 + # 生成随机Tag + tag = StringUtils.generate_random_str(10) + # 如果开启代理下载以及种子地址不是磁力地址,则请求种子到内存再传入下载器 + 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下载种子失败,继续尝试传递种子地址到下载器进行下载") + if torrent_content: + 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 = 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 self.downloader_helper.is_downloader("transmission", service=self.service_info): + # 如果开启代理下载以及种子地址不是磁力地址,则请求种子到内存再传入下载器 + 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下载种子失败,继续尝试传递种子地址到下载器进行下载") + if torrent_content: + 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: + downloader.change_torrent(hash_string=torrent.hashString, + upload_limit=up_speed, + download_limit=down_speed) + return torrent.hashString + return None + + def __qb_torrents_reannounce(self, torrent_hashes: List[str]): + """强制重新汇报""" + downloader = self.downloader + if not downloader: + return + + if not downloader.qbc: + return + + if not torrent_hashes: + return + + try: + # 重新汇报 + downloader.qbc.torrents_reannounce(torrent_hashes=torrent_hashes) + except Exception as err: + logger.error(f"强制重新汇报失败:{str(err)}") + + def __get_hash(self, torrent: Any): + """ + 获取种子hash + """ + try: + 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)) + return "" + + def __get_all_hashes(self, torrents): + """ + 获取torrents列表中所有种子的Hash值 + + :param torrents: 包含种子信息的列表 + :return: 包含所有Hash值的列表 + """ + try: + all_hashes = [] + for torrent in torrents: + # 根据下载器类型获取Hash值 + 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) + return all_hashes + except Exception as e: + print(str(e)) + return [] + + def __get_label(self, torrent: Any): + """ + 获取种子标签 + """ + try: + return [str(tag).strip() for tag in torrent.get("tags").split(',')] \ + if self.downloader_helper.is_downloader("qbittorrent", + service=self.service_info) 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()) + # QB + if self.downloader_helper.is_downloader("qbittorrent", service=self.service_info): + """ + { + "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() + + downloader = self.downloader + if not downloader: + return ret_info + + 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 + + def __get_downloading_count(self) -> int: + """ + 获取正在下载的任务数量 + """ + try: + brush_config = self.__get_brush_config() + downloader = self.downloader + if not downloader: + return 0 + + torrents = downloader.get_downloading_torrents(tags=brush_config.brush_tag) + if torrents is None: + logger.warning("获取下载数量失败,可能是下载器连接发生异常") + 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.subscribe_oper.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 __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.sites_helper.get_indexer(domain) + if site_info: + return site_info.get("id"), site_info.get("name") + + # 当找不到对应的站点信息时,返回一个默认值 + return 0, domain diff --git a/plugins.v2/chatgpt/__init__.py b/plugins.v2/chatgpt/__init__.py new file mode 100644 index 0000000..bf64853 --- /dev/null +++ b/plugins.v2/chatgpt/__init__.py @@ -0,0 +1,263 @@ +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.1" + # 插件作者 + 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 self._recognize: + return + 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 {} 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)) 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)) diff --git a/plugins.v2/downloadsitetag/__init__.py b/plugins.v2/downloadsitetag/__init__.py new file mode 100644 index 0000000..26b8275 --- /dev/null +++ b/plugins.v2/downloadsitetag/__init__.py @@ -0,0 +1,859 @@ +import datetime +import threading +from typing import List, Tuple, Dict, Any, Optional + +import pytz +from app.helper.sites import SitesHelper +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +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): + # 插件名称 + plugin_name = "下载任务分类与标签" + # 插件描述 + plugin_desc = "自动给下载任务分类与打站点标签、剧集名称标签" + # 插件图标 + plugin_icon = "Youtube-dl_B.png" + # 插件版本 + plugin_version = "2.2" + # 插件作者 + 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() + # 私有属性 + downloadhistory_oper = None + sites_helper = None + downloader_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 + _downloaders = None + + def init_plugin(self, config: dict = None): + self.downloadhistory_oper = DownloadHistoryOper() + self.downloader_helper = DownloaderHelper() + 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 "动漫" + self._downloaders = config.get("downloaders") + + # 停止现有任务 + 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() + + @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 + + @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): + """ + 补全下载历史的标签与分类 + """ + if not self.service_infos: + return + 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 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} 获取下载器失败 {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=service.type) + 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=service.type) + # 获取种子hash + _hash = self._get_hash(torrent=torrent, dl_type=service.type) + if not _hash: + continue + # 获取种子当前标签 + 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: + # 如果找到已处理种子的历史, 表明当前种子是辅种, 否则创建一个空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=service.type) + 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 service.type == "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(service=service, _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 + + @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, 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 = service.instance + 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 _hash and _torrent: + # 下载器api不通用, 因此需分开处理 + if service.type == "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"下载器 {service.name} 种子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=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}下载器: {service.name} 种子id: {_hash} {(' 标签: ' + ','.join(_tags)) if _tags else ''} {(' 分类: ' + _cat) if _cat else ''}") + + @eventmanager.register(EventType.DownloadAdded) + def download_added(self, event: Event): + """ + 添加下载事件 + """ + if not self.get_state(): + return + + if not event.event_data: + 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 + _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(service=service, _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 + }, + 'content': [ + { + 'component': 'VCheckboxBtn', + 'props': { + 'model': 'onlyonce', + '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': 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)) \ No newline at end of file diff --git a/plugins.v2/ffmpegthumb/__init__.py b/plugins.v2/ffmpegthumb/__init__.py new file mode 100644 index 0000000..b645f31 --- /dev/null +++ b/plugins.v2/ffmpegthumb/__init__.py @@ -0,0 +1,363 @@ +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 = "2.0" + # 插件作者 + 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 + 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}") + 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/historytov2/__init__.py b/plugins.v2/historytov2/__init__.py new file mode 100644 index 0000000..e163abe --- /dev/null +++ b/plugins.v2/historytov2/__init__.py @@ -0,0 +1,336 @@ +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.1" + # 插件作者 + 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: + 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): + """ + 关闭开关 + """ + 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 diff --git a/plugins.v2/iyuuautoseed/__init__.py b/plugins.v2/iyuuautoseed/__init__.py new file mode 100644 index 0000000..130da1c --- /dev/null +++ b/plugins.v2/iyuuautoseed/__init__.py @@ -0,0 +1,1257 @@ +import os +import re +from datetime import datetime, timedelta +from threading import Event +from typing import Any, Dict, List, Optional, Tuple + +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.downloader import DownloaderHelper +from app.helper.sites import SitesHelper +from app.helper.torrent import TorrentHelper +from app.log import logger +from app.plugins import _PluginBase +from app.plugins.iyuuautoseed.iyuu_helper import IyuuHelper +from app.schemas import NotificationType, ServiceInfo +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 = "2.2" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "iyuuautoseed_" + # 加载顺序 + plugin_order = 17 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + _scheduler = None + iyuu_helper = None + downloader_helper = None + sites_helper = None + site_oper = None + torrent_helper = 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_helper = SitesHelper() + self.site_oper = SiteOper() + self.torrent_helper = TorrentHelper() + self.downloader_helper = DownloaderHelper() + # 读取配置 + 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.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() + + # 停止现有任务 + self.stop_service() + + # 启动定时任务 & 立即运行一次 + if self.get_state() or self._onlyonce: + self.iyuu_helper = IyuuHelper(token=self._token) + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + 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._clearcache: + # 关闭清除缓存开关 + self._clearcache = False + # 保存配置 + 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]]: + """ + 服务信息 + """ + 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 + + @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.site_oper.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, + 'clearable': True, + 'model': 'downloaders', + 'label': '下载器', + 'items': [{"title": config.name, "value": config.name} + for config in self.downloader_helper.get_configs().values()] + } + } + ] + }, + { + '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 auto_seed(self): + """ + 开始辅种 + """ + if not self.iyuu_helper or not self.service_infos: + return + logger.info("开始辅种任务 ...") + + # 计数器初始化 + self.total = 0 + self.realtotal = 0 + self.success = 0 + self.exist = 0 + self.fail = 0 + self.cached = 0 + # 扫描下载器辅种 + for service in self.service_infos.values(): + downloader = service.name + downloader_obj = service.instance + logger.info(f"开始扫描下载器 {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=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=torrent, dl_type=service.type) + + 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=torrent, dl_type=service.type) + 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=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 + + 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, + service=service) + # 触发校验检查 + 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.service_infos: + return + if not self._recheck_torrents: + return + if self._is_recheck_running: + return + self._is_recheck_running = True + 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} 的校验任务 ...") + # 获取下载器中的种子状态 + torrents, _ = downloader_obj.get_torrents(ids=recheck_torrents) + if torrents: + can_seeding_torrents = [] + for torrent in torrents: + # 获取种子hash + 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)} 个任务校验完成,开始辅种 ...") + # 开始任务 + 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, service: ServiceInfo): + """ + 执行一批种子的辅种 + """ + if not hash_strs: + return + logger.info(f"下载器 {service.name} 开始查询辅种,数量:{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.iyuu_helper.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, + service=service, + 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=service.name, + success_torrents=success_torrents) + + logger.info(f"下载器 {service.name} 辅种完成") + + 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, service: ServiceInfo, 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 service.type == "qbittorrent": + # 生成随机Tag + tag = StringUtils.generate_random_str(10) + + torrent_tags.append(tag) + + 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 = service.instance.get_torrent_id_by_tag(tags=tag) + if not torrent_hash: + logger.error(f"{service.name} 下载任务添加成功,但获取任务信息失败!") + return None + return torrent_hash + elif service.type == "transmission": + # 添加任务 + torrent = service.instance.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"不支持的下载器:{service.type}") + return None + + def __download_torrent(self, seed: dict, service: ServiceInfo, 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.iyuu_helper.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_helper.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 = 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_helper.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_helper.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(service=service, + 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(service.name): + self._recheck_torrents[service.name] = [] + self._recheck_torrents[service.name].append(download_id) + # TR会自动校验 + if service.type == "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) + return f"{site.get('url')}torrents/download/{tid}.{rsskey}" + + 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..d322385 --- /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://2025.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..682e969 --- /dev/null +++ b/plugins.v2/libraryscraper/__init__.py @@ -0,0 +1,460 @@ +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 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 +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 = "2.0" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "libraryscraper_" + # 加载顺序 + plugin_order = 7 + # 可使用的用户级别 + user_level = 1 + + # 私有属性 + transferhis = None + mediachain = 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): + self.mediachain = MediaChain() + # 读取配置 + 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'}, + ] + } + } + ] + }, + { + '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") + # 需要适削的媒体文件夹 + scraper_paths = [] + 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 + # 识别是电影还是电视剧 + if not mtype: + file_meta = MetaInfoPath(file_path) + mtype = file_meta.type + if mtype == MediaType.TV: + 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_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: + logger.info(f"开始刮削目录:{item[0]} ...") + self.__scrape_dir(path=item[0], mtype=item[1]) + else: + logger.info(f"未发现需要刮削的目录") + + def __scrape_dir(self, path: Path, mtype: MediaType): + """ + 削刮一个目录,该目录必须是媒体文件目录 + """ + # 优先读取本地nfo文件 + tmdbid = None + if mtype == MediaType.MOVIE: + # 电影 + movie_nfo = path / "movie.nfo" + if movie_nfo.exists(): + tmdbid = self.__get_tmdbid_from_nfo(movie_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 = 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=mtype) + else: + # 按名称识别 + meta = MetaInfoPath(path) + meta.type = mtype + mediainfo = self.chain.recognize_media(meta=meta) + if not mediainfo: + logger.warn(f"未识别到媒体信息:{path}") + 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.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): + """ + 从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..80e4105 --- /dev/null +++ b/plugins.v2/mediaservermsg/__init__.py @@ -0,0 +1,379 @@ +import time +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.plugins import _PluginBase +from app.schemas import WebhookEventInfo, ServiceInfo +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.5" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "mediaservermsg_" + # 加载顺序 + plugin_order = 14 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + mediaserver_helper = None + _enabled = False + _add_play_link = False + _mediaservers = None + _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): + self.mediaserver_helper = MediaServerHelper() + if config: + self._enabled = config.get("enabled") + self._types = config.get("types") or [] + 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 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 + + @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': '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()] + } + } + ] + } + ] + }, + { + '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=API_TOKEN&source=媒体服务器名(3001端口),其中 API_TOKEN 为设置的 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 + + 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(): + # 刷新过期时间 + 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) + + 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": + # 停止播放消息,添加到过期字典 + 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..69d1b4b --- /dev/null +++ b/plugins.v2/mediaserverrefresh/__init__.py @@ -0,0 +1,223 @@ +import time +from pathlib import Path +from typing import Any, List, Dict, Tuple, Optional + +from app.core.context import MediaInfo +from app.core.event import eventmanager, Event +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): + # 插件名称 + plugin_name = "媒体库服务器刷新" + # 插件描述 + plugin_desc = "入库后自动刷新Emby/Jellyfin/Plex服务器海报墙。" + # 插件图标 + plugin_icon = "refresh2.png" + # 插件版本 + plugin_version = "1.3.1" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "mediaserverrefresh_" + # 加载顺序 + plugin_order = 14 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + mediaserver_helper = None + _enabled = False + _delay = 0 + _mediaservers = None + + 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]]: + """ + 服务信息 + """ + 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 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': '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': [ + { + '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 self.service_infos: + return + + if self._delay: + logger.info(f"延迟 {self._delay} 秒后刷新媒体库... ") + time.sleep(float(self._delay)) + + # 入库数据 + 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( + title=mediainfo.title, + year=mediainfo.year, + type=mediainfo.type, + category=mediainfo.category, + target_path=Path(transferinfo.target_diritem.path) + ) + ] + + for name, service in self.service_infos.items(): + # Emby + if self.mediaserver_helper.is_media_server("emby", service=service): + service.instance.refresh_library_by_items(items) + + # Jeyllyfin + if self.mediaserver_helper.is_media_server("jellyfin", service=service): + # FIXME Jellyfin未找到刷新单个项目的API + service.instance.refresh_root_library() + + # Plex + if self.mediaserver_helper.is_media_server("plex", service=service): + service.instance.refresh_library_by_items(items) + + def stop_service(self): + """ + 退出插件 + """ + pass 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)) diff --git a/plugins.v2/personmeta/__init__.py b/plugins.v2/personmeta/__init__.py new file mode 100644 index 0000000..f1c5049 --- /dev/null +++ b/plugins.v2/personmeta/__init__.py @@ -0,0 +1,1107 @@ +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.helper.mediaserver import MediaServerHelper +from app.log import logger +from app.plugins import _PluginBase +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 +from app.utils.string import StringUtils + + +class PersonMeta(_PluginBase): + # 插件名称 + plugin_name = "演职人员刮削" + # 插件描述 + plugin_desc = "刮削演职人员图片以及中文名称。" + # 插件图标 + plugin_icon = "actor.png" + # 插件版本 + plugin_version = "2.0.1" + # 插件作者 + 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 + 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") + 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._mediaservers = config.get("mediaservers") or [] + + # 停止现有任务 + 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, + "mediaservers": self._mediaservers + }) + + 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 + }, + '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': [ + { + '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 + + 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): + """ + 根据事件实时刮削演员信息 + """ + 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, server_type=existsinfo.server_type, + item=iteminfo, mediainfo=mediainfo, season=meta.begin_season) + + def scrap_library(self): + """ + 扫描整个媒体库,刮削演员信息 + """ + # 所有媒体服务器 + service_infos = self.service_infos() + if not service_infos: + return + for server, service in service_infos.items(): + # 扫描所有媒体库 + 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, server_type=service.type) + logger.info(f"{item.title} 的演员信息刮削完成") + logger.info(f"媒体库 {library.name} 的演员信息刮削完成") + logger.info(f"服务器 {server} 的演员信息刮削完成") + + def __update_peoples(self, server: str, server_type: 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, server_type=server_type, + 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, server_type=server_type, + itemid=itemid, iteminfo=iteminfo) + + def __update_item(self, server: str, item: MediaServerItem, server_type: str = None, + 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, server_type=server_type, 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, 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, server_type=server_type, + 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_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, 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, 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, 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, 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, server_type: 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, server_type=server_type, + 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, server_type=server_type, 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, server_type=server_type, + 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 [] + + 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媒体项详情 + """ + try: + url = f'[HOST]emby/Users/[USER]/Items/{itemid}?' \ + f'Fields=ChannelMappingInfo&api_key=[APIKEY]' + res = service.instance.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 = service.instance.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 = service.instance.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_type == "emby": + return __get_emby_iteminfo() + elif server_type == "jellyfin": + return __get_jellyfin_iteminfo() + else: + return __get_plex_iteminfo() + + def get_items(self, server: str, server_type: str, parentid: str, mtype: str = None) -> dict: + """ + 获得媒体的所有子媒体项 + """ + service = self.service_infos(server_type).get(server) + if not service: + logger.warn(f"未找到媒体服务器 {server} 的实例") + return {} + + 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 = service.instance.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 = service.instance.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 = service.instance.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_type == "emby": + return __get_emby_items() + elif server_type == "jellyfin": + return __get_jellyfin_items() + else: + return __get_plex_items() + + 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 = service.instance.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 = service.instance.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 = service.instance.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_type == "emby": + return __set_emby_iteminfo() + elif server_type == "jellyfin": + return __set_jellyfin_iteminfo() + else: + return __set_plex_iteminfo() + + @retry(RequestException, logger=logger) + 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(): + """ + 下载图片 + """ + 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 = service.instance.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 = service.instance.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 = 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_type == "emby": + # 下载图片获取base64 + image_base64 = __download_image() + if image_base64: + return __set_emby_item_image(image_base64) + elif server_type == "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/playletcategory/__init__.py b/plugins.v2/playletcategory/__init__.py new file mode 100644 index 0000000..109d4e0 --- /dev/null +++ b/plugins.v2/playletcategory/__init__.py @@ -0,0 +1,357 @@ +import random +import time +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.file import FileItem +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.1" + # 插件作者 + plugin_author = "jxxghp,longqiuyu" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "playletcategory_" + # 加载顺序 + plugin_order = 29 + # 可使用的用户级别 + auth_level = 1 + + _enabled = False + _notify = True + _delay: int = 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): + """ + 根据事件实时刮削剧集组信息 + """ + logger.debug(f"触发短剧分类!") + if not event: + logger.debug(f"短剧分类异常:{event}") + return + if not self.get_state(): + logger.debug(f"短剧分类插件配置不完整!") + return + try: + event_data = event.event_data + media_info: MediaInfo = event_data.get("mediainfo") + transfer_info: TransferInfo = event_data.get("transferinfo") + if not media_info or not transfer_info: + return + if not transfer_info.success: + logger.debug(f"整理失败不做处理!") + return + 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 media_info.type != MediaType.TV: + logger.info(f"{target_path} 不是电视剧,跳过分类处理") + return + if int(self._delay) > 0: + # 进行延迟 + time.sleep(int(self._delay)) + # 加锁 + with lock: + file_list = transfer_info.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: + 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} 需要分类处理,开始移动文件...") + 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: + logger.info(f"短剧分类异常:{str(e)}") + + @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) -> bool: + """ + 移动文件到分类目录 + :param target_path: 电视剧时为季的目录 + """ + logger.debug(f"target_path: {target_path}") + if not target_path.exists(): + logger.warning(f"目标路径 {target_path} 不存在,跳过处理。") + return False + if target_path.is_file(): + target_path = target_path.parent + # 剧集的根目录 + tv_path = target_path + # 新的文件目录 + new_path = Path(self._category_dir) / target_path.name + logger.debug(f"{new_path}") + if not new_path.exists(): + # 移动目录 + try: + shutil.move(target_path, new_path) + except Exception as e: + logger.error(f"移动文件失败:{e}") + return False + else: + # 遍历目录下的所有文件,并移动到目的目录 + for file in target_path.iterdir(): + logger.debug(f"{file}") + if file.is_file(): + try: + # 相对路径 + relative_path = file.relative_to(target_path) + logger.debug(f"relative_path:{to_path}") + to_path = new_path / relative_path + logger.debug(f"to_path:{to_path}") + shutil.move(file, to_path) + except Exception as e: + logger.error(f"移动文件失败:{e}") + 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: + shutil.rmtree(target_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} 目录", + ) + return True + + def stop_service(self): + """ + 停止服务 + """ + pass 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)) diff --git a/plugins.v2/rsssubscribe/__init__.py b/plugins.v2/rsssubscribe/__init__.py new file mode 100644 index 0000000..4480b2d --- /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 = "2.0" + # 插件作者 + 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_groups = self.systemconfig.get(SystemConfigKey.SubscribeFilterRuleGroups) + # 解析数据 + 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_groups=filter_groups, + 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/sitestatistic/__init__.py b/plugins.v2/sitestatistic/__init__.py new file mode 100644 index 0000000..e0f1651 --- /dev/null +++ b/plugins.v2/sitestatistic/__init__.py @@ -0,0 +1,985 @@ +import warnings +from datetime import datetime, timedelta +from threading import Lock +from typing import Optional, Any, List, Dict, Tuple + +from app import schemas +from app.chain.site import SiteChain +from app.core.config import settings +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, NotificationType +from app.utils.string import StringUtils + +warnings.filterwarnings("ignore", category=FutureWarning) + +lock = Lock() + + +class SiteStatistic(_PluginBase): + # 插件名称 + plugin_name = "站点数据统计" + # 插件描述 + plugin_desc = "站点统计数据图表。" + # 插件图标 + plugin_icon = "statistic.png" + # 插件版本 + plugin_version = "1.4.1" + # 插件作者 + plugin_author = "lightolly,jxxghp" + # 作者主页 + author_url = "https://github.com/lightolly" + # 插件配置项ID前缀 + plugin_config_prefix = "sitestatistic_" + # 加载顺序 + plugin_order = 1 + # 可使用的用户级别 + auth_level = 2 + + # 配置属性 + siteoper = None + siteshelper = None + sitechain = None + _enabled: bool = False + _onlyonce: bool = False + _dashboard_type: str = "today" + _notify_type = "" + + def init_plugin(self, config: dict = None): + self.siteoper = SiteOper() + self.siteshelper = SitesHelper() + self.sitechain = SiteChain() + + # 停止现有任务 + self.stop_service() + + # 配置 + if config: + 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.sitechain.refresh_userdatas() + self.update_config(config=config) + + 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说明" + }] + """ + return [{ + "path": "/refresh_by_domain", + "endpoint": self.refresh_by_domain, + "methods": ["GET"], + "summary": "刷新站点数据", + "description": "刷新对应域名的站点数据", + }] + + def get_service(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'dashboard_type', + 'label': '仪表板组件', + 'items': [ + {'title': '今日数据', 'value': 'today'}, + {'title': '汇总数据', 'value': 'total'}, + {'title': '所有数据', 'value': 'all'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'notify_type', + 'label': '数据刷新时发送通知', + 'items': [ + {'title': '不发送', 'value': ''}, + {'title': '今日增量数据', 'value': 'inc'}, + {'title': '累计全量数据', 'value': 'all'} + ] + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "onlyonce": False, + "dashboard_type": 'today' + } + + @eventmanager.register(EventType.SiteRefreshed) + 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() + # 转换为字典 + 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]]: + """ + 获取今天的日期、今天的站点数据、昨天的站点数据 + """ + # 获取最近所有数据 + data_list: List[SiteUserData] = self.siteoper.get_userdata() + if not data_list: + return "", [], [] + # 每个日期、每个站点只保留最后一条数据 + 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 = 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 + + @staticmethod + def __get_total_elements(today: str, stattistic_data: List[SiteUserData], yesterday_sites_data: List[SiteUserData], + 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 __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 __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值的差值(如果值为数字),返回新字典 + """ + if not d1: + return {} + if not d2: + return 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(): + if str(v).isdigit() and int(v) < 0: + d[k] = 0 + return d + + if dashboard in ['total', 'all']: + # 总上传量 + total_upload = sum([data.upload for data in stattistic_data if data.upload]) + # 总下载量 + total_download = sum([data.download for data in stattistic_data if data.download]) + # 总做种数 + total_seed = sum([data.seeding for data in stattistic_data if data.seeding]) + # 总做种体积 + total_seed_size = sum([data.seeding_size for data in stattistic_data if data.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 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[data.name] = inc + # 今日上传 + 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()] + # 今日上传数据 + 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") if v.get("download") > 0} + # 今日下载站点 + 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, key: str, **kwargs) -> 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': data.name + }, + { + 'component': 'td', + 'text': data.username + }, + { + 'component': 'td', + 'text': data.user_level + }, + { + 'component': 'td', + 'props': { + 'class': 'text-success' + }, + 'text': StringUtils.str_filesize(data.upload) + }, + { + 'component': 'td', + 'props': { + 'class': 'text-error' + }, + 'text': StringUtils.str_filesize(data.download) + }, + { + 'component': 'td', + 'text': data.ratio + }, + { + 'component': 'td', + 'text': format_bonus(data.bonus or 0) + }, + { + 'component': 'td', + 'text': data.seeding + }, + { + 'component': 'td', + 'text': StringUtils.str_filesize(data.seeding_size) + } + ] + } for data in stattistic_data + ] + + # 拼装页面 + 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): + pass + + def refresh_by_domain(self, domain: str, apikey: str) -> schemas.Response: + """ + 刷新一个站点数据,可由API调用 + """ + 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/speedlimiter/__init__.py b/plugins.v2/speedlimiter/__init__.py new file mode 100644 index 0000000..3f01708 --- /dev/null +++ b/plugins.v2/speedlimiter/__init__.py @@ -0,0 +1,680 @@ +import ipaddress +from typing import List, Tuple, Dict, Any, Optional + +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.plugins import _PluginBase +from app.schemas import NotificationType, WebhookEventInfo, ServiceInfo +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 = "2.1" + # 插件作者 + plugin_author = "Shurelol" + # 作者主页 + author_url = "https://github.com/Shurelol" + # 插件配置项ID前缀 + plugin_config_prefix = "speedlimit_" + # 加载顺序 + plugin_order = 11 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + downloader_helper = None + mediaserver_helper = None + _scheduler = 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): + self.downloader_helper = DownloaderHelper() + self.mediaserver_helper = MediaServerHelper() + # 读取配置 + 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 [] + + 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', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'clearable': True, + 'model': 'downloader', + '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': '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 + + @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.service_infos: + 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 + media_servers = self.mediaserver_helper.get_services() + if not media_servers: + return + # 查询所有媒体服务器状态 + for server, service in media_servers.items(): + # 查询播放中会话 + playing_sessions = [] + if service.type == "emby": + req_url = "[HOST]emby/Sessions?api_key=[APIKEY]" + try: + res = service.instance.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 service.type == "jellyfin": + req_url = "[HOST]Sessions?api_key=[APIKEY]" + try: + res = service.instance.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 service.type == "plex": + _plex = service.instance.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.service_infos: + 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: + service = self.service_infos.get(download) + 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 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: + 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)}") + + @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/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.v2/torrentremover/__init__.py b/plugins.v2/torrentremover/__init__.py new file mode 100644 index 0000000..f3ab683 --- /dev/null +++ b/plugins.v2/torrentremover/__init__.py @@ -0,0 +1,841 @@ +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.helper.downloader import DownloaderHelper +from app.log import logger +from app.plugins import _PluginBase +from app.schemas import NotificationType, ServiceInfo +from app.utils.string import StringUtils + +lock = threading.Lock() + + +class TorrentRemover(_PluginBase): + # 插件名称 + plugin_name = "自动删种" + # 插件描述 + plugin_desc = "自动删除下载器中的下载任务。" + # 插件图标 + plugin_icon = "delete.jpg" + # 插件版本 + plugin_version = "2.1.1" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "torrentremover_" + # 加载顺序 + plugin_order = 8 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + downloader_helper = 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): + self.downloader_helper = DownloaderHelper() + 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: + 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': { + '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': 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)) + + @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): + """ + 根据类型返回下载器实例 + """ + 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): + """ + 定时删除下载器中的下载任务 + """ + 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) + downloader_config = self.__get_downloader_config(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_config.type == "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_config.type == "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..ca6ca7b --- /dev/null +++ b/plugins.v2/torrenttransfer/__init__.py @@ -0,0 +1,997 @@ +import os +from datetime import datetime, timedelta +from pathlib import Path +from threading import Event +from typing import Any, List, Dict, Tuple, Optional, Union + +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.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, ServiceInfo +from app.utils.string import StringUtils + + +class TorrentTransfer(_PluginBase): + # 插件名称 + plugin_name = "自动转移做种" + # 插件描述 + plugin_desc = "定期转移下载器中的做种任务到另一个下载器。" + # 插件图标 + plugin_icon = "seed.png" + # 插件版本 + plugin_version = "1.7.1" + # 插件作者 + plugin_author = "jxxghp" + # 作者主页 + author_url = "https://github.com/jxxghp" + # 插件配置项ID前缀 + plugin_config_prefix = "torrenttransfer_" + # 加载顺序 + plugin_order = 18 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + _scheduler = None + torrent_helper = None + downloader_helper = None + # 开关 + _enabled = False + _cron = None + _onlyonce = False + _fromdownloader = None + _todownloader = None + _frompath = None + _topath = None + _notify = False + _nolabels = None + _includelabels = None + _includecategory = None + _nopaths = None + _deletesource = False + _deleteduplicate = False + _fromtorrentpath = None + _autostart = False + _transferemptylabel = False + _add_torrent_tags = None + # 退出事件 + _event = Event() + # 待检查种子清单 + _recheck_torrents = {} + _is_recheck_running = False + # 任务标签 + _torrent_tags = [] + + def init_plugin(self, config: dict = None): + self.torrent_helper = TorrentHelper() + self.downloader_helper = DownloaderHelper() + # 读取配置 + 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._includecategory = config.get("includecategory") + 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._add_torrent_tags = config.get("add_torrent_tags") or "" + self._torrent_tags = self._add_torrent_tags.strip().split(",") if self._add_torrent_tags else [] + + # 停止现有任务 + self.stop_service() + + # 启动定时任务 & 立即运行一次 + if self.get_state() or self._onlyonce: + 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 + + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + if self._autostart: + # 追加种子校验服务 + 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 + 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 \ + 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、数据结构 + """ + downloader_options = [{"title": config.name, "value": config.name} + for config in self.downloader_helper.get_configs().values()] + 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': 'add_torrent_tags', + 'label': '添加种子标签', + 'placeholder': '已整理,转移做种' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'includecategory', + 'label': '转移种子分类', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'nolabels', + 'label': '不转移种子标签', + } + } + ] + }, { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'includelabels', + 'label': '转移种子标签', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'fromdownloader', + 'label': '源下载器', + 'items': downloader_options + } + } + ] + }, + { + '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': downloader_options + } + } + ] + }, + { + '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": "", + "includecategory": "", + "frompath": "", + "topath": "", + "fromdownloader": "", + "todownloader": "", + "deletesource": False, + "deleteduplicate": False, + "fromtorrentpath": "", + "nopaths": "", + "autostart": True, + "transferemptylabel": False, + "add_torrent_tags": "已整理,转移做种" + } + + def get_page(self) -> List[dict]: + pass + + 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, service: ServiceInfo, content: bytes, + save_path: str) -> Optional[str]: + """ + 添加下载任务 + """ + if not service or not service.instance: + return + downloader = service.instance + if self.downloader_helper.is_downloader("qbittorrent", service=service): + # 生成随机Tag + tag = StringUtils.generate_random_str(10) + 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 = downloader.get_torrent_id_by_tag(tags=tag) + if not torrent_hash: + logger.error(f"{downloader} 下载任务添加成功,但获取任务信息失败!") + return None + return torrent_hash + elif self.downloader_helper.is_downloader("transmission", service=service): + # 添加任务 + 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"不支持的下载器类型") + return None + + def transfer(self): + """ + 开始转移做种 + """ + logger.info("开始转移做种任务 ...") + + if not self.__validate_config(): + return + + 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"下载器 {from_service.name} 已完成种子数:{len(torrents)}") + else: + logger.info(f"下载器 {from_service.name} 没有已完成种子") + return + + # 过滤种子,记录保存目录 + trans_torrents = [] + for torrent in torrents: + if self._event.is_set(): + logger.info(f"转移服务停止") + return + + # 获取种子hash + hash_str = self.__get_hash(torrent, from_service.type) + # 获取保存路径 + save_path = self.__get_save_path(torrent, from_service.type) + + 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, from_service.type) + # 获取种子分类 + 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: + 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: + 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值是否已经在目的下载器中 + torrent_info, _ = to_downloader.get_torrents(ids=[torrent_item.get('hash')]) + if torrent_info: + # 删除重复的源种子,不能删除文件! + if self._deleteduplicate: + logger.info(f"删除重复的源下载器任务(不含文件):{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')} 已在目的下载器中,跳过 ...") + # 跳过计数 + 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 self.downloader_helper.is_downloader("qbittorrent", service=from_service): + # 读取种子内容、解析种子文件 + 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"添加转移做种任务到下载器 {to_service.name}:{torrent_file}") + download_id = self.__download(service=to_service, + 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 self.downloader_helper.is_downloader("qbittorrent", service=to_service): + logger.info(f"qbittorrent 开始校验 {download_id} ...") + to_downloader.recheck_torrents(ids=[download_id]) + + # 追加校验任务 + logger.info(f"添加校验检查任务:{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')} ...") + from_downloader.delete_torrents(delete_file=False, ids=[torrent_item.get('hash')]) + + # 成功计数 + success += 1 + # 插入转种记录 + history_key = f"{from_service.name}-{torrent_item.get('hash')}" + self.save_data(key=history_key, + value={ + "to_download": to_service.name, + "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 + + # 校验下载器 + 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(to_service.name, []) + if not recheck_torrents: + return + + logger.info(f"开始检查下载器 {to_service.name} 的校验任务 ...") + + # 运行状态 + self._is_recheck_running = True + + torrents, _ = to_downloader.get_torrents(ids=recheck_torrents) + if torrents: + # 可做种的种子 + can_seeding_torrents = [] + for torrent in torrents: + # 获取种子hash + hash_str = self.__get_hash(torrent, to_service.type) + # 判断是否可做种 + 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)} 个任务校验完成,开始做种") + # 开始做种 + to_downloader.start_torrents(ids=can_seeding_torrents) + # 去除已经处理过的种子 + 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"下载器 {to_service.name} 查询校验任务失败,将在下次继续查询 ...") + else: + logger.info(f"下载器 {to_service.name} 中没有需要检查的校验任务,清空待处理列表") + self._recheck_torrents[to_service.name] = [] + + 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_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): + """ + 获取种子保存路径 + """ + 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/autosignin/__init__.py b/plugins/autosignin/__init__.py index 1c9a5a3..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" + plugin_version = "2.4.2" # 插件作者 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, 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/bangumicoll/__init__.py b/plugins/bangumicoll/__init__.py new file mode 100644 index 0000000..5728e41 --- /dev/null +++ b/plugins/bangumicoll/__init__.py @@ -0,0 +1,434 @@ +# 基础库 +import datetime +import json +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.event import eventmanager, Event +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 +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 EventType, NotificationType +from app.utils.http import RequestUtils + + +class BangumiColl(_PluginBase): + # 插件名称 + plugin_name = "Bangumi收藏订阅" + # 插件描述 + plugin_desc = "将Bangumi用户收藏添加到订阅" + # 插件图标 + plugin_icon = "bangumi_b.png" + # 插件版本 + plugin_version = "1.5.2" + # 插件作者 + plugin_author = "Attente" + # 作者主页 + author_url = "https://github.com/wikrin" + # 插件配置项ID前缀 + plugin_config_prefix = "bangumicoll_" + # 加载顺序 + plugin_order = 23 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _scheduler = None + siteoper: SiteOper = None + subscribehelper: SubscribeHelper = None + subscribeoper: SubscribeOper = None + + # 配置属性 + _enabled: bool = False + _total_change: bool = False + _cron: str = "" + _notify: bool = False + _onlyonce: bool = False + _include: str = "" + _exclude: str = "" + _uid: str = "" + _collection_type = [] + _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() + self.load_config(config) + + if self._onlyonce: + self.schedule_once() + + def load_config(self, config: dict): + """加载配置""" + if config: + # 遍历配置中的键并设置相应的属性 + 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}"))) + # 获得所有站点 + 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): + """调度一次性任务""" + 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() + + # 关闭一次性开关 + self._onlyonce = False + self.__update_config() + + def __update_config(self): + """更新设置""" + self.update_config( + { + "enabled": self._enabled, + "notify": self._notify, + "total_change": self._total_change, + "onlyonce": self._onlyonce, + "cron": self._cron, + "uid": self._uid, + "collection_type": self._collection_type, + "include": self._include, + "exclude": self._exclude, + "save_path": self._save_path, + "sites": self._sites, + } + ) + + 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 form(sites_options) + + def get_service(self) -> List[Dict[str, Any]]: + """ + 注册插件公共服务 + """ + 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 [ + { + "id": "BangumiColl", + "name": "Bangumi收藏订阅", + "trigger": trigger, + "func": self.bangumi_coll, + "kwargs": kwargs, + } + ] + return [] + + def stop_service(self): + """退出插件""" + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + self._scheduler.shutdown() + self._scheduler = None + 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 + + def get_command(self): + pass + + def get_page(self): + pass + + def get_state(self): + return self._enabled + + def bangumi_coll(self): + """订阅Bangumi用户收藏""" + if not self._uid: + logger.error("请设置UID") + return + + try: + res = self.get_bgm_res(addr="UserCollections", id=self._uid) + items = self.parse_collection_items(res) + + # 新增和移除条目 + self.manage_subscriptions(items) + 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 {} + + logger.info("解析Bangumi条目信息...") + 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'), + } + for item in data + if item.get("type") in self._collection_type + } + + 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 + del_sub = db_sub.keys() - items.keys() + + logger.debug(f"待新增条目:{new_sub}") + logger.debug(f"待移除条目:{del_sub}") + + if del_sub and self._notify: + 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()))) + + # 添加订阅 + 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")) + if not meta.name: + fail_items[self._subid] = f"{item.get('name_cn')} 未识别到有效数据" + logger.warn(f"{item.get('name_cn')} 未识别到有效数据") + continue + + 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 + + self.update_media_info(item, mediainfo) + + sid = self.subscribeoper.list_by_tmdbid( + mediainfo.tmdb_id, mediainfo.number_of_seasons + ) + if sid: + 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} Bangumi条目id更新成功") + continue + + sid, msg = self.subscribechain.add( + title=mediainfo.title, + year=mediainfo.year, + season=mediainfo.number_of_seasons, + bangumiid=self._subid, + exist_ok=True, + username="Bangumi订阅", + **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, meta: MetaBase, mediainfo: MediaInfo) -> Dict: + """准备额外参数""" + kwargs = { + "save_path": self._save_path, + "sites": ( + self._sites + if self.are_types_equal(attribute_name='sites') + else json.dumps(self._sites) + ), + } + + 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": 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") + else json.dumps(prev_eps) + ), + } + ) + logger.info( + f"{mediainfo.title_year} 更新总集数为: {total_ep},开始集数为: {meta.begin_episode}" + ) + + return kwargs + + 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")): + mediainfo.number_of_seasons = info.get("season_number") + mediainfo.number_of_episodes = info.get("episode_count") + break + + 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] + 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)}") + finally: + return meta + + # 移除订阅 + def delete_subscribe(self, del_items: Dict[int, int]): + """删除订阅""" + 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): + 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: + """对比两个日期字符串是否接近""" + date1 = datetime.datetime.strptime(date_str1, '%Y-%m-%d') + date2 = datetime.datetime.strptime(date_str2, '%Y-%m-%d') + return abs((date1 - date2).days) <= threshold_days + + @db_query + def get_subscribe_history(self, db: Session = None) -> set: + """获取已完成的订阅""" + 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是否一致""" + 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)) 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 diff --git a/plugins/brushflow/__init__.py b/plugins/brushflow/__init__.py index 0189777..faa6c05 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 @@ -63,15 +64,15 @@ 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) - 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) 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") @@ -257,7 +258,7 @@ class BrushFlow(_PluginBase): # 插件图标 plugin_icon = "brush.jpg" # 插件版本 - plugin_version = "3.3" + plugin_version = "3.8" # 插件作者 plugin_author = "jxxghp,InfinityPacer" # 作者主页 @@ -295,7 +296,6 @@ class BrushFlow(_PluginBase): # endregion def init_plugin(self, config: dict = None): - logger.info(f"站点刷流服务初始化") self.siteshelper = SitesHelper() self.siteoper = SiteOper() self.torrents = TorrentsChain() @@ -340,11 +340,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 +365,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 +971,6 @@ class BrushFlow(_PluginBase): 'component': 'VWindow', 'props': { 'model': '_tabs' - # VWindow设置paddnig会导致切换Tab时页面高度变动,调整为修改VRow的方案 - # 'style': { - # 'padding-top': '24px', - # 'padding-bottom': '24px', - # }, }, 'content': [ { @@ -1140,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" + } + } + ] } ] } @@ -1426,11 +1437,28 @@ class BrushFlow(_PluginBase): 'component': 'VTextField', 'props': { 'model': 'seed_inactivetime', - 'label': '未活动时间(分钟) ', + 'label': '未活动时间(分钟)', 'placeholder': '超过时删除任务' } } ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'delete_except_tags', + 'label': '删除排除标签', + 'placeholder': '如:MOVIEPILOT,H&R' + } + } + ] } ] } @@ -1476,8 +1504,8 @@ class BrushFlow(_PluginBase): { 'component': 'VSwitch', 'props': { - 'model': 'except_tags', - 'label': '删种排除MoviePilot任务', + 'model': 'except_subscribe', + 'label': '排除订阅(实验性功能)', } } ] @@ -1492,8 +1520,8 @@ class BrushFlow(_PluginBase): { 'component': 'VSwitch', 'props': { - 'model': 'except_subscribe', - 'label': '排除订阅(实验性功能)', + 'model': 'qb_first_last_piece', + 'label': '优先下载首尾文件块', } } ] @@ -1640,43 +1668,6 @@ class BrushFlow(_PluginBase): } } ] - }, - { - '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': 'log_more', - 'label': '记录更多日志', - } - } - ] } ] } @@ -1742,7 +1733,7 @@ class BrushFlow(_PluginBase): 'props': { 'type': 'error', 'variant': 'tonal', - 'text': '注意:排除H&R并不保证能完全适配所有站点(部分站点在列表页不显示H&R标志,但实际上是有H&R的),请注意核对使用!' + 'text': '注意:排除H&R并不保证能完全适配所有站点(部分站点在列表页不显示H&R标志,但实际上是有H&R的),请注意核对使用' } } ] @@ -1849,7 +1840,7 @@ 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, "proxy_download": False, @@ -1857,7 +1848,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 +2045,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 +2055,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 +2065,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 +2087,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, @@ -2134,6 +2123,13 @@ class BrushFlow(_PluginBase): "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 @@ -2306,7 +2302,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 +2311,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 @@ -2331,10 +2326,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 {} @@ -2372,34 +2363,58 @@ 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() + # 如果 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 + torrent_tasks[torrent_hash]["deleted_time"] = time.time() + + # 归档数据 + self.__auto_archive_tasks(torrent_tasks=torrent_tasks) self.__update_and_save_statistic_info(torrent_tasks) @@ -2618,8 +2633,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 +2671,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 @@ -2829,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", "") @@ -2914,7 +2928,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)}," @@ -2954,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 = { @@ -3026,15 +3041,15 @@ 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, - "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, "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 +3146,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 +3216,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 @@ -3654,12 +3668,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: @@ -3705,14 +3728,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 @@ -3752,7 +3782,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 @@ -3766,7 +3797,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 @@ -3833,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): """ 归档已经删除的种子数据 @@ -3843,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(): @@ -3852,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: diff --git a/plugins/customhosts/__init__.py b/plugins/customhosts/__init__.py index 849159f..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" # 作者主页 @@ -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', 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 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 diff --git a/plugins/doubansync/__init__.py b/plugins/doubansync/__init__.py index 173367d..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.8" + 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,7 +514,12 @@ class DoubanSync(_PluginBase): continue logger.info(f"开始同步用户 {user_id} 的豆瓣想看数据 ...") url = self._interests_url % user_id - results = self.rsshelper.parse(url) + 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 diff --git a/plugins/dynamicwechat/__init__.py b/plugins/dynamicwechat/__init__.py new file mode 100644 index 0000000..c6d9dd5 --- /dev/null +++ b/plugins/dynamicwechat/__init__.py @@ -0,0 +1,1152 @@ +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 + +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.schemas.types import EventType + +from app.plugins.dynamicwechat.helper import PyCookieCloud, MySender + + +class DynamicWeChat(_PluginBase): + # 插件名称 + plugin_name = "动态企微可信IP" + # 插件描述 + plugin_desc = "修改企微应用可信IP,详细说明查看'作者主页',支持第三方通知。验证码以?结尾发送到企业微信应用" + # 插件图标 + plugin_icon = "Wecom_A.png" + # 插件版本 + plugin_version = "1.6.0" + # 插件作者 + plugin_author = "RamenRa" + # 作者主页 + author_url = "https://github.com/RamenRa/MoviePilot-Plugins" + # 插件配置项ID前缀 + plugin_config_prefix = "dynamicwechat_" + # 加载顺序 + plugin_order = 47 + # 可使用的用户级别 + auth_level = 2 + # 检测间隔时间,默认10分钟 + _refresh_cron = '*/20 * * * *' + + # ------------------------------------------私有属性------------------------------------------ + _enabled = False # 开关 + _cron = None + _onlyonce = False + # IP更改成功状态 + _ip_changed = False + # 强制更改IP + _forced_update = False + # CloudCookie服务器 + _cc_server = None + # 本地扫码开关 + _local_scan = False + # 类初始化时添加标记变量 + _is_special_upload = False + # 聚合通知 + _my_send = None + # 保存cookie + _saved_cookie = None + # 通知方式token/api + _notification_token = '' + + # 匹配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' + + # 输入的企业应用id + _input_id_list = '' + # 二维码 + _qr_code_image = None + # 用户消息 + text = "" + # 手机验证码 + _verification_code = '' + # 过期时间 + _future_timestamp = 0 + # 配置文件路径 + _settings_file_path = None + + # cookie有效检测 + _cookie_valid = True + # cookie存活时间 + _cookie_lifetime = 0 + # 使用CookieCloud开关 + _use_cookiecloud = True + # 登录cookie + _cookie_header = "" + _server = f'http://localhost:{settings.NGINX_PORT}/cookiecloud' + + _cookiecloud = CookieCloudHelper() + # 定时器 + _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 = '' + self._cron = '*/10 * * * *' + self._ip_changed = True + self._forced_update = False + self._use_cookiecloud = True + self._local_scan = False + self._input_id_list = '' + self._cookie_header = "" + self._settings_file_path = self.get_data_path() / "settings.json" + 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._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") + 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 + _, 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: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + # 运行一次定时服务 + if self._onlyonce: + 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: + 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="本地扫码登陆") # 添加任务 + self._local_scan = 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() + 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): + """ + 强制修改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 + # 先尝试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'") + self._cookie_valid = False + browser.close() + except Exception as err: + logger.error(f"强制修改IP失败:{err}") + + logger.info("----------------------本次任务结束----------------------") + + @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) # 页面加载等待时间 + if self.find_qrc(page): + 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: + attempt += 1 + # logger.info(f"第 {attempt} 次检查登录状态...") + time.sleep(20) # 每20秒检查一次 + if self.check_login_status(page, task='local_scanning'): + self._update_cookie(page, context) # 刷新cookie + self.click_app_management_buttons(page) + break + else: + logger.info("用户可能没有扫码或登录失败") + else: + logger.error("未找到二维码,任务结束") + logger.info("----------------------本次任务结束----------------------") + browser.close() + except Exception as e: + logger.error(f"本地扫码任务: 本地扫码失败: {e}") + + @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 + + 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): + 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 url: + logger.error("获取IP失败 不操作可信IP") + return False + + 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 + return True + else: + return False + + def try_connect_cc(self): + 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, 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 元素并切换到它 + 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) + refuse_time = (datetime.now() + timedelta(seconds=115)).strftime("%Y-%m-%d %H:%M:%S") + return qr_code_url, refuse_time + else: + logger.warning("未找到二维码") + return None, None + except Exception as e: + logger.debug(str(e)) + return None, None + + 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 = self.get_cookie() + if cookie: + context.add_cookies(cookie) + page = context.new_page() + page.goto(self._wechatUrl) + time.sleep(3) + img_src, refuse_time = self.find_qrc(page) + if img_src: + if self._my_send: # 统一逻辑,只有用户发送'/push_qr'才会发生二维码 + self._ip_changed = False + self._send_cookie_false() + logger.info("已尝试发送cookie失效通知") + else: + self._ip_changed = False + self._cookie_valid = False + logger.info("cookie已失效,且没有配置通知方式,本次修改可信IP失败") + else: # 如果直接进入企业微信 + logger.info("尝试cookie登录") + 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: + self._ip_changed = False + logger.error(f"更改可信IP失败: {e}") + finally: + pass + + def _update_cookie(self, page, context): + self._future_timestamp = 0 # 标记二维码失效 + PyCookieCloud.save_cookie_lifetime(self._settings_file_path, 0) # 重置cookie存活时间 + 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 not self._cc_server.check_connection(): + logger.error("连接 CookieCloud 失败", self._server) + return + current_url = page.url + current_cookies = context.cookies(current_url) # 通过 context 获取 cookies + if current_cookies is None: + logger.error("无法从内置浏览器获取 cookies") + self._cookie_valid = False + 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 + + if domain not in formatted_cookies: + formatted_cookies[domain] = [] + formatted_cookies[domain].append(cookie) + 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: + logger.info("更新本地 Cookie成功") + 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): + if self._saved_cookie and self._cookie_valid: + return self._saved_cookie + try: + cookie_header = '' + 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}") + return + + @staticmethod + def parse_cookie_header(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=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 self.check_login_status(page, task='refresh_cookie'): + # logger.info("本地保存的 cookie 有效") + self._cookie_valid = True + 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 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() + 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._send_cookie_false() + self._saved_cookie = None # 异常时清空 cookie + logger.error(f"cookie 校验过程中发生异常: {e}") + + # + def check_login_status(self, page, task): + # 等待页面加载 + time.sleep(3) + # 检查是否需要进行短信验证 + if task != 'refresh_cookie': + logger.info("检查登录状态...") + try: + # 先检查登录成功后的页面状态 + success_element = page.wait_for_selector('#check_corp_info', timeout=5000) # 检查登录成功的元素 + if success_element: + if task != 'refresh_cookie': + logger.info("登录成功!") + return True + except Exception as e: + logger.debug(str(e)) + pass + + try: + # 在这里使用更安全的方式来检查元素是否存在 + captcha_panel = page.wait_for_selector('.receive_captcha_panel', timeout=5000) # 检查验证码面板 + if captcha_panel: # 出现了短信验证界面 + 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: + 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=5000) + if success_element: + logger.info("验证码登录成功!") + return True + else: + logger.error("未收到短信验证码") + return False + 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': # 延长任务找到的二维码不会被发送,所以不算用户没有扫码 + 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 = [ + # ("//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()='配置']", + "配置") + ] + _, 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] + else: + 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: + 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: + # 等待按钮出现并可点击 + 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: + self._my_send.send(title="更新可信IP成功", + content='应用: ' + app_id + ' 输入IP:' + masked_ip, + force_send=True, diy_channel="WeChat") + + def __update_config(self): + """ + 更新配置 + """ + self.update_config({ + "enabled": self._enabled, + "onlyonce": self._onlyonce, + "cron": self._cron, + "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, + "input_id_list": self._input_id_list, + "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': '强制更新IP', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'use_cookiecloud', + 'label': '使用CookieCloud', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'local_scan', + 'label': '本地扫码修改IP', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '[必填]检测周期', + 'placeholder': '0 * * * *' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'notification_token', + 'label': '[可选] 通知方式', + 'rows': 1, + 'placeholder': '支持微信、Server酱、PushPlus、AnPush等Token或API' + } + } + ] + } + ] + }, + { + '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 + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '建议启用内建或自定义CookieCloud。支持微信、Server酱等第三方通知,具体请查看作者主页' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'text': 'Cookie失效时通知用户,用户使用/push_qr让插件推送二维码。使用第三方通知时填写对应Token/API' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "cron": "", + "onlyonce": False, + "forceUpdate": False, + "use_cookiecloud": True, + "use_local_qr": False, + "cookie_header": "", + "notification_token": "", + "input_id_list": "" + } + + def get_page(self) -> List[dict]: + # 获取当前时间戳 + current_time = datetime.now().timestamp() + + # 判断二维码是否过期 + if current_time > self._future_timestamp: + 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}" + color = "#32CD32" + + # 如果self._qr_code_image为None,返回提示信息 + if self._qr_code_image is None: + img_component = { + "component": "div", + "text": "登录二维码都会在此展示,二维码有6秒延时。 [适用于Docker版]", + "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" + } + } + } + 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_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", + "props": { + "style": { + "textAlign": "center" + } + }, + "content": [ + { + "component": "div", + "props": { + "style": { + "display": "flex", + "justifyContent": "center", + "alignItems": "center", + "flexDirection": "column", # 垂直排列 + "gap": "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 # 二维码图片或提示信息 + ] + } + ] + + return base_content + + @eventmanager.register(EventType.PluginAction) + 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 + 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) + image_src, refuse_time = self.find_qrc(page) + if image_src: + if self._my_send: + result = self._my_send.send("企业微信登录二维码", image=image_src) + if result: + logger.info(f"远程推送任务: 二维码发送失败,原因:{result}") + browser.close() + logger.info("----------------------本次任务结束----------------------") + return + logger.info("远程推送任务: 二维码发送成功,等待用户 90 秒内扫码登录。V2'微信通知'的用户,此消息并不准确") + # logger.info("远程推送任务: 如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?") + time.sleep(90) + if self.check_login_status(page, 'push_qr_code'): + self._update_cookie(page, context) # 刷新cookie + # logger.info("远程推送任务: 没有可用的CookieCloud服务器,只修改可信IP") + self.click_app_management_buttons(page) + else: + logger.warning("远程推送任务: 没有找到可用的通知方式") + else: + logger.warning("远程推送任务: 未找到二维码") + browser.close() + logger.info("----------------------本次任务结束----------------------") + except Exception as e: + logger.error(f"远程推送任务: 推送二维码失败: {e}") + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [ + { + "cmd": "/push_qr", + "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") + if self.text[:6].isdigit() and len(self.text) == 7: + self._verification_code = self.text[:6] + logger.info(f"收到验证码:{self._verification_code}") + + 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._scheduler.shutdown() + self._scheduler = None + except Exception as e: + logger.error(str(e)) diff --git a/plugins/dynamicwechat/helper.py b/plugins/dynamicwechat/helper.py new file mode 100644 index 0000000..0df72d4 --- /dev/null +++ b/plugins/dynamicwechat/helper.py @@ -0,0 +1,296 @@ +import re +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): + 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 方法 + + @staticmethod + def _detect_channel(token): + """根据 token 确定通知渠道""" + if "WeChat" in token: + return "WeChat" + + 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 + + # 对纯文本消息进行限制 + if not image and not force_send: + if self.first_text_sent: + return + self.first_text_sent = True + + # 如果指定了自定义通道,直接尝试发送 + if diy_channel: + return self._try_send(title, content, image, 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: + pass # 忽略单个错误,继续尝试下一个通道 + self.current_index = (self.current_index + 1) % len(self.tokens) + return f"所有的通知方式都发送失败" + + 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): + wechat = WeChat() + 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, userid=actual_userid) + + if send_status is None: + return "微信通知发送错误" + return None + + def _send_serverchan(self, title, content, image): + tmp_tokens = self.tokens[self.current_index] + 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) + if match: + num = match.group(1) + url = f'https://{num}.push.ft07.com/send/{token}.send' + else: + return '错误的Server3 Sendkey' + else: + 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'} + 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): + token = self.tokens[self.current_index] # 获取当前通道对应的 token + if ',' in token: + channel, token = token.split(',', 1) + else: + return "可能AnPush 没有配置消息通道ID" + 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): + token = self.tokens[self.current_index] # 获取当前通道对应的 token + pushplus_url = f"http://www.pushplus.plus/send/{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 _send_v2_wechat(self, title, content, image, token): + """V2 微信通知发送""" + if token and ',' in token: + _, actual_userid = token.split(',', 1) + else: + actual_userid = None + self.post_message_func( + channel=MessageChannel.Wechat, + mtype=NotificationType.Plugin, + title=title, + text=content, + image=image, + link=image, + userid=actual_userid + ) + return None # 由于self.post_message()了None外,没有其他返回值。无法判断是否发送成功,V2直接默认成功 + + def reset_limit(self): + """解除限制,允许再次发送纯文本消息""" + self.first_text_sent = False diff --git a/plugins/dynamicwechat/src/UpdateHelp.py b/plugins/dynamicwechat/src/UpdateHelp.py new file mode 100644 index 0000000..ec747b5 --- /dev/null +++ b/plugins/dynamicwechat/src/UpdateHelp.py @@ -0,0 +1,87 @@ +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: + 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. + """ + 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] diff --git a/plugins/episodegroupmeta/__init__.py b/plugins/episodegroupmeta/__init__.py index 7a3be26..20c7b6c 100644 --- a/plugins/episodegroupmeta/__init__.py +++ b/plugins/episodegroupmeta/__init__.py @@ -2,14 +2,13 @@ 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 - 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 @@ -22,16 +21,16 @@ 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 class ExistMediaInfo(BaseModel): - # 类型 电影、电视剧 - type: Optional[schemas.MediaType] # 季, 集 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 +46,7 @@ class EpisodeGroupMeta(_PluginBase): # 主题色 plugin_color = "#098663" # 插件版本 - plugin_version = "1.1" + plugin_version = "2.6" # 插件作者 plugin_author = "叮叮当" # 作者主页 @@ -63,25 +62,25 @@ class EpisodeGroupMeta(_PluginBase): _event = threading.Event() # 私有属性 - mschain = None tv = None emby = None plex = None jellyfin = None + mediaserver_helper = None _enabled = False + _notify = True + _autorun = True _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._notify = config.get("notify") + self._autorun = config.get("autorun") self._ignorelock = config.get("ignorelock") self._delay = config.get("delay") or 120 self._allowlist = [] @@ -90,6 +89,14 @@ 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 ("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} 配置修正 ...") def get_state(self) -> bool: return self._enabled @@ -99,7 +106,80 @@ 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}") + self.systemmessage.put("正在刮削中,请稍等!", title="剧集组刮削") + if self.start_rt(mediainfo, episode_groups, group_id): + 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]]: """ @@ -116,7 +196,7 @@ class EpisodeGroupMeta(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'md': 6 + 'md': 3 }, 'content': [ { @@ -132,18 +212,50 @@ class EpisodeGroupMeta(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'md': 6 + 'md': 3 }, 'content': [ { - 'component': 'VSwitch', + 'component': 'VCheckboxBtn', 'props': { - 'model': 'ignorelock', - 'label': '媒体信息锁定时也进行刮削', + 'model': 'autorun', + 'label': '季集匹配时自动刮削', } } ] - } + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VCheckboxBtn', + 'props': { + 'model': 'ignorelock', + 'label': '锁定的剧集也刮削', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VCheckboxBtn', + 'props': { + 'model': 'notify', + 'label': '开启通知', + } + } + ] + }, ] }, { @@ -203,7 +315,7 @@ class EpisodeGroupMeta(_PluginBase): 'props': { 'type': 'info', 'variant': 'tonal', - 'text': '注意:刮削白名单(留空), 则全部刮削. 否则仅刮削白名单.' + 'text': '注意:刮削白名单(留空)则全部刮削. 否则仅刮削白名单.' } } ] @@ -235,18 +347,220 @@ class EpisodeGroupMeta(_PluginBase): } ], { "enabled": False, + "notify": True, + "autorun": True, "ignorelock": False, "allowlist": "", "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]: - pass + """ + 拼装插件详情页面,需要返回页面配置,同时附带数据 + """ + # 查询待处理数据列表 + mediainfo_list: List[PluginData] = self.get_data() + # 拼装页面 + contents = [] + for plugin_data in mediainfo_list: + try: + tmdb_id = plugin_data.key + # fix v1版本数据读取问题 + if self.is_objstr(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,21 +593,124 @@ 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} 未勾选自动刮削, 无需处理") + # 发送通知 + 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)) - # 获取可用的媒体服务器 - _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} 在媒体库中不存在") + # 开始处理 + 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 - # 新增需要的属性 - existsinfo.server = _existsinfo.server - existsinfo.type = _existsinfo.type - self.log_info(f"{mediainfo.title_year} 存在于媒体服务器: {_existsinfo.server}") + + 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 + # 获取全部可用的媒体服务器, 兼容v2 + service_infos = self.service_infos() + relust_bool = False + if self.mediaserver_helper is None: + # v1版本 单一媒体服务器的方式 + 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 + # 遍历媒体服务器 + for name, info in service_infos.items(): + self.log_info(f"正在查询媒体服务器: ({info.type}){name}") + 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 + + 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', @@ -309,6 +726,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) @@ -321,29 +741,37 @@ 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) - 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} 季") # 遍历全部媒体项并更新 + 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 - # 是否无视项目锁定 + # 锁定的剧集是否也刮削? 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] @@ -361,11 +789,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) @@ -376,16 +805,16 @@ class EpisodeGroupMeta(_PluginBase): continue self.log_info(f"{mediainfo.title_year} 已经运行完毕了..") + return True @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: + def __media_exists(self, mediainfo: schemas.MediaInfo, server: str, server_type: str, mediaserver_instance: Any = None) -> ExistMediaInfo: """ - 根据媒体信息,返回剧集列表与剧集ID列表 + 根据媒体信息,返回是否存在于指定媒体服务器中,剧集列表与剧集ID列表 :param mediainfo: 媒体信息 :return: 剧集列表与剧集ID列表 """ @@ -394,7 +823,8 @@ class EpisodeGroupMeta(_PluginBase): # 获取系列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" @@ -410,18 +840,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}){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() @@ -430,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] = [] @@ -449,17 +879,20 @@ class EpisodeGroupMeta(_PluginBase): return ExistMediaInfo( itemid=item_id, groupep=group_ep, - groupid=group_id + groupid=group_id, + server_type=server_type, + server=server, ) except Exception as e: - self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}") + self.log_error(f"媒体服务器 ({server_type}){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") @@ -470,19 +903,19 @@ 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}){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( - "[HOST]emby/Shows/%s/Episodes?Season=&IsMissing=false&api_key=[APIKEY]" % item_id) + res_json = instance.get_data( + "[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") @@ -490,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] = [] @@ -509,30 +942,30 @@ class EpisodeGroupMeta(_PluginBase): return ExistMediaInfo( itemid=item_id, groupep=group_ep, - groupid=group_id + groupid=group_id, + server_type=server_type, + server=server, ) except Exception as e: - self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}") + self.log_error(f"媒体服务器 ({server_type}){server} 发生了错误, 连接Shows/Id/Episodes出错:{str(e)}") return None def __plex_media_exists(): try: - _plex = 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): @@ -547,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: @@ -569,10 +1002,12 @@ class EpisodeGroupMeta(_PluginBase): return ExistMediaInfo( itemid=videos.key, groupep=group_ep, - groupid=group_id + groupid=group_id, + server_type=server_type, + server=server, ) except Exception as e: - self.log_error(f"连接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: @@ -598,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: """ 获得媒体项详情 """ @@ -615,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: @@ -629,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: @@ -646,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 @@ -675,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): """ 更新媒体项详情 """ @@ -705,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={ @@ -726,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={ @@ -747,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'], @@ -760,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): """ 更新媒体项图片 """ @@ -797,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={ @@ -820,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: @@ -838,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 is 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 is None: + if self.emby is 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)}") diff --git a/plugins/iyuuautoseed/__init__.py b/plugins/iyuuautoseed/__init__.py index 187a8c2..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.3" + plugin_version = "1.9.6" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -957,6 +957,10 @@ class IYUUAutoSeed(_PluginBase): if self._skipverify: # 跳过校验 logger.info(f"{download_id} 跳过校验,请自行检查...") + # 请注意这里是故意不自动开始的 + # 跳过校验存在直接失败、种子目录相同文件不同等异常情况 + # 必须要用户自行二次确认之后才能开始做种 + # 否则会出现反复下载刷掉分享率、做假种的情况 else: # 追加校验任务 logger.info(f"添加校验检查任务:{download_id} ...") 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 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() 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): """ 退出插件 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} 暂停") # 处理辅种的辅种 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]]]: """ 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' + } + } + ] + } + ] + } ] } ], { 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( diff --git a/plugins/sitestatistic/__init__.py b/plugins/sitestatistic/__init__.py index 752ee13..9760734 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 @@ -43,7 +42,7 @@ class SiteStatistic(_PluginBase): # 插件图标 plugin_icon = "statistic.png" # 插件版本 - plugin_version = "3.9.1" + plugin_version = "4.0.1" # 插件作者 plugin_author = "lightolly" # 作者主页 @@ -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', diff --git a/plugins/sitestatistic/siteuserinfo/nexus_php.py b/plugins/sitestatistic/siteuserinfo/nexus_php.py index e5efd06..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)) @@ -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: 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 diff --git a/plugins/speedlimiter/__init__.py b/plugins/speedlimiter/__init__.py index 4700c6c..9ad8dbb 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.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 @@ -55,6 +56,7 @@ class SpeedLimiter(_PluginBase): _unlimited_ips = {} # 当前限速状态 _current_state = "" + _exclude_path = "" def init_plugin(self, config: dict = None): # 读取配置 @@ -66,9 +68,15 @@ 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 + 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 @@ -316,6 +324,23 @@ class SpeedLimiter(_PluginBase): } } ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'reserved_bandwidth', + 'label': '预留带宽(应对突发流量和额外开销)', + 'placeholder': 'Mbps' + } + } + ] } ] }, @@ -355,6 +380,23 @@ class SpeedLimiter(_PluginBase): } } ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'exclude_path', + 'label': '不限速路径', + 'placeholder': '包含该路径的媒体不限速,多个请换行' + } + } + ] } ] } @@ -371,7 +413,8 @@ class SpeedLimiter(_PluginBase): "bandwidth": None, "allocation_ratio": "", "ipv4": "", - "ipv6": "" + "ipv6": "", + "exclude_path": "" } def get_page(self) -> List[dict]: @@ -415,7 +458,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 @@ -429,6 +474,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]" @@ -438,7 +485,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 @@ -481,6 +529,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) @@ -488,6 +537,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: @@ -495,11 +545,24 @@ 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: """ 计算智能上传限速 """ - 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) @@ -518,71 +581,67 @@ 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 = upload_limit 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) + 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: - 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 已取消限速" - ) + self._qb.set_speed_limit(download_limit=download_limit, upload_limit=upload_limit_final) 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 已取消限速" - ) + 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: """ diff --git a/plugins/synccookiecloud/__init__.py b/plugins/synccookiecloud/__init__.py new file mode 100644 index 0000000..97696d5 --- /dev/null +++ b/plugins/synccookiecloud/__init__.py @@ -0,0 +1,299 @@ +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: + 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, encrypt_data: dict): + """ + 获取并解密本地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 _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/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 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) diff --git a/plugins/torrenttransfer/__init__.py b/plugins/torrenttransfer/__init__.py index c304a59..2a1eba3 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.6" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -55,19 +55,21 @@ class TorrentTransfer(_PluginBase): _notify = False _nolabels = None _includelabels = None + _includecategory = None _nopaths = None _deletesource = False _deleteduplicate = False _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() @@ -79,6 +81,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") @@ -89,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() @@ -97,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 # 定时服务 @@ -121,24 +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, - "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(): @@ -269,6 +260,39 @@ class TorrentTransfer(_PluginBase): 'cols': 12, 'md': 4 }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'add_torrent_tags', + 'label': '添加种子标签', + 'placeholder': '已整理,转移做种' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'includecategory', + 'label': '转移种子分类', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, 'content': [ { 'component': 'VTextField', @@ -282,7 +306,7 @@ class TorrentTransfer(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'md': 4 + 'md': 6 }, 'content': [ { @@ -293,7 +317,7 @@ class TorrentTransfer(_PluginBase): } } ] - } + }, ] }, { @@ -494,6 +518,7 @@ class TorrentTransfer(_PluginBase): "cron": "", "nolabels": "", "includelabels": "", + "includecategory": "", "frompath": "", "topath": "", "fromdownloader": "", @@ -503,7 +528,8 @@ class TorrentTransfer(_PluginBase): "fromtorrentpath": "", "nopaths": "", "autostart": True, - "transferemptylabel": False + "transferemptylabel": False, + "add_torrent_tags": "已整理,转移做种" } def get_page(self) -> List[dict]: @@ -520,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]: """ @@ -531,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: @@ -546,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: @@ -561,6 +602,9 @@ class TorrentTransfer(_PluginBase): """ logger.info("开始转移做种任务 ...") + if not self.__validate_config(): + return + # 源下载器 downloader = self._fromdownloader # 目的下载器 @@ -600,13 +644,20 @@ 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: continue @@ -724,6 +775,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" # 编码并保存到临时文件 @@ -867,6 +921,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): """ diff --git a/plugins/vcbanimemonitor/__init__.py b/plugins/vcbanimemonitor/__init__.py index de3edd1..f81f257 100644 --- a/plugins/vcbanimemonitor/__init__.py +++ b/plugins/vcbanimemonitor/__init__.py @@ -15,12 +15,11 @@ 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.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 @@ -73,11 +72,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" + plugin_version = "1.8.2.2" # 插件作者 plugin_author = "pixel@qingwa" # 作者主页 @@ -91,7 +90,6 @@ class VCBAnimeMonitor(_PluginBase): # 私有属性 _switch_ova = False - _high_mode = False _torrents_path = None new_save_path = None qb = None @@ -100,6 +98,7 @@ class VCBAnimeMonitor(_PluginBase): downloadhis = None transferchian = None tmdbchain = None + mediaChain = None _observer = [] _enabled = False _notify = False @@ -126,6 +125,7 @@ class VCBAnimeMonitor(_PluginBase): self.transferhis = TransferHistoryOper() self.downloadhis = DownloadHistoryOper() self.transferchian = TransferChain() + self.mediaChain = MediaChain() self.tmdbchain = TmdbChain() # 清空配置 self._dirconf = {} @@ -145,7 +145,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 "" # 停止现有任务 @@ -164,13 +163,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() @@ -181,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文件") @@ -224,7 +226,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)) @@ -290,7 +293,6 @@ class VCBAnimeMonitor(_PluginBase): "size": self._size, "scrape": self._scrape, "ova": self._switch_ova, - "high_mode": self._high_mode, "torrents_path": self._torrents_path }) @@ -376,33 +378,56 @@ 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 == "SPs": - logger.warn("位于SPs目录下,跳过处理") + if file_path.parent.name.lower() in ["sps", "scans", "cds", "previews", "extras"]: + 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资源,开始历史记录处理") + 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: - file_meta.begin_episode = 1 - self.save_data(key=f"OVA_{file_meta.title}", value=1) + self.save_data(file_meta.title, [file_meta.begin_episode]) else: return @@ -418,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}') @@ -615,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 @@ -813,22 +847,6 @@ class VCBAnimeMonitor(_PluginBase): } ] }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'high_mode', - 'label': '高性能处理模式', - } - } - ] - }, { 'component': 'VCol', 'props': { @@ -983,7 +1001,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' @@ -1031,8 +1049,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!' } } ] @@ -1053,9 +1073,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资源转移到监控目录实现自动整理(' + '仅支持第一个监控目录),' '监控种子目录为空则不转移文件' } } @@ -1077,7 +1097,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 4624cc7..ea261eb 100644 --- a/plugins/vcbanimemonitor/remeta.py +++ b/plugins/vcbanimemonitor/remeta.py @@ -1,5 +1,6 @@ import concurrent import re +from dataclasses import dataclass from pathlib import Path from typing import List from app.chain.media import MediaChain @@ -8,196 +9,276 @@ 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 +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: - # 解析之后的标题: - 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的剧集命名规范,归类为电影,跳过剧集模块处理。注意:年份较为久远的作品可能在此会判断错误") + self.parse_movie() + 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 + 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, 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"OVA模式下识别出集数为{self.vcb_meta.ep}") + self.vcb_meta.season = 0 + 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): + + _, 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/webhook/__init__.py b/plugins/webhook/__init__.py index 7a624e9..6fb57ba 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): @@ -14,7 +16,7 @@ class WebHook(_PluginBase): # 插件图标 plugin_icon = "webhook.png" # 插件版本 - plugin_version = "1.0" + plugin_version = "1.1" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -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): """ 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" # 作者主页