Merge branch 'main' into qbcommand

This commit is contained in:
DzAvril
2024-11-16 21:01:04 +08:00
committed by GitHub
19 changed files with 1822 additions and 1556 deletions

View File

@@ -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服务器压力"
}
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -33,7 +33,7 @@ class IYUUAutoSeed(_PluginBase):
# 插件图标
plugin_icon = "IYUU.png"
# 插件版本
plugin_version = "2.0.1"
plugin_version = "2.1"
# 插件作者
plugin_author = "jxxghp"
# 作者主页

View File

@@ -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

View File

@@ -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))

View File

@@ -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

View File

@@ -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())))

View File

@@ -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

View File

@@ -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"企业微信登录二维码<br/><img src='{img_src}' />")
# 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"企业微信登录二维码<br/><img src='{img_src}' />")
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))

View File

@@ -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'![img]({image})' if image else content}
headers = {'Content-Type': 'application/json;charset=utf-8'}
response = requests.post(url, json=params, headers=headers)
result = response.json()
if result.get('code') != 0:
return f"Server酱通知错误: {result.get('message')}"
return None
def send_anpush(self, title, content, image):
if ',' in self.token:
channel, token = self.token.split(',', 1)
else:
return
url = f"https://api.anpush.com/push/{token}"
payload = {
"title": title,
"content": f"<img src=\"{image}\" width=\"100%\">" 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"企业微信登录二维码<br/><img src='{image}' />" 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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)}")

View File

@@ -34,7 +34,7 @@ class IYUUAutoSeed(_PluginBase):
# 插件图标
plugin_icon = "IYUU.png"
# 插件版本
plugin_version = "1.9.5"
plugin_version = "1.9.6"
# 插件作者
plugin_author = "jxxghp"
# 作者主页

View File

@@ -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

View File

@@ -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()

View File

@@ -23,7 +23,7 @@ class SyncCookieCloud(_PluginBase):
# 插件图标
plugin_icon = "Cookiecloud_A.png"
# 插件版本
plugin_version = "2.0"
plugin_version = "1.4"
# 插件作者
plugin_author = "thsrite"
# 作者主页