diff --git a/package.json b/package.json
index 4a40be9..1ea49fe 100644
--- a/package.json
+++ b/package.json
@@ -74,12 +74,14 @@
"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+版本",
@@ -296,11 +298,12 @@
"name": "IYUU自动辅种",
"description": "基于IYUU官方Api实现自动辅种。",
"labels": "做种,IYUU",
- "version": "1.9.5",
+ "version": "1.9.6",
"icon": "IYUU.png",
"author": "jxxghp",
"level": 2,
"history": {
+ "v1.9.6": "调整IYUU最新域名",
"v1.9.5": "Revert qBittorrent跳检之后自动开始",
"v1.9.4": "修复qBittorrent辅种后不会自动开始做种",
"v1.9.3": "修复Monika因缺少rsskey,种子下载失败的问题",
@@ -487,16 +490,6 @@
"level": 1,
"v2": true
},
- "IyuuMsg": {
- "name": "IYUU消息推送",
- "description": "支持使用IYUU发送消息通知。",
- "labels": "消息通知,IYUU",
- "version": "1.2",
- "icon": "Iyuu_A.png",
- "author": "jxxghp",
- "level": 1,
- "v2": true
- },
"PushDeerMsg": {
"name": "PushDeer消息推送",
"description": "支持使用PushDeer发送消息通知。",
@@ -534,10 +527,18 @@
"name": "TMDB剧集组刮削",
"description": "从TMDB剧集组刮削季集的实际顺序。",
"labels": "刮削",
- "version": "1.1",
+ "version": "2.5",
"icon": "Element_A.png",
"author": "叮叮当",
- "level": 1
+ "level": 1,
+ "v2": true,
+ "history": {
+ "v2.5": "修复当媒体服务器中剧集的季不完整时会中断的问题",
+ "v2.3": "修复v2版本无法读取媒体库的问题",
+ "v2.2": "修复v2版本无法读取数据的问题",
+ "v2.1": "增加发送通知提醒选择剧集组",
+ "v2.0": "增加手动选择剧集组的功能"
+ }
},
"CustomIndexer": {
"name": "自定义索引站点",
@@ -859,53 +860,66 @@
"v2": true
},
"DynamicWeChat": {
- "name": "修改企业微信可信IP",
- "description": "优先使用cookie,可本地扫码刷新Cookie,当填写两个第三方token时可手机远程更新cookie。",
+ "name": "动态企微可信IP",
+ "description": "修改企微应用可信IP,支持Srever酱等第三方通知。验证码以?结尾发送到企业微信应用",
"labels": "消息通知",
- "version": "1.3.1",
+ "version": "1.5.1",
"icon": "Wecom_A.png",
"author": "RamenRa",
"level": 2,
"v2": true,
"history": {
+ "v1.5.1": "修复v2微信通知,可以指定微信通知ID",
+ "v1.5.0": "支持企微应用通知和第Serve酱等第三方推送。按要求修改插件名称",
+ "v1.4.1": "完善面板说明",
+ "v1.4.0": "修复强制更改IP时配置面板延时过长的问题。庆祝v2进入正式版,显示了一个没用的参数",
"v1.3.1": "修正一些逻辑判断,修改ip成功会通知一次",
"v1.3.0": "兼容v2,操作cookie前检查一次CookieCloud",
- "v1.2.0": "远程命令/push_qr,立即推送一次二维码到pushplus。添加<本地扫码刷新cookie>",
- "v1.1.5": "将chromium运行设置为headless模式",
- "v1.1.4": "放弃self.post_message()的消息推送,还原成send_pushplus_message()",
- "v1.1.3": "关闭cookie输入框,延长cookie任务成功时不输出日志,使用设定中的CookieCloud设置"
+ "v1.2.0": "远程命令/push_qr,立即推送一次二维码到pushplus。添加<本地扫码刷新cookie>"
}
},
"SyncCookieCloud": {
"name": "同步CookieCloud",
"description": "同步MoviePilot站点Cookie到本地CookieCloud。",
"labels": "站点",
- "version": "2.0",
+ "version": "1.4",
"icon": "Cookiecloud_A.png",
"author": "thsrite",
"level": 1,
"history": {
- "v2.0": "调整逻辑,修复问题",
+ "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.1",
- "icon": "bangumi_b.png",
- "author": "Attente",
- "level": 1,
- "v2": true,
- "history": {
- "v1.5.1": "修复季度信息未传递的问题. 新增站点列表同步删除",
- "v1.5": "修复总集数会同步TMDB变动的问题,增加开关选项",
- "v1.4": "结构优化",
- "v1.3.1": "修复因修改季号导致未下载剧集而完成订阅的问题"
+ "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
index 4018fc4..0341e18 100644
--- a/package.v2.json
+++ b/package.v2.json
@@ -178,32 +178,16 @@
"v2.0": "兼容MoviePilot V2 版本"
}
},
- "ConfigCenter": {
- "name": "配置中心",
- "description": "快速调整部分系统设定。",
- "labels": "系统设置",
- "version": "3.2",
- "icon": "setting.png",
- "author": "jxxghp",
- "level": 1,
- "history": {
- "v3.2": "优化显示,按功能区分",
- "v3.1": "重构配置更新逻辑,从而与主程序保持一致",
- "v3.0": "兼容MoviePilot V2 版本",
- "v2.6": "支持DOH相关配置项",
- "v2.5": "增加Github加速服务器设置项"
- }
- },
"TorrentRemover": {
"name": "自动删种",
"description": "自动删除下载器中的下载任务。",
"labels": "做种",
- "version": "2.1",
+ "version": "2.1.1",
"icon": "delete.jpg",
"author": "jxxghp",
"level": 2,
"history": {
- "v2.1": "修复兼容MoviePilot V2 版本",
+ "v2.1.1": "修复兼容MoviePilot V2 版本",
"v2.0": "兼容MoviePilot V2 版本"
}
},
@@ -211,11 +195,12 @@
"name": "IYUU自动辅种",
"description": "基于IYUU官方Api实现自动辅种。",
"labels": "做种,IYUU",
- "version": "2.0.1",
+ "version": "2.1",
"icon": "IYUU.png",
"author": "jxxghp",
"level": 2,
"history": {
+ "v2.1": "调整IYUU最新域名",
"v2.0": "兼容MoviePilot V2 版本"
}
},
@@ -230,5 +215,29 @@
"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"
+ }
}
}
\ No newline at end of file
diff --git a/plugins.v2/configcenter/__init__.py b/plugins.v2/configcenter/__init__.py
deleted file mode 100644
index 62f266a..0000000
--- a/plugins.v2/configcenter/__init__.py
+++ /dev/null
@@ -1,966 +0,0 @@
-from typing import Any, List, Dict, Tuple
-
-from app.core.config import settings
-from app.core.module import ModuleManager
-from app.log import logger
-from app.plugins import _PluginBase
-
-
-class ConfigCenter(_PluginBase):
- # 插件名称
- plugin_name = "配置中心"
- # 插件描述
- plugin_desc = "快速调整部分系统设定。"
- # 插件图标
- plugin_icon = "setting.png"
- # 插件版本
- plugin_version = "3.2"
- # 插件作者
- plugin_author = "jxxghp"
- # 作者主页
- author_url = "https://github.com/jxxghp"
- # 插件配置项ID前缀
- plugin_config_prefix = "configcenter_"
- # 加载顺序
- plugin_order = 0
- # 可使用的用户级别
- auth_level = 1
-
- # 私有属性
- _enabled = False
- _params = ""
-
- def init_plugin(self, config: dict = None):
- if not config:
- return
-
- # 清理插件配置,从而实现默认使用.env中的数据源
- self._params = config.pop("params", "")
- if "undefined" in config:
- del config["undefined"]
- if "_tabs" in config:
- del config["_tabs"]
- self.update_config(config={})
-
- # 将自定义配置存储到 __ConfigCenter__
- self.update_config(plugin_id="__ConfigCenter__", config={"params": self._params})
-
- logger.info(f"正在应用配置中心配置:{config}")
-
- # 追加自定义配置中的内容
- params = self.__parse_params(self._params) or {}
- config.update(**params)
-
- # 批量更新配置,并获取更新结果
- update_results = settings.update_settings(config)
-
- # 遍历更新结果
- for key, (success, message) in update_results.items():
- if not success:
- self.__log_and_notify_error(f"配置项 '{key}' 更新失败:{message}")
- elif message:
- self.__log_and_notify_error(f"配置项 '{key}' 更新时出现警告:{message}")
-
- # 重新加载模块
- ModuleManager().reload()
-
- def __log_and_notify_error(self, message):
- """
- 记录错误日志并发送系统通知
- """
- logger.error(message)
- self.systemmessage.put(message, title=self.plugin_name)
-
- @staticmethod
- def __parse_params(param_str: str) -> dict:
- """
- 解析自定义配置
- """
- if not param_str:
- return {}
- result = {}
- params = param_str.split("\n")
- for param in params:
- if not param:
- continue
- if str(param).strip().startswith("#"):
- continue
- parts = param.split("=", 1)
- if len(parts) != 2:
- continue
- key = parts[0].strip()
- value = parts[1].strip()
- if not key:
- continue
- if not value:
- continue
- result[key] = value
- return result
-
- def get_state(self) -> bool:
- return True
-
- @staticmethod
- def get_command() -> List[Dict[str, Any]]:
- pass
-
- def get_api(self) -> List[Dict[str, Any]]:
- pass
-
- def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
- """
- 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
- """
- default_settings = {}
- settings_model = self.get_settings_model()
- keys = self.extract_keys(settings_model)
- for key in keys:
- if hasattr(settings, key):
- default_settings[key] = getattr(settings, key)
-
- config = self.get_config(plugin_id="__ConfigCenter__") or {}
- params_str = config.get("params") or ""
- params = self.__parse_params(params_str) or {}
- updated_params = {key: getattr(settings, key) for key in params if hasattr(settings, key)}
- params_str = "\n".join(f"{key}={value}" for key, value in updated_params.items())
- default_settings["params"] = params_str
-
- return [
- {
- "component": "VForm",
- "content": settings_model
- }
- ], default_settings
-
- def extract_keys(self, components: List[dict]) -> List[str]:
- """
- 递归提取所有组件中的model键
- """
- models = []
- for component in components:
- # 检查当前组件的props中是否有model
- props = component.get("props", {})
- model = props.get("model")
- if model:
- models.append(model)
-
- # 如果当前组件有嵌套的content,递归提取
- nested_content = component.get("content", [])
- if isinstance(nested_content, list):
- models.extend(self.extract_keys(nested_content))
- elif isinstance(nested_content, dict):
- models.extend(self.extract_keys([nested_content]))
-
- return models
-
- @staticmethod
- def get_settings_model() -> List[dict]:
- """
- 获取配置项模型
- """
- return [
- {
- "component": "VRow",
- "content": [
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- },
- "content": [
- {
- "component": "VAlert",
- "props": {
- "type": "warning",
- "variant": "tonal",
- "text": "注意:部分配置项的更改可能需要重启服务才能生效,为确保配置一致性,已在环境变量中的相关配置项,请手动更新"
- }
- }
- ]
- }
- ]
- },
- {
- "component": "VTabs",
- "props": {
- "model": "_tabs",
- "height": 72,
- "fixed-tabs": True,
- "style": {
- "margin-top": "8px",
- "margin-bottom": "10px",
- }
- },
- "content": [
- {
- "component": "VTab",
- "props": {
- "value": "basic_tab",
- "style": {
- "padding-top": "10px",
- "padding-bottom": "10px",
- "font-size": "16px"
- },
- },
- "text": "基础设置"
- },
- {
- "component": "VTab",
- "props": {
- "value": "network_tab",
- "style": {
- "padding-top": "10px",
- "padding-bottom": "10px",
- "font-size": "16px"
- },
- },
- "text": "网络设置"
- },
- {
- "component": "VTab",
- "props": {
- "value": "media_and_download_tab",
- "style": {
- "padding-top": "10px",
- "padding-bottom": "10px",
- "font-size": "16px"
- },
- },
- "text": "媒体与下载"
- },
- {
- "component": "VTab",
- "props": {
- "value": "search_and_transfer_tab",
- "style": {
- "padding-top": "10px",
- "padding-bottom": "10px",
- "font-size": "16px"
- },
- },
- "text": "搜索与整理"
- },
- {
- "component": "VTab",
- "props": {
- "value": "params_tab",
- "style": {
- "padding-top": "10px",
- "padding-bottom": "10px",
- "font-size": "16px"
- },
- },
- "text": "自定义配置"
- },
- ]
- },
- {
- "component": "VWindow",
- "props": {
- "model": "_tabs",
- },
- "content": [
- # 备份分类块
- # {
- # "component": "VWindowItem",
- # "props": {
- # "value": "client_setting",
- # "style": {
- # "padding-top": "20px",
- # "padding-bottom": "20px"
- # },
- # },
- # "content": [
- # {
- # "component": "VRow",
- # "props": {
- # "align": "center"
- # },
- # "content": []
- # }
- # ]
- # },
- # 基础
- {
- "component": "VWindowItem",
- "props": {
- "value": "basic_tab",
- "style": {
- "padding-top": "20px",
- "padding-bottom": "20px"
- },
- },
- "content": [
- {
- "component": "VRow",
- "props": {
- "align": "center"
- },
- "content": [
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 6,
- },
- "content": [
- {
- "component": "VSwitch",
- "props": {
- "model": "AUXILIARY_AUTH_ENABLE",
- "label": "启用用户辅助认证",
- "hint": "启用后允许通过外部服务进行认证、单点登录以及自动创建用户",
- "persistent-hint": True
- }
- }
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 6,
- },
- "content": [
- {
- "component": "VSwitch",
- "props": {
- "model": "GLOBAL_IMAGE_CACHE",
- "label": "全局图片缓存",
- "hint": "是否启用全局图片缓存,将媒体图片缓存到本地",
- "persistent-hint": True
- }
- }
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 6.
- },
- "content": [
- {
- "component": "VSelect",
- "props": {
- "model": "WALLPAPER",
- "label": "登录首页电影海报",
- "items": [
- {
- "title": "TheMovieDb电影海报",
- "value": "tmdb"
- },
- {
- "title": "Bing每日壁纸",
- "value": "bing"
- }
- ],
- "hint": "登录首页电影海报",
- "persistent-hint": True,
- }
- }
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 6,
- },
- "content": [
- {
- "component": "VTextField",
- "props": {
- "model": "API_TOKEN",
- "label": "API密钥",
- "hint": "用于Jellyseerr/Overseerr、媒体服务器Webhook等配置以及部分支持API_TOKEN的API请求",
- "persistent-hint": True,
- "clearable": True,
- }
- }
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12
- },
- "content": [
- {
- "component": "VTextarea",
- "props": {
- "model": "PLUGIN_MARKET",
- "label": "插件市场",
- "hint": "插件市场仓库地址,多个地址使用逗号分隔,确保每个地址以/结尾",
- "persistent-hint": True,
- "clearable": True,
- }
- }
- ]
- },
-
- ]
- }
- ]
- },
- # 网络
- {
- "component": "VWindowItem",
- "props": {
- "value": "network_tab",
- "style": {
- "padding-top": "20px",
- "padding-bottom": "20px"
- },
- },
- "content": [
- # DOH
- {
- "component": "VRow",
- "props": {
- "align": "center",
- },
- "content": [
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 6
- },
- "content": [
- {
- "component": "VSwitch",
- "props": {
- "model": "DOH_ENABLE",
- "label": "启用DNS over HTTPS",
- "hint": "启用后对特定域名使用DOH解析以避免DNS污染",
- "persistent-hint": True
- }
- }
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 6,
- },
- "content": [
- {
- "component": "VAlert",
- "props": {
- "type": "info",
- "variant": "tonal",
- "style": "white-space: pre-line;",
- "text": "如果已经配置好 'PROXY_HOST' ,建议关闭 'DOH' ",
- },
- },
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 6
- },
- "content": [
- {
- "component": "VTextField",
- "props": {
- "model": "DOH_DOMAINS",
- "label": "DOH解析的域名",
- "hint": "DOH解析的域名列表,多个域名使用逗号分隔",
- "persistent-hint": True,
- "clearable": True,
- }
- }
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 6
- },
- "content": [
- {
- "component": "VTextField",
- "props": {
- "model": "DOH_RESOLVERS",
- "label": "DOH解析服务器",
- "hint": "DOH解析服务器列表,多个服务器使用逗号分隔",
- "persistent-hint": True,
- "clearable": True,
- }
- }
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 6
- },
- "content": [
- {
- "component": "VTextField",
- "props": {
- "model": "GITHUB_TOKEN",
- "label": "GitHub Token",
- "placeholder": "格式: ghp_**** 或 github_pat_****",
- "hint": "GitHub Token,提高请求API限流阈值",
- "persistent-hint": True,
- "clearable": True,
- }
- }
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 6
- },
- "content": [
- {
- "component": "VTextField",
- "props": {
- "model": "OCR_HOST",
- "label": "验证码识别服务器",
- "hint": "验证码识别服务器地址",
- "persistent-hint": True,
- "clearable": True,
- }
- }
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 6
- },
- "content": [
- {
- "component": "VTextField",
- "props": {
- "model": "GITHUB_PROXY",
- "label": "GitHub加速服务器",
- "placeholder": "格式: https://mirror.ghproxy.com/",
- "hint": "留空则不使用GitHub加速服务器,(注意末尾需要带/)",
- "persistent-hint": True,
- "clearable": True,
- }
- }
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 6
- },
- "content": [
- {
- "component": "VTextField",
- "props": {
- "model": "PIP_PROXY",
- "label": "PIP加速服务器",
- "hint": "留空则不使用PIP加速服务器",
- "placeholder": "格式: https://pypi.tuna.tsinghua.edu.cn/simple",
- "persistent-hint": True,
- "clearable": True,
- }
- }
- ]
- },
- ]
- },
- # Tmdb相关
- {
- "component": "VRow",
- "props": {
- "align": "center"
- },
- "content": [
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 6
- },
- "content": [
- {
- "component": "VTextField",
- "props": {
- "model": "TMDB_API_DOMAIN",
- "label": "TMDB API地址",
- "hint": "访问正常时无需更改;无法访问时替换为其他中转服务地址,确保连通性",
- "persistent-hint": True,
- "clearable": True,
- }
- }
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 6
- },
- "content": [
- {
- "component": "VTextField",
- "props": {
- "model": "TMDB_IMAGE_DOMAIN",
- "label": "TheMovieDb图片服务器",
- "placeholder": "例如:static-mdb.v.geilijiasu.com",
- "hint": "访问正常时无需更改;无法访问时可替换为其他可用地址,确保连通性",
- "persistent-hint": True,
- "clearable": True,
- }
- }
- ]
- },
- ]
- },
- ]
- },
- # 媒体与下载
- {
- "component": "VWindowItem",
- "props": {
- "value": "media_and_download_tab",
- "style": {
- "padding-top": "20px",
- "padding-bottom": "20px"
- },
- },
- "content": [
- {
- "component": "VRow",
- "props": {
- "align": "center",
- },
- "content": [
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 9,
- },
- "content": [
- {
- "component": "VSwitch",
- "props": {
- "model": "DOWNLOAD_SUBTITLE",
- "label": "自动下载站点字幕",
- "hint": "自动下载站点字幕(如有)",
- "persistent-hint": True
- }
- }
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 3,
- },
- "content": [
- {
- "component": "VTextField",
- "props": {
- "model": "MEDIASERVER_SYNC_INTERVAL",
- "label": "媒体服务器同步间隔",
- "hint": "媒体服务器同步间隔",
- "persistent-hint": True,
- "prefix": "每",
- "suffix": "小时",
- "type": "number",
- }
- }
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 12,
- },
- "content": [
- {
- "component": "VTextField",
- "props": {
- "model": "AUTO_DOWNLOAD_USER",
- "label": "交互搜索自动下载用户ID",
- "hint": "使用,分割,设置为 all 代表所有用户自动择优下载,未设置需要用户手动选择资源或者回复`0`才自动择优下载",
- "persistent-hint": True,
- "clearable": True,
- }
- }
- ]
- },
- ]
- },
- ]
- },
- # 搜索与整理
- {
- "component": "VWindowItem",
- "props": {
- "value": "search_and_transfer_tab",
- "style": {
- "padding-top": "20px",
- "padding-bottom": "20px"
- },
- },
- "content": [
- {
- "component": "VRow",
- "props": {
- "align": "center"
- },
- "content": [
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 6,
- },
- "content": [
- {
- "component": "VSwitch",
- "props": {
- "model": "SEARCH_MULTIPLE_NAME",
- "label": "资源搜索整合多名称结果",
- "hint": "搜索多个名称时是整合多名称的结果",
- "persistent-hint": True
- }
- }
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 3,
- },
- "content": [
- {
- "component": "VSwitch",
- "props": {
- "model": "FANART_ENABLE",
- "label": "使用Fanart图片数据源",
- "hint": "启用Fanart图片数据源",
- "persistent-hint": True
- }
- }
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 3,
- },
- "content": [
- {
- "component": "VTextField",
- "props": {
- "model": "META_CACHE_EXPIRE",
- "label": "元数据缓存时间",
- "hint": "0或负值时,使用系统默认缓存时间",
- "persistent-hint": True,
- "prefix": "每",
- "suffix": "小时",
- "type": "number",
- }
- }
- ]
- },
- ]
- },
- {
- "component": "VRow",
- "props": {
- "align": "center"
- },
- "content": [
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 6
- },
- "content": [
- {
- "component": "VSelect",
- "props": {
- "model": "RECOGNIZE_SOURCE",
- "label": "媒体信息识别来源",
- "items": [
- {
- "title": "TheMovieDb",
- "value": "themoviedb"
- },
- {
- "title": "豆瓣",
- "value": "douban"
- }
- ],
- "hint": "媒体信息识别来源",
- "persistent-hint": True
- }
- }
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 6
- },
- "content": [
- {
- "component": "VSelect",
- "props": {
- "model": "SCRAP_SOURCE",
- "label": "刮削元数据及图片使用的数据源",
- "items": [
- {
- "title": "TheMovieDb",
- "value": "themoviedb"
- },
- {
- "title": "豆瓣",
- "value": "douban"
- }
- ],
- "hint": "刮削元数据及图片使用的数据源",
- "persistent-hint": True
- }
- }
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12
- },
- "content": [
- {
- "component": "VTextarea",
- "props": {
- "model": "MOVIE_RENAME_FORMAT",
- "label": "电影重命名格式",
- "hint": "电影重命名格式,使用Jinja2语法,每行一个配置项,参考:https://jinja.palletsprojects.com/en/3.0.x/templates/",
- "persistent-hint": True
- }
- }
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12
- },
- "content": [
- {
- "component": "VTextarea",
- "props": {
- "model": "TV_RENAME_FORMAT",
- "label": "电视剧重命名格式",
- "hint": "电视剧重命名格式,使用Jinja2语法",
- "persistent-hint": True
- }
- }
- ]
- },
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- "md": 12,
- },
- "content": [
- {
- "component": "VAlert",
- "props": {
- "type": "warning",
- "variant": "tonal",
- "style": "white-space: pre-line;",
- "text": "Jinja2语法参考:"
- },
- "content": [
- {
- "component": "a",
- "props": {
- "href": "https://jinja.palletsprojects.com/en/3.0.x/templates/",
- "target": "_blank"
- },
- "content": [
- {
- "component": "u",
- "text": "https://jinja.palletsprojects.com/en/3.0.x/templates/"
- }
- ]
- }
- ]
- },
- ]
- }
- ]
- }
- ]
- },
- # 自定义
- {
- "component": "VWindowItem",
- "props": {
- "value": "params_tab",
- "style": {
- "padding-top": "20px",
- "padding-bottom": "20px"
- },
- },
- "content": [
- {
- "component": "VRow",
- "props": {
- "align": "center",
- },
- "content": [
- {
- "component": "VCol",
- "props": {
- "cols": 12,
- },
- "content": [
- {
- "component": "VTextarea",
- "props": {
- "model": "params",
- "label": "自定义配置",
- "hint": "自定义配置,每行一个配置项,格式:配置项=值",
- "persistent-hint": True
- }
- }
- ]
- }
- ]
- },
- ]
- }
- ]
- },
- ]
-
- def get_page(self) -> List[dict]:
- pass
-
- def stop_service(self):
- """
- 退出插件
- """
- pass
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
index dfba16c..5d1ffec 100644
--- a/plugins.v2/iyuuautoseed/__init__.py
+++ b/plugins.v2/iyuuautoseed/__init__.py
@@ -33,7 +33,7 @@ class IYUUAutoSeed(_PluginBase):
# 插件图标
plugin_icon = "IYUU.png"
# 插件版本
- plugin_version = "2.0.1"
+ plugin_version = "2.1"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
diff --git a/plugins.v2/iyuuautoseed/iyuu_helper.py b/plugins.v2/iyuuautoseed/iyuu_helper.py
index 7fd1a70..d322385 100644
--- a/plugins.v2/iyuuautoseed/iyuu_helper.py
+++ b/plugins.v2/iyuuautoseed/iyuu_helper.py
@@ -11,7 +11,7 @@ class IyuuHelper(object):
适配新版本IYUU开发版
"""
_version = "8.2.0"
- _api_base = "https://dev.iyuu.cn"
+ _api_base = "https://2025.iyuu.cn"
_sites = {}
_token = None
_sid_sha1 = None
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
index 6aa67ee..f3ab683 100644
--- a/plugins.v2/torrentremover/__init__.py
+++ b/plugins.v2/torrentremover/__init__.py
@@ -26,7 +26,7 @@ class TorrentRemover(_PluginBase):
# 插件图标
plugin_icon = "delete.jpg"
# 插件版本
- plugin_version = "2.1"
+ plugin_version = "2.1.1"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
@@ -814,7 +814,7 @@ class TorrentRemover(_PluginBase):
name = remove_torrent.get("name")
size = remove_torrent.get("size")
for torrent in torrents:
- if downloader == "qbittorrent":
+ if downloader_config.type == "qbittorrent":
plus_id = torrent.hash
plus_name = torrent.name
plus_size = torrent.size
diff --git a/plugins/bangumicoll/__init__.py b/plugins/bangumicoll/__init__.py
index 2abb002..5728e41 100644
--- a/plugins/bangumicoll/__init__.py
+++ b/plugins/bangumicoll/__init__.py
@@ -36,7 +36,7 @@ class BangumiColl(_PluginBase):
# 插件图标
plugin_icon = "bangumi_b.png"
# 插件版本
- plugin_version = "1.5.1"
+ plugin_version = "1.5.2"
# 插件作者
plugin_author = "Attente"
# 作者主页
@@ -49,7 +49,7 @@ class BangumiColl(_PluginBase):
auth_level = 1
# 私有属性
- _scheduler: Optional[BackgroundScheduler] = None
+ _scheduler = None
siteoper: SiteOper = None
subscribehelper: SubscribeHelper = None
subscribeoper: SubscribeOper = None
@@ -148,8 +148,10 @@ class BangumiColl(_PluginBase):
return form(sites_options)
def get_service(self) -> List[Dict[str, Any]]:
- """注册插件公共服务"""
- if self._enabled:
+ """
+ 注册插件公共服务
+ """
+ if self._enabled or self._cron:
trigger = CronTrigger.from_crontab(self._cron) if self._cron else "interval"
kwargs = {"hours": 6} if not self._cron else {}
return [
@@ -207,8 +209,6 @@ class BangumiColl(_PluginBase):
# 新增和移除条目
self.manage_subscriptions(items)
-
- logger.info("Bangumi收藏订阅执行完成")
except Exception as e:
logger.error(f"执行失败: {str(e)}")
@@ -249,10 +249,12 @@ class BangumiColl(_PluginBase):
del_items = {db_sub[i]: i for i in del_sub}
logger.info("开始移除订阅...")
self.delete_subscribe(del_items)
+ logger.info("移除完成")
if new_sub:
logger.info("开始添加订阅...")
msg = self.add_subscribe({i: items[i] for i in new_sub})
+ logger.info("添加完成")
if msg:
logger.info("\n".ljust(49, ' ').join(list(msg.values())))
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
index 6cc8bbf..aa16cfc 100644
--- a/plugins/dynamicwechat/__init__.py
+++ b/plugins/dynamicwechat/__init__.py
@@ -19,18 +19,19 @@ from app.helper.cookiecloud import CookieCloudHelper
from app.log import logger
from app.plugins import _PluginBase
from app.plugins.dynamicwechat.update_help import PyCookieCloud
-from app.schemas.types import EventType, NotificationType
+from app.plugins.dynamicwechat.notify_helper import MySender
+from app.schemas.types import EventType
class DynamicWeChat(_PluginBase):
# 插件名称
- plugin_name = "修改企业微信可信IP"
+ plugin_name = "动态企微可信IP"
# 插件描述
- plugin_desc = "优先使用cookie,可本地扫码刷新Cookie,当填写两个第三方token时手机微信可以更新cookie。"
+ plugin_desc = "修改企微应用可信IP,详细说明查看'作者主页',支持第三方通知。验证码以?结尾发送到企业微信应用"
# 插件图标
plugin_icon = "Wecom_A.png"
# 插件版本
- plugin_version = "1.3.1"
+ plugin_version = "1.5.1"
# 插件作者
plugin_author = "RamenRa"
# 作者主页
@@ -41,6 +42,8 @@ class DynamicWeChat(_PluginBase):
plugin_order = 47
# 可使用的用户级别
auth_level = 2
+ # 检测间隔时间,默认10分钟
+ _refresh_cron = '*/20 * * * *'
# ------------------------------------------私有属性------------------------------------------
_enabled = False # 开关
@@ -54,6 +57,12 @@ class DynamicWeChat(_PluginBase):
_cc_server = None
# 本地扫码开关
_local_scan = False
+ # 类初始化时添加标记变量
+ _is_special_upload = False
+ # 聚合通知
+ _my_send = None
+ # 通知方式token/api
+ _notification_token = ''
# 匹配ip地址的正则
_ip_pattern = r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b'
@@ -63,28 +72,26 @@ class DynamicWeChat(_PluginBase):
_current_ip_address = '0.0.0.0'
# 企业微信登录
_wechatUrl = 'https://work.weixin.qq.com/wework_admin/loginpage_wx?from=myhome'
- # 检测间隔时间,默认10分钟
- _refresh_cron = '*/20 * * * *'
+
# 输入的企业应用id
_input_id_list = ''
- # helloimg的token
- _helloimg_s_token = ""
- # pushplus的token
- _pushplus_token = ""
# 二维码
_qr_code_image = None
+ # 用户消息
text = ""
# 手机验证码
_verification_code = ''
# 过期时间
_future_timestamp = 0
+ # 配置文件路径
+ _settings_file_path = None
# cookie有效检测
- # _cookie_valid = False
+ _cookie_valid = True
+ # cookie存活时间
+ _cookie_lifetime = 0
# 使用CookieCloud开关
_use_cookiecloud = True
- # 从CookieCloud获取的cookie
- _cookie_from_CC = ""
# 登录cookie
_cookie_header = ""
_server = f'http://localhost:{settings.NGINX_PORT}/cookiecloud'
@@ -93,42 +100,47 @@ class DynamicWeChat(_PluginBase):
# 定时器
_scheduler: Optional[BackgroundScheduler] = None
+ if hasattr(settings, 'VERSION_FLAG'):
+ version = settings.VERSION_FLAG # V2
+ else:
+ version = "v1"
def init_plugin(self, config: dict = None):
# 清空配置
- self._server = f'http://localhost:{settings.NGINX_PORT}/cookiecloud'
- self._helloimg_s_token = ''
- self._pushplus_token = ''
+ self._notification_token = ''
self._ip_changed = True
self._forced_update = False
self._use_cookiecloud = True
self._local_scan = False
self._input_id_list = ''
self._cookie_header = ""
- self._cookie_from_CC = ""
- self._current_ip_address = self.get_ip_from_url(self._ip_urls[0])
+ self._current_ip_address = self.get_ip_from_url(random.choice(self._ip_urls))
+ self._settings_file_path = self.get_data_path() / "settings.json"
+ # self._cookie_lifetime = PyCookieCloud.load_cookie_lifetime()
if config:
self._enabled = config.get("enabled")
+ self._notification_token = config.get("notification_token")
self._cron = config.get("cron")
self._onlyonce = config.get("onlyonce")
self._input_id_list = config.get("input_id_list")
self._current_ip_address = config.get("current_ip_address")
- self._pushplus_token = config.get("pushplus_token")
- self._helloimg_s_token = config.get("helloimg_s_token")
- self._cookie_from_CC = config.get("cookie_from_CC")
self._forced_update = config.get("forced_update")
self._local_scan = config.get("local_scan")
self._use_cookiecloud = config.get("use_cookiecloud")
self._cookie_header = config.get("cookie_header")
self._ip_changed = config.get("ip_changed")
- self.try_connect_cc()
-
+ 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.stop_service()
if (self._enabled or self._onlyonce) and self._input_id_list:
# 定时服务
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
# 运行一次定时服务
- if self._onlyonce or self._forced_update:
+ if self._onlyonce:
logger.info("立即检测公网IP")
self._scheduler.add_job(func=self.check, trigger='date',
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
@@ -136,6 +148,12 @@ class DynamicWeChat(_PluginBase):
# 关闭一次性开关
self._onlyonce = False
+ if self._forced_update:
+ self._scheduler.add_job(func=self.forced_change, trigger='date',
+ run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
+ name="强制更新公网IP") # 添加任务
+ self._forced_update = False
+
if self._local_scan:
self._scheduler.add_job(func=self.local_scanning, trigger='date',
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
@@ -155,11 +173,41 @@ class DynamicWeChat(_PluginBase):
if self._scheduler.get_jobs():
self._scheduler.print_jobs()
self._scheduler.start()
- if self._forced_update:
- time.sleep(4)
- self._forced_update = False
self.__update_config()
+ @eventmanager.register(EventType.PluginAction)
+ def forced_change(self, event: Event = None):
+ """
+ 强制修改IP
+ """
+ if not self._enabled:
+ logger.error("插件未开启")
+ return
+ if event:
+ event_data = event.event_data
+ if not event_data or event_data.get("action") != "dynamicwechat":
+ return
+ # 先尝试cookie登陆
+ try:
+ with sync_playwright() as p:
+ browser = p.chromium.launch(headless=True, args=['--lang=zh-CN'])
+ context = browser.new_context()
+ cookie = self.get_cookie()
+ if cookie:
+ context.add_cookies(cookie)
+ page = context.new_page()
+ page.goto(self._wechatUrl)
+ time.sleep(3)
+ if self.check_login_status(page, task='forced_change'):
+ self.click_app_management_buttons(page)
+ else:
+ logger.error("cookie失效,强制修改IP失败:请使用'本地扫码修改IP'")
+ browser.close()
+ except Exception as err:
+ logger.error(f"强制修改IP失败:{err}")
+
+ logger.info("----------------------本次任务结束----------------------")
+
@eventmanager.register(EventType.PluginAction)
def local_scanning(self, event: Event = None):
"""
@@ -180,12 +228,11 @@ class DynamicWeChat(_PluginBase):
page = context.new_page()
page.goto(self._wechatUrl)
time.sleep(3) # 页面加载等待时间
-
if self.find_qrc(page):
current_time = datetime.now()
future_time = current_time + timedelta(seconds=110)
self._future_timestamp = int(future_time.timestamp())
- logger.info("请重新进入插件面板扫码!,每20秒检查登录状态,最大尝试5次")
+ logger.info("请重新进入插件面板扫码! 每20秒检查登录状态,最大尝试5次")
max_attempts = 5
attempt = 0
while attempt < max_attempts:
@@ -197,9 +244,9 @@ class DynamicWeChat(_PluginBase):
self.click_app_management_buttons(page)
break
else:
- logger.info("未检测到登录,任务结束")
+ logger.info("用户可能没有扫码或登录失败")
else:
- logger.info("未找到二维码,任务结束")
+ logger.error("未找到二维码,任务结束")
logger.info("----------------------本次任务结束----------------------")
browser.close()
except Exception as e:
@@ -218,22 +265,15 @@ class DynamicWeChat(_PluginBase):
event_data = event.event_data
if not event_data or event_data.get("action") != "dynamicwechat":
return
- # logger.info("收到命令,开始检测公网IP ...")
- # self.post_message(channel=event.event_data.get("channel"),
- # title="开始检测公网IP ...",
- # userid=event.event_data.get("user"))
- logger.info("开始检测公网IP")
- if self.CheckIP():
- self.ChangeIP()
- self.__update_config()
-
- # logger.info("检测公网IP完毕")
- logger.info("----------------------本次任务结束----------------------")
- # if event:
- # self.post_message(channel=event.event_data.get("channel"),
- # title="检测公网IP完毕",
- # userid=event.event_data.get("user"))
+ if self._cookie_valid:
+ logger.info("开始检测公网IP")
+ if self.CheckIP():
+ self.ChangeIP()
+ self.__update_config()
+ logger.info("----------------------本次任务结束----------------------")
+ else:
+ logger.warning("cookie已失效请及时更新,不检测公网IP")
def CheckIP(self):
retry_urls = random.sample(self._ip_urls, len(self._ip_urls))
@@ -324,22 +364,15 @@ class DynamicWeChat(_PluginBase):
qr_code_data = requests.get(qr_code_url).content
self._qr_code_image = io.BytesIO(qr_code_data)
- return True
+ refuse_time = (datetime.now() + timedelta(seconds=115)).strftime("%Y-%m-%d %H:%M:%S")
+ return qr_code_url, refuse_time
else:
logger.warning("未找到二维码")
- return False
+ return None, None
except Exception as e:
logger.debug(str(e))
- return False
+ return None, None
- def send_pushplus_message(self, title, content):
- pushplus_url = f"http://www.pushplus.plus/send/{self._pushplus_token}"
- pushplus_data = {
- "title": title,
- "content": content,
- "template": "html"
- }
- response = requests.post(pushplus_url, json=pushplus_data)
def ChangeIP(self):
@@ -349,25 +382,20 @@ class DynamicWeChat(_PluginBase):
# 启动 Chromium 浏览器并设置语言为中文
browser = p.chromium.launch(headless=True, args=['--lang=zh-CN'])
context = browser.new_context()
- # ----------cookie addd-----------------
cookie = self.get_cookie()
if cookie:
context.add_cookies(cookie)
- # ----------cookie END-----------------
page = context.new_page()
page.goto(self._wechatUrl)
time.sleep(3)
- if self.find_qrc(page):
- if self._pushplus_token and self._helloimg_s_token:
- img_src, refuse_time = self.upload_image(self._qr_code_image)
- self.send_pushplus_message(refuse_time, f"企业微信登录二维码
")
- # if img_src:
- # self.post_message(
- # mtype=NotificationType.Plugin,
- # title="企业微信登录二维码",
- # text=refuse_time,
- # image=img_src
- # )
+ img_src, refuse_time = self.find_qrc(page)
+ if img_src:
+ if self._my_send:
+ result = self._my_send.send(title="企业微信登录二维码", image=img_src)
+ if result:
+ logger.info(f"二维码发送失败,原因:{result}")
+ browser.close()
+ return
logger.info("二维码已经发送,等待用户 90 秒内扫码登录")
# logger.info("如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?")
time.sleep(90) # 等待用户扫码
@@ -378,17 +406,15 @@ class DynamicWeChat(_PluginBase):
else:
self._ip_changed = False
else:
- logger.info("cookie失效,请重新上传或者配置pushplus_token和helloimg_s_token。")
+ self._ip_changed = False
+ logger.info("cookie已失效")
else: # 如果直接进入企业微信
logger.info("尝试cookie登录")
- # ----------cookie addd-----------------
login_status = self.check_login_status(page, "")
if login_status:
self.click_app_management_buttons(page)
else:
- # ----------cookie END-----------------
self._ip_changed = False
- return
browser.close()
except Exception as e:
@@ -398,6 +424,7 @@ class DynamicWeChat(_PluginBase):
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() # 再尝试一次连接
@@ -411,7 +438,6 @@ class DynamicWeChat(_PluginBase):
if current_cookies is None:
logger.error("无法获取当前 cookies")
return
-
formatted_cookies = {}
for cookie in current_cookies:
domain = cookie.get('domain') # 使用 get() 方法避免 KeyError
@@ -421,7 +447,7 @@ class DynamicWeChat(_PluginBase):
if domain not in formatted_cookies:
formatted_cookies[domain] = []
formatted_cookies[domain].append(cookie)
- flag = self._cc_server.update_cookie({'cookie_data': formatted_cookies})
+ flag = self._cc_server.update_cookie(formatted_cookies)
if flag:
logger.info("更新 CookieCloud 成功")
else:
@@ -429,8 +455,7 @@ class DynamicWeChat(_PluginBase):
else:
logger.error("连接 CookieCloud 失败", self._server)
except Exception as e:
- logger.error(
- f"更新 cookie 发生错误: {e}")
+ logger.error(f"更新 cookie 发生错误: {e}")
else:
logger.error("CookieCloud没有启用或配置错误, 不刷新cookie")
@@ -442,22 +467,23 @@ class DynamicWeChat(_PluginBase):
if not cookies: # CookieCloud获取cookie失败
logger.error(f"CookieCloud获取cookie失败,失败原因:{msg}")
return
- # cookie_header = self._cookie_header
else:
for domain, cookie in cookies.items():
if domain == ".work.weixin.qq.com":
cookie_header = cookie
+ if '_upload_type=A' in cookie:
+ self._is_special_upload = True
+ else:
+ self._is_special_upload = False
break
if cookie_header == '':
cookie_header = self._cookie_header
else: # 不使用CookieCloud
return
cookie = self.parse_cookie_header(cookie_header)
- self._cookie_from_CC = cookie
return cookie
except Exception as e:
logger.error(f"从CookieCloud获取cookie错误,错误原因:{e}")
- # logger.info("尝试推送登录二维码")
return
@staticmethod
@@ -474,21 +500,34 @@ class DynamicWeChat(_PluginBase):
return cookies
def refresh_cookie(self): # 保活
- try:
- with sync_playwright() as p:
- browser = p.chromium.launch(headless=True, args=['--lang=zh-CN'])
- context = browser.new_context()
- cookie = self.get_cookie()
- if cookie:
- context.add_cookies(cookie)
- page = context.new_page()
- page.goto(self._wechatUrl)
- time.sleep(3)
- if not self.check_login_status(page, task='refresh_cookie'):
- logger.info("cookie已失效,下次IP变动推送二维码")
- browser.close()
- except Exception as e:
- logger.error(f"cookie校验失败:{e}")
+ if self._use_cookiecloud:
+ try:
+ with sync_playwright() as p:
+ browser = p.chromium.launch(headless=True, args=['--lang=zh-CN'])
+ context = browser.new_context()
+ cookie = self.get_cookie()
+ if cookie:
+ context.add_cookies(cookie)
+ page = context.new_page()
+ page.goto(self._wechatUrl)
+ time.sleep(3)
+ if not self.check_login_status(page, task='refresh_cookie'):
+ self._cookie_valid = False
+ if self._my_send:
+ result = self._my_send.send(title="cookie已失效,请及时更新",
+ content="请在企业微信应用发送/push_qr,让插件推送二维码。如果是使用微信通知请确保公网IP还没有变动",
+ image=None, force_send=False) # 标题,内容,图片,是否强制发送
+ if result:
+ logger.info(f"cookie失效通知发送失败,原因:{result}")
+ else:
+ self._cookie_valid = True
+ if self._my_send:
+ self._my_send.reset_limit()
+ PyCookieCloud.increase_cookie_lifetime(self._settings_file_path, 1200)
+ self._cookie_lifetime = PyCookieCloud.load_cookie_lifetime(self._settings_file_path)
+ browser.close()
+ except Exception as e:
+ logger.error(f"cookie校验失败:{e}")
#
def check_login_status(self, page, task):
@@ -536,8 +575,9 @@ class DynamicWeChat(_PluginBase):
except Exception as e:
# logger.debug(str(e)) # 基于bug运行,请不要将错误输出到日志
# try: # 没有登录成功,也没有短信验证码
- if self.find_qrc(page) and not task == 'refresh_cookie': # 延长任务找到的二维码不会被发送,所以不算用户没有扫码
- logger.error(f"用户没有扫描二维码")
+ if self.find_qrc(
+ page) and not task == 'refresh_cookie' and not task == 'local_scanning': # 延长任务找到的二维码不会被发送,所以不算用户没有扫码
+ logger.warning(f"用户没有扫描二维码")
return False
def click_app_management_buttons(self, page):
@@ -547,8 +587,8 @@ class DynamicWeChat(_PluginBase):
# ("//span[@class='frame_nav_item_title' and text()='应用管理']", "应用管理"),
# ("//div[@class='app_index_item_title ' and contains(text(), 'MoviePilot')]", "MoviePilot"),
(
- "//div[contains(@class, 'js_show_ipConfig_dialog')]//a[contains(@class, '_mod_card_operationLink') and text()='配置']",
- "配置")
+ "//div[contains(@class, 'js_show_ipConfig_dialog')]//a[contains(@class, '_mod_card_operationLink') and text()='配置']",
+ "配置")
]
if self._input_id_list:
id_list = self._input_id_list.split(",")
@@ -581,81 +621,15 @@ class DynamicWeChat(_PluginBase):
logger.info(f"应用: {app_id} 输入IP:" + self._current_ip_address)
ip_parts = self._current_ip_address.split('.')
masked_ip = f"{ip_parts[0]}.{len(ip_parts[1]) * '*'}.{len(ip_parts[2]) * '*'}.{ip_parts[3]}"
- self.post_message(
- mtype=NotificationType.Plugin,
- title="更新可信IP成功",
- text='应用: ' + app_id + ' 输入IP:' + masked_ip,
- # image=img_src
- )
+ if self._my_send:
+ result = self._my_send.send(title="更新可信IP成功",
+ content='应用: ' + app_id + ' 输入IP:' + masked_ip,
+ force_send=True, diy_channel="WeChat")
return
else:
logger.error("未找到应用id,修改IP失败")
return
- def upload_image(self, file_obj, permission=1, strategy_id=1, album_id=1):
- """
- 上传图片到 helloimg 图床,支持传入文件路径或 BytesIO 对象。
-
- :param file_obj: 文件对象,可以是路径 (str) 或 BytesIO 对象
- :param permission: 上传图片的权限设置,默认 1
- :param strategy_id: 上传策略 ID,默认 1
- :param album_id: 相册 ID,默认 1
- :return: 上传成功返回图片链接,失败返回 None
- """
- helloimg_token = "Bearer " + self._helloimg_s_token
- helloimg_url = "https://www.helloimg.com/api/v1/upload"
- headers = {
- "Authorization": helloimg_token,
- "Accept": "application/json",
- }
-
- # 构造上传的文件,支持传入 BytesIO 或文件路径
- if isinstance(file_obj, io.BytesIO):
- # 如果是 BytesIO 对象,直接使用
- files = {
- "file": ('qr_code.png', file_obj, 'image/png')
- }
- else:
- # 如果是文件路径,打开文件进行读取
- files = {
- "file": open(file_obj, "rb")
- }
-
- expired_at = (datetime.now() + timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S")
- helloimg_data = {
- "token": "你的临时上传 Token", # 确保这里的 token 是有效的
- "permission": permission,
- "strategy_id": strategy_id,
- "album_id": album_id,
- "expired_at": expired_at
- }
- refuse_time = (datetime.now() + timedelta(seconds=110)).strftime("%Y-%m-%d %H:%M:%S")
-
- # 发送上传请求
- response = requests.post(helloimg_url, headers=headers, files=files, data=helloimg_data)
-
- # 检查响应内容是否符合预期
- response_data = None
- try:
- response_data = response.json()
- if not response_data['status']:
- if response_data['message'] == "Unauthenticated.":
- logger.error("Token失效,无法上传图片。请检查你的上传Token。")
- logger.info(f"使用的Token: {helloimg_token}")
- # self._ip_changed = False
- return
- else:
- logger.error(f"上传到图床失败: {response_data['message']}")
- self._ip_changed = False
- return
-
- img_src = response_data['data']['links']['html']
- return img_src.split('"')[1], refuse_time # 提取 img src
- except KeyError as e:
- logger.error(f"上传图片时解析响应失败: {e}, 响应内容: {response_data}")
- self._ip_changed = False
- return
-
def __update_config(self):
"""
更新配置
@@ -664,15 +638,12 @@ class DynamicWeChat(_PluginBase):
"enabled": self._enabled,
"onlyonce": self._onlyonce,
"cron": self._cron,
- # "wechatUrl": self._wechatUrl,
+ "notification_token": self._notification_token,
"current_ip_address": self._current_ip_address,
"ip_changed": self._ip_changed,
"forced_update": self._forced_update,
"local_scan": self._local_scan,
- "helloimg_s_token": self._helloimg_s_token,
- "pushplus_token": self._pushplus_token,
"input_id_list": self._input_id_list,
- "cookie_from_CC": self._cookie_from_CC,
"cookie_header": self._cookie_header,
"use_cookiecloud": self._use_cookiecloud,
})
@@ -741,7 +712,6 @@ class DynamicWeChat(_PluginBase):
}
]
},
- # 添加 "使用CookieCloud获取cookie" 开关按钮
{
'component': 'VRow',
'content': [
@@ -772,7 +742,7 @@ class DynamicWeChat(_PluginBase):
'component': 'VSwitch',
'props': {
'model': 'local_scan',
- 'label': '扫码刷新Cookie和改IP',
+ 'label': '本地扫码修改IP',
}
}
]
@@ -793,11 +763,29 @@ class DynamicWeChat(_PluginBase):
'component': 'VTextField',
'props': {
'model': 'cron',
- 'label': '检测周期',
+ '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'
+ }
+ }
+ ]
}
]
},
@@ -814,7 +802,7 @@ class DynamicWeChat(_PluginBase):
'component': 'VTextarea',
'props': {
'model': 'input_id_list',
- 'label': '应用ID',
+ 'label': '[必填]应用ID',
'rows': 1,
'placeholder': '输入应用ID,多个ID用英文逗号分隔。在企业微信应用页面URL末尾获取'
}
@@ -823,47 +811,6 @@ class DynamicWeChat(_PluginBase):
}
]
},
- {
- 'component': 'VRow',
- 'content': [
- {
- 'component': 'VCol',
- 'props': {
- 'cols': 12,
- 'md': 6
- },
- 'content': [
- {
- 'component': 'VTextarea',
- 'props': {
- 'model': 'pushplus_token',
- 'label': 'pushplus_token',
- 'rows': 1,
- 'placeholder': '[可选] 请输入 pushplus_token'
- }
- }
- ]
- },
- {
- 'component': 'VCol',
- 'props': {
- 'cols': 12,
- 'md': 6
- },
- 'content': [
- {
- 'component': 'VTextarea',
- 'props': {
- 'model': 'helloimg_s_token',
- 'label': 'helloimg_s_token',
- 'rows': 1,
- 'placeholder': '[可选] 请输入 helloimg_token'
- }
- }
- ]
- }
- ]
- },
{
'component': 'VRow',
'content': [
@@ -878,7 +825,7 @@ class DynamicWeChat(_PluginBase):
'props': {
'type': 'info',
'variant': 'tonal',
- 'text': '使用内建CookieCloud 或 自定义 或 填写两个token 至少三选一,否则无法正常使用'
+ 'text': '建议启用内建或自定义CookieCloud。支持微信、Server酱等第三方通知,具体请查看作者主页'
}
}
]
@@ -898,7 +845,7 @@ class DynamicWeChat(_PluginBase):
'component': 'VAlert',
'props': {
'type': 'info',
- 'text': '优先使用cookie,当IP变动 且 cookie失效 且 填写两个token才会调用API推送登录二维码。',
+ 'text': 'Cookie失效时通知用户,用户使用/push_qr让插件推送二维码。使用第三方通知时填写对应Token/API'
}
}
]
@@ -913,14 +860,12 @@ class DynamicWeChat(_PluginBase):
"onlyonce": False,
"forceUpdate": False,
"use_cookiecloud": True,
- "use_local_qr": False, # 默认关闭本地扫码
+ "use_local_qr": False,
"cookie_header": "",
- "pushplus_token": "",
- "helloimg_token": "",
- "input_id_list": "",
+ "notification_token": "",
+ "input_id_list": ""
}
-
def get_page(self) -> List[dict]:
# 获取当前时间戳
current_time = datetime.now().timestamp()
@@ -972,8 +917,33 @@ class DynamicWeChat(_PluginBase):
}
}
}
+ if self._is_special_upload:
+ # 计算 cookie_lifetime 的天数、小时数和分钟数
+ cookie_lifetime_days = self._cookie_lifetime // 86400 # 一天的秒数为 86400
+ cookie_lifetime_hours = (self._cookie_lifetime % 86400) // 3600 # 计算小时数
+ cookie_lifetime_minutes = (self._cookie_lifetime % 3600) // 60 # 计算分钟数
+ bg_color = "#40bb45" if self._cookie_valid else "#ff0000"
+ cookie_lifetime_text = f"Cookie 已使用: {cookie_lifetime_days}天{cookie_lifetime_hours}小时{cookie_lifetime_minutes}分钟"
+
+ cookie_lifetime_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",
@@ -985,24 +955,39 @@ class DynamicWeChat(_PluginBase):
"content": [
{
"component": "div",
- "text": vaild_text,
"props": {
"style": {
- "fontSize": "22px",
- "fontWeight": "bold",
- "color": "#ffffff",
- "backgroundColor": color,
- "padding": "8px",
- "borderRadius": "5px",
- "display": "inline-block",
- "textAlign": "center",
- "marginBottom": "40px"
+ "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 # 二维码图片或提示信息
]
- },
- img_component # 二维码图片
+ }
]
return base_content
@@ -1026,10 +1011,14 @@ class DynamicWeChat(_PluginBase):
page = context.new_page()
page.goto(self._wechatUrl)
time.sleep(3)
- if self.find_qrc(page):
- if self._pushplus_token and self._helloimg_s_token:
- img_src, refuse_time = self.upload_image(self._qr_code_image)
- self.send_pushplus_message(refuse_time, f"企业微信登录二维码
")
+ image_src, refuse_time = self.find_qrc(page)
+ if image_src:
+ if self._my_send:
+ result = self._my_send.send("企业微信登录二维码", image=image_src)
+ if result:
+ logger.info(f"远程推送任务: 二维码发送失败,原因:{result}")
+ browser.close()
+ return
logger.info("远程推送任务: 二维码已经发送,等待用户 90 秒内扫码登录")
# logger.info("远程推送任务: 如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301?")
time.sleep(90)
@@ -1039,9 +1028,10 @@ class DynamicWeChat(_PluginBase):
# logger.info("远程推送任务: 没有可用的CookieCloud服务器,只修改可信IP")
self.click_app_management_buttons(page)
else:
- logger.warning("远程推送任务: 未配置pushplus_token和helloimg_s_token")
+ logger.warning("远程推送任务: 没有找到可用的通知方式")
else:
logger.warning("远程推送任务: 未找到二维码")
+ browser.close()
except Exception as e:
logger.error(f"远程推送任务: 推送二维码失败: {e}")
@@ -1051,7 +1041,7 @@ class DynamicWeChat(_PluginBase):
{
"cmd": "/push_qr",
"event": EventType.PluginAction,
- "desc": "立即推送登录二维码到pushplus",
+ "desc": "立即推送登录二维码",
"category": "",
"data": {
"action": "push_qrcode"
@@ -1109,5 +1099,3 @@ class DynamicWeChat(_PluginBase):
self._scheduler = None
except Exception as e:
logger.error(str(e))
-
-
diff --git a/plugins/dynamicwechat/notify_helper.py b/plugins/dynamicwechat/notify_helper.py
new file mode 100644
index 0000000..6c70a5d
--- /dev/null
+++ b/plugins/dynamicwechat/notify_helper.py
@@ -0,0 +1,148 @@
+import re
+import requests
+from app.modules.wechat import WeChat
+from app.schemas.types import NotificationType
+
+
+class MySender:
+ def __init__(self, token=None, func=None):
+ self.token = token
+ self.channel = self.send_channel() if token else None # 初始化时确定发送渠道
+ self.first_text_sent = False # 记录是否已发送过纯文本消息
+ self.init_success = bool(token) # 标识初始化成功
+ self.post_message_func = func # V2微信模式的 post_message 方法
+
+ def send_channel(self):
+ if "WeChat" in self.token:
+ return "WeChat"
+
+ letters_only = ''.join(re.findall(r'[A-Za-z]', self.token))
+ if self.token.lower().startswith("sct".lower()):
+ return "ServerChan"
+ elif letters_only.isupper():
+ return "AnPush"
+ else:
+ return "PushPlus"
+
+ # 标题,内容,图片,是否强制发送
+ def send(self, title, content=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
+ else:
+ self.first_text_sent = True
+
+ # # 如果是 V2 微信通知直接处理
+ if self.channel == "WeChat" and self.post_message_func:
+ return self.send_v2_wechat(title, content, image)
+
+ try:
+ if not diy_channel:
+ channel = self.channel
+ else:
+ channel = diy_channel
+
+ if channel == "WeChat":
+ return MySender.send_wechat(title, content, image, self.token)
+ elif channel == "ServerChan":
+ return self.send_serverchan(title, content, image)
+ elif channel == "AnPush":
+ return self.send_anpush(title, content, image)
+ elif channel == "PushPlus":
+ return self.send_pushplus(title, content, image)
+ else:
+ return "Unknown channel"
+ except Exception as e:
+ return f"Error occurred: {str(e)}"
+
+ @staticmethod
+ def send_wechat(title, content, image, token):
+ wechat = WeChat()
+ if ',' in token:
+ channel, actual_userid = token.split(',', 1)
+ else:
+ actual_userid = None
+ if image:
+ send_status = wechat.send_msg(title='企业微信登录二维码', image=image, link=image, userid=actual_userid)
+ else:
+ send_status = wechat.send_msg(title=title, text=content)
+
+ if send_status is None:
+ return "微信通知发送错误"
+ return None
+
+ def send_serverchan(self, title, content, image):
+ if self.token.startswith('sctp'):
+ match = re.match(r'sctp(\d+)t', self.token)
+ if match:
+ num = match.group(1)
+ url = f'https://{num}.push.ft07.com/send/{self.token}.send'
+ else:
+ raise ValueError('Invalid sendkey format for sctp')
+ else:
+ url = f'https://sctapi.ftqq.com/{self.token}.send'
+
+ params = {'title': title, 'desp': f'' if image else content}
+ headers = {'Content-Type': 'application/json;charset=utf-8'}
+ response = requests.post(url, json=params, headers=headers)
+ result = response.json()
+ if result.get('code') != 0:
+ return f"Server酱通知错误: {result.get('message')}"
+ return None
+
+ def send_anpush(self, title, content, image):
+ if ',' in self.token:
+ channel, token = self.token.split(',', 1)
+ else:
+ return
+ url = f"https://api.anpush.com/push/{token}"
+ payload = {
+ "title": title,
+ "content": f"
" if image else content,
+ "channel": channel
+ }
+ headers = {"Content-Type": "application/x-www-form-urlencoded"}
+ response = requests.post(url, headers=headers, data=payload)
+ result = response.json()
+ # 判断返回的code和msgIds
+ if result.get('code') != 200:
+ return f"AnPush: {result.get('msg')}"
+ elif not result.get('data') or not result['data'].get('msgIds'):
+ return "AnPush 消息通道未找到"
+ return None
+
+ def send_pushplus(self, title, content, image):
+ pushplus_url = f"http://www.pushplus.plus/send/{self.token}"
+ # PushPlus发送逻辑
+ data = {
+ "title": title,
+ "content": f"企业微信登录二维码
" if image else content,
+ "template": "html"
+ }
+ response = requests.post(pushplus_url, json=data)
+ result = response.json()
+ if result.get('code') != 200:
+ return f"PushPlus send failed: {result.get('msg')}"
+ return None
+
+ def send_v2_wechat(self, title, content, image):
+ """V2 微信通知发送"""
+ if not self.token or ',' not in self.token:
+ return '没有指定V2微信用户ID'
+ channel, actual_userid = self.token.split(',', 1)
+ self.post_message_func(
+ mtype=NotificationType.Plugin,
+ title=title,
+ text=content,
+ image=image,
+ link=image,
+ userid=actual_userid
+ )
+ return None
+
+ def reset_limit(self):
+ """解除限制,允许再次发送纯文本消息"""
+ self.first_text_sent = False
diff --git a/plugins/dynamicwechat/src/debug.py b/plugins/dynamicwechat/src/debug.py
deleted file mode 100644
index c6499d7..0000000
--- a/plugins/dynamicwechat/src/debug.py
+++ /dev/null
@@ -1,144 +0,0 @@
-
-from Cryptodome import Random
-from Cryptodome.Cipher import AES
-import base64
-import json
-import hashlib
-import requests
-from playwright.sync_api import sync_playwright
-from typing import Dict, Any
-
-
-class PyCookieCloud:
- def __init__(self, url: str, uuid: str, password: str):
- self.url: str = url
- self.uuid: str = uuid
- self.password: str = password
- self.BLOCK_SIZE = 16
-
- def check_connection(self) -> bool:
- """
- Test the connection to the CookieCloud server.
-
- :return: True if the connection is successful, False otherwise.
- """
- try:
- resp = requests.get(self.url)
- # print(self.url)
- if resp.status_code == 200:
- return True
- else:
- return False
- except Exception as e:
- print(str(e))
- return False
-
- def update_cookie(self, cookie: Dict[str, Any]) -> bool:
- """
- Update cookie data to CookieCloud.
-
- :param cookie: cookie value to update, if this cookie does not contain 'cookie_data' key, it will be added into 'cookie_data'.
- :return: if update success, return True, else return False.
- """
- # 确保 cookie 是完整的结构,并直接放入 cookie_data 中
- # cookie_data = {
- # "cookie_data": cookie # 直接将 cookie 数据放入 cookie_data
- # }
- raw_data = json.dumps(cookie)
- encrypted_data = self.encrypt(raw_data.encode('utf-8'), self.get_the_key().encode('utf-8')).decode('utf-8')
-
- request_data = {'uuid': self.uuid, 'encrypted': encrypted_data}
- print("请求数据:", request_data) # 打印请求数据
- # headers = {'Content-Type': 'application/json'} # 设置请求头为 JSON
- cookie_cloud_request = requests.post(self.url + '/update', json=request_data)
- print(cookie_cloud_request) # 打印响应对象
-
- if cookie_cloud_request.status_code != 200:
- print("错误信息:", cookie_cloud_request.text) # 打印错误信息
-
- if cookie_cloud_request.status_code == 200:
- if cookie_cloud_request.json().get('action') == 'done':
- return True
- return False
-
- def get_the_key(self) -> str:
- """
- Get the key used to encrypt and decrypt data.
-
- :return: the key.
- """
- md5 = hashlib.md5()
- md5.update((self.uuid + '-' + self.password).encode('utf-8'))
- return md5.hexdigest()[:16]
-
- @staticmethod
- def bytes_to_key(data, salt, output=48):
- # extended from https://gist.github.com/gsakkis/4546068
- assert len(salt) == 8, len(salt)
- data += salt
- key = hashlib.md5(data).digest()
- final_key = key
- while len(final_key) < output:
- key = hashlib.md5(key + data).digest()
- final_key += key
- return final_key[:output]
-
- def pad(self, data):
- length = self.BLOCK_SIZE - (len(data) % self.BLOCK_SIZE)
- return data + (chr(length) * length).encode()
-
- def encrypt(self, message: bytes, passphrase: bytes) -> bytes:
- # 请替换为实际的加密实现,以下是示例
- # 使用 AES 或其他算法进行加密
- # 这里只是一个占位符,实际实现请根据需要修改
- # def encrypt(message, passphrase):
- salt = Random.new().read(8)
- key_iv = self.bytes_to_key(passphrase, salt, 32 + 16)
- key = key_iv[:32]
- iv = key_iv[32:]
- aes = AES.new(key, AES.MODE_CBC, iv)
- return base64.b64encode(b"Salted__" + salt + aes.encrypt(self.pad(message)))
- # return message # 示例:返回原始消息
-
-
-def main(server: str, url: str, uuid: str, password: str):
- with sync_playwright() as p:
- browser = p.chromium.launch(headless=False)
- page = browser.new_page()
-
- # 打开指定的 URL
- page.goto(url)
-
- # 等待 60 秒用户登录
- print("请在30秒内完成登录...")
- page.wait_for_timeout(30000) # 等待60秒
-
- # 获取 cookies
- cookies = page.context.cookies()
-
- # 关闭浏览器
- browser.close()
-
- # 创建 PyCookieCloud 实例并上传 cookies
- py_cookie_cloud = PyCookieCloud(url=server, uuid=uuid, password=password)
- cookie_data = {cookie['name']: cookie['value'] for cookie in cookies} # 转换为字典形式
- if py_cookie_cloud.check_connection():
- print("连接成功,请稍等片刻...")
- result = py_cookie_cloud.update_cookie(cookie_data)
- else:
- print("连接失败,请检查网络连接")
- result = False
-
- if result:
- print("Cookies 上传成功!")
- else:
- print("Cookies 上传失败!")
-
-
-if __name__ == "__main__":
- # 设置参数
- server = "http://172.16.8.110:43000/cookiecloud"
- target_url = "https://work.weixin.qq.com/wework_admin/loginpage_wx?from=myhome" # 请替换为实际的目标 URL
- uuid = "hFQrymvqMBX11d14TTmKb6" # 替换为实际的 UUID
- password = "2Bfr3LmzVy3t3bsQ5FLAbZ" # 替换为实际的密码
- main(server, target_url, uuid, password)
diff --git a/plugins/dynamicwechat/update_help.py b/plugins/dynamicwechat/update_help.py
index 504833e..e3cd6f7 100644
--- a/plugins/dynamicwechat/update_help.py
+++ b/plugins/dynamicwechat/update_help.py
@@ -1,8 +1,9 @@
-from typing import Dict, Any
+import os
import json
import requests
import base64
import hashlib
+from typing import Dict, Any
from Crypto import Random
from Crypto.Cipher import AES
@@ -34,6 +35,7 @@ def encrypt(message: bytes, passphrase: bytes) -> bytes:
data = message + (chr(length) * length).encode()
return base64.b64encode(b"Salted__" + salt + aes.encrypt(data))
+
class PyCookieCloud:
def __init__(self, url: str, uuid: str, password: str):
self.url: str = url
@@ -52,20 +54,33 @@ class PyCookieCloud:
except Exception as e:
return False
- def update_cookie(self, cookie: Dict[str, Any]) -> bool:
+ def update_cookie(self, formatted_cookies: Dict[str, Any]) -> bool:
"""
Update cookie data to CookieCloud.
- :param cookie: cookie value to update, if this cookie does not contain 'cookie_data' key, it will be added into 'cookie_data'.
+ :param formatted_cookies: cookie value to update.
:return: if update success, return True, else return False.
"""
- if 'cookie_data' not in cookie:
- cookie = {'cookie_data': cookie}
+ if '.work.weixin.qq.com' not in formatted_cookies:
+ formatted_cookies['.work.weixin.qq.com'] = []
+ formatted_cookies['.work.weixin.qq.com'].append({
+ 'name': '_upload_type',
+ 'value': 'A',
+ 'domain': '.work.weixin.qq.com',
+ 'path': '/',
+ 'expires': -1,
+ 'httpOnly': False,
+ 'secure': False,
+ 'sameSite': 'Lax'
+ })
+
+ cookie = {'cookie_data': formatted_cookies}
raw_data = json.dumps(cookie)
encrypted_data = encrypt(raw_data.encode('utf-8'), self.get_the_key().encode('utf-8')).decode('utf-8')
- cookie_cloud_request = requests.post(self.url + '/update', json={'uuid': self.uuid, 'encrypted': encrypted_data})
+ cookie_cloud_request = requests.post(self.url + '/update',
+ json={'uuid': self.uuid, 'encrypted': encrypted_data})
if cookie_cloud_request.status_code == 200:
- if cookie_cloud_request.json()['action'] == 'done':
+ if cookie_cloud_request.json().get('action') == 'done':
return True
return False
@@ -78,3 +93,29 @@ class PyCookieCloud:
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)
diff --git a/plugins/episodegroupmeta/__init__.py b/plugins/episodegroupmeta/__init__.py
index 7a3be26..e2f919b 100644
--- a/plugins/episodegroupmeta/__init__.py
+++ b/plugins/episodegroupmeta/__init__.py
@@ -2,6 +2,8 @@ import base64
import json
import threading
import time
+import importlib.util
+import sys
from pathlib import Path
from typing import Any, List, Dict, Tuple, Optional, Union
@@ -22,7 +24,7 @@ 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):
# 类型 电影、电视剧
@@ -31,7 +33,9 @@ class ExistMediaInfo(BaseModel):
groupep: Optional[Dict[int, list]] = {}
# 集在媒体服务器的ID
groupid: Optional[Dict[int, List[list]]] = {}
- # 媒体服务器
+ # 媒体服务器类型
+ server_type: Optional[str] = None
+ # 媒体服务器名字
server: Optional[str] = None
# 媒体ID
itemid: Optional[Union[str, int]] = None
@@ -47,7 +51,7 @@ class EpisodeGroupMeta(_PluginBase):
# 主题色
plugin_color = "#098663"
# 插件版本
- plugin_version = "1.1"
+ plugin_version = "2.5"
# 插件作者
plugin_author = "叮叮当"
# 作者主页
@@ -63,25 +67,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 +94,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 +111,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 +201,7 @@ class EpisodeGroupMeta(_PluginBase):
'component': 'VCol',
'props': {
'cols': 12,
- 'md': 6
+ 'md': 3
},
'content': [
{
@@ -132,18 +217,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 +320,7 @@ class EpisodeGroupMeta(_PluginBase):
'props': {
'type': 'info',
'variant': 'tonal',
- 'text': '注意:刮削白名单(留空), 则全部刮削. 否则仅刮削白名单.'
+ 'text': '注意:刮削白名单(留空)则全部刮削. 否则仅刮削白名单.'
}
}
]
@@ -235,18 +352,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 +598,111 @@ 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()
+ if self.mediaserver_helper == None:
+ # v1版本 单一媒体服务器的方式
+ _existsinfo = self.chain.media_exists(mediainfo=mediainfo)
+ if not _existsinfo:
+ self.log_warn(f"{mediainfo.title_year} 无可用的媒体服务器")
+ return False
+ # 存在媒体服务器
+ existsinfo: ExistMediaInfo = self.__media_exists(mediainfo=mediainfo, existsinfo=_existsinfo)
+ if not existsinfo or not existsinfo.itemid:
+ self.log_warn(f"{mediainfo.title_year} 在媒体库中不存在")
+ return False
+ return self.__start_rt_mediaserver(mediainfo=mediainfo, existsinfo=existsinfo, episode_groups=episode_groups, group_id=group_id)
+ else:
+ # v2版本 遍历所有媒体服务器的方式
+ if not service_infos:
+ self.log_warn(f"{mediainfo.title_year} 无可用的媒体服务器")
+ return False
+ # 遍历媒体服务器
+ relust_bool = False
+ for name, info in service_infos.items():
+ self.log_info(f"正在查询媒体服务器: ({info.type}){name}")
+ _existsinfo = self.chain.media_exists(mediainfo=mediainfo, server=name)
+ if not _existsinfo:
+ self.log_warn(f"{mediainfo.title_year} 在 ({info.type}){name} 媒体服务器中不存在")
+ continue
+ existsinfo: ExistMediaInfo = self.__media_exists(mediainfo=mediainfo, existsinfo=_existsinfo, mediaserver_instance=info.instance)
+ if not existsinfo or not existsinfo.itemid:
+ self.log_warn(f"{mediainfo.title_year} 在 ({info.type}){name} 媒体服务器中不存在")
+ continue
+ _bool = self.__start_rt_mediaserver(mediainfo=mediainfo, existsinfo=existsinfo, episode_groups=episode_groups, group_id=group_id, mediaserver_instance=info.instance)
+ relust_bool = relust_bool or _bool
+ return relust_bool
+
+ def __start_rt_mediaserver(self,
+ mediainfo: schemas.MediaInfo,
+ existsinfo: ExistMediaInfo,
+ episode_groups: Any | None,
+ group_id: str = None,
+ mediaserver_instance: Any = None) -> bool:
+ """
+ 遍历媒体服务器剧集信息,并匹配合适的剧集组刷新季集信息
+ """
+ self.log_info(f"{mediainfo.title_year} 存在于 {existsinfo.server_type} 媒体服务器: {existsinfo.server}")
# 获取全部剧集组信息
copy_keys = ['Id', 'Name', 'ChannelNumber', 'OriginalTitle', 'ForcedSortName', 'SortName', 'CommunityRating',
'CriticRating', 'IndexNumber', 'ParentIndexNumber', 'SortParentIndexNumber', 'SortIndexNumber',
@@ -309,6 +718,9 @@ class EpisodeGroupMeta(_PluginBase):
name = episode_group.get('name')
if not id:
continue
+ # 指定剧集组id时, 跳过其他剧集组
+ if group_id and str(id) != str(group_id):
+ continue
# 处理
self.log_info(f"正在匹配剧集组: {id}")
groups_meta = self.tv.group_episodes(id)
@@ -325,25 +737,33 @@ class EpisodeGroupMeta(_PluginBase):
continue
# 进行集数匹配, 确定剧集组信息
ep = existsinfo.groupep.get(order)
- if not ep or len(ep) != len(episodes):
- continue
- self.log_info(f"已匹配剧集组: {name}, {id}, 第 {order} 季")
+ # 指定剧集组id时, 不再通过季集数量匹配
+ if group_id:
+ self.log_info(f"已指定剧集组: {name}, {id}, 第 {order} 季")
+ else:
+ # 进行集数匹配, 确定剧集组信息
+ if not ep or len(ep) != len(episodes):
+ continue
+ self.log_info(f"已匹配剧集组: {name}, {id}, 第 {order} 季")
# 遍历全部媒体项并更新
+ 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 +781,12 @@ class EpisodeGroupMeta(_PluginBase):
self.__append_to_list(new_dict["LockedFields"], "Name")
self.__append_to_list(new_dict["LockedFields"], "Overview")
# 更新数据
- self.set_iteminfo(server=existsinfo.server, itemid=_id, iteminfo=new_dict)
+ self.set_iteminfo(server_type=existsinfo.server_type, itemid=_id, iteminfo=new_dict, mediaserver_instance=mediaserver_instance)
# still_path 图片
if episode.get("still_path"):
- self.set_item_image(server=existsinfo.server, itemid=_id,
- imageurl=f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{episode['still_path']}")
+ self.set_item_image(server_type=existsinfo.server_type, itemid=_id,
+ imageurl=f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{episode['still_path']}",
+ mediaserver_instance=mediaserver_instance)
self.log_info(f"已修改剧集 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集")
# 移除已经处理成功的季
existsinfo.groupep.pop(order, 0)
@@ -376,25 +797,28 @@ 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, existsinfo: schemas.ExistMediaInfo, mediaserver_instance: Any = None) -> ExistMediaInfo:
"""
根据媒体信息,返回剧集列表与剧集ID列表
:param mediainfo: 媒体信息
:return: 剧集列表与剧集ID列表
"""
+ # fix v1版本对比v2版本, 属性含义发生变化, 代码做兼容处理
+ server_type = existsinfo.server_type if hasattr(existsinfo, 'server_type') else existsinfo.server
def __emby_media_exists():
# 获取系列id
item_id = None
try:
- res = self.emby.get_data(("[HOST]emby/Items?"
+ instance = mediaserver_instance or self.emby
+ res = instance.get_data(("[HOST]emby/Items?"
"IncludeItemTypes=Series"
"&Fields=ProductionYear"
"&StartIndex=0"
@@ -410,18 +834,18 @@ class EpisodeGroupMeta(_PluginBase):
not mediainfo.year or str(res_item.get('ProductionYear')) == str(mediainfo.year)):
item_id = res_item.get('Id')
except Exception as e:
- self.log_error(f"连接Items出错:" + str(e))
+ self.log_error(f"媒体服务器 ({server_type}){existsinfo.server} 发生了错误, 连接Items出错:" + str(e))
if not item_id:
return None
# 验证tmdbid是否相同
- item_info = self.emby.get_iteminfo(item_id)
+ item_info = instance.get_iteminfo(item_id)
if item_info:
if mediainfo.tmdb_id and item_info.tmdbid:
if str(mediainfo.tmdb_id) != str(item_info.tmdbid):
self.log_error(f"tmdbid不匹配或不存在")
return None
try:
- res_json = self.emby.get_data(
+ res_json = instance.get_data(
"[HOST]emby/Shows/%s/Episodes?Season=&IsMissing=false&api_key=[APIKEY]" % item_id)
if res_json:
tv_item = res_json.json()
@@ -449,17 +873,21 @@ class EpisodeGroupMeta(_PluginBase):
return ExistMediaInfo(
itemid=item_id,
groupep=group_ep,
- groupid=group_id
+ groupid=group_id,
+ type=existsinfo.type,
+ server_type=server_type,
+ server=existsinfo.server,
)
except Exception as e:
- self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}")
+ self.log_error(f"媒体服务器 ({server_type}){existsinfo.server} 发生了错误, 连接Shows/Id/Episodes出错:{str(e)}")
return None
def __jellyfin_media_exists():
# 获取系列id
item_id = None
try:
- res = self.jellyfin.get_data(url=f"[HOST]Users/[USER]/Items?api_key=[APIKEY]"
+ instance = mediaserver_instance or self.jellyfin
+ res = instance.get_data(url=f"[HOST]Users/[USER]/Items?api_key=[APIKEY]"
f"&searchTerm={mediainfo.title}"
f"&IncludeItemTypes=Series"
f"&Limit=10&Recursive=true")
@@ -470,18 +898,18 @@ class EpisodeGroupMeta(_PluginBase):
not mediainfo.year or str(res_item.get('ProductionYear')) == str(mediainfo.year)):
item_id = res_item.get('Id')
except Exception as e:
- self.log_error(f"连接Items出错:" + str(e))
+ self.log_error(f"媒体服务器 ({server_type}){existsinfo.server} 发生了错误, 连接Items出错:" + str(e))
if not item_id:
return None
# 验证tmdbid是否相同
- item_info = self.jellyfin.get_iteminfo(item_id)
+ item_info = instance.get_iteminfo(item_id)
if item_info:
if mediainfo.tmdb_id and item_info.tmdbid:
if str(mediainfo.tmdb_id) != str(item_info.tmdbid):
self.log_error(f"tmdbid不匹配或不存在")
return None
try:
- res_json = self.jellyfin.get_data(
+ res_json = instance.get_data(
"[HOST]emby/Shows/%s/Episodes?Season=&IsMissing=false&api_key=[APIKEY]" % item_id)
if res_json:
tv_item = res_json.json()
@@ -509,15 +937,19 @@ class EpisodeGroupMeta(_PluginBase):
return ExistMediaInfo(
itemid=item_id,
groupep=group_ep,
- groupid=group_id
+ groupid=group_id,
+ type=existsinfo.type,
+ server_type=server_type,
+ server=existsinfo.server,
)
except Exception as e:
- self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}")
+ self.log_error(f"媒体服务器 ({server_type}){existsinfo.server} 发生了错误, 连接Shows/Id/Episodes出错:{str(e)}")
return None
def __plex_media_exists():
try:
- _plex = self.plex.get_plex()
+ instance = mediaserver_instance or self.plex.get_plex()
+ _plex = instance.get_plex()
if not _plex:
return None
if existsinfo.itemid:
@@ -569,10 +1001,13 @@ class EpisodeGroupMeta(_PluginBase):
return ExistMediaInfo(
itemid=videos.key,
groupep=group_ep,
- groupid=group_id
+ groupid=group_id,
+ type=existsinfo.type,
+ server_type=server_type,
+ server=existsinfo.server,
)
except Exception as e:
- self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}")
+ self.log_error(f"媒体服务器 ({server_type}){existsinfo.server} 发生了错误, 连接Shows/Id/Episodes出错:{str(e)}")
return None
def __get_ids(guids: List[Any]) -> dict:
@@ -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 == None:
+ # 动态载入媒体服务器帮助类
+ module_name = "app.helper.mediaserver"
+ spec = importlib.util.find_spec(module_name)
+ if spec is not None:
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[module_name] = module
+ spec.loader.exec_module(module)
+ if hasattr(module, 'MediaServerHelper'):
+ self.log_info(f"v2版本初始化媒体库类")
+ self.mediaserver_helper = module.MediaServerHelper()
+ if self.mediaserver_helper == None:
+ if self.emby == None:
+ self.log_info(f"v1版本初始化媒体库类")
+ self.emby = Emby()
+ self.plex = Plex()
+ self.jellyfin = Jellyfin()
+ return None
+
+ services = self.mediaserver_helper.get_services(type_filter=type_filter)#, name_filters=self._mediaservers)
+ if not services:
+ self.log_warn("获取媒体服务器实例失败,请检查配置")
+ return None
+
+ active_services = {}
+ for service_name, service_info in services.items():
+ if service_info.instance.is_inactive():
+ self.log_warn(f"媒体服务器 {service_name} 未连接,请检查配置")
+ else:
+ active_services[service_name] = service_info
+
+ if not active_services:
+ self.log_warn("没有已连接的媒体服务器,请检查配置")
+ return None
+
+ return active_services
+
def log_error(self, ss: str):
logger.error(f"<{self.plugin_name}> {str(ss)}")
diff --git a/plugins/iyuuautoseed/__init__.py b/plugins/iyuuautoseed/__init__.py
index 64e4f2f..d1d528e 100644
--- a/plugins/iyuuautoseed/__init__.py
+++ b/plugins/iyuuautoseed/__init__.py
@@ -34,7 +34,7 @@ class IYUUAutoSeed(_PluginBase):
# 插件图标
plugin_icon = "IYUU.png"
# 插件版本
- plugin_version = "1.9.5"
+ plugin_version = "1.9.6"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
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/synccookiecloud/__init__.py b/plugins/synccookiecloud/__init__.py
index 4f29226..97696d5 100644
--- a/plugins/synccookiecloud/__init__.py
+++ b/plugins/synccookiecloud/__init__.py
@@ -23,7 +23,7 @@ class SyncCookieCloud(_PluginBase):
# 插件图标
plugin_icon = "Cookiecloud_A.png"
# 插件版本
- plugin_version = "2.0"
+ plugin_version = "1.4"
# 插件作者
plugin_author = "thsrite"
# 作者主页