Merge remote-tracking branch 'origin/main'

# Conflicts:
#	package.json
#	plugins/brushflow/__init__.py
This commit is contained in:
jxxghp
2024-04-17 21:15:36 +08:00
7 changed files with 675 additions and 162 deletions

View File

@@ -17,7 +17,7 @@ MoviePilot官方插件市场https://github.com/jxxghp/MoviePilot-Plugins
- 插件命名请勿与官方库插件中的插件冲突否则会在MoviePilot版本升级时被官方插件覆盖。
### 4. 依赖
- 可在插件目录中放置`requirement.txt`文件用于指定插件依赖的第三方库MoviePilot会在插件安装时自动安装依赖库。
- 可在插件目录中放置`requirements.txt`文件用于指定插件依赖的第三方库MoviePilot会在插件安装时自动安装依赖库。
### 5. 界面开发
- 插件支持`插件配置``详情展示`两个展示页面,通过配置化的方式组装,使用 [Vuetify](https://vuetifyjs.com/) 组件库所有该组件库有的组件都可以通过Json配置使用。

BIN
icons/PluginAutoUpgrade.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -198,10 +198,13 @@
"CrossSeed": {
"name": "青蛙辅种助手",
"description": "参考ReseedPuppy和IYUU辅种插件实现自动辅种支持站点青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。",
"version": "2.1",
"version": "2.2",
"icon": "qingwa.png",
"author": "233@qingwa",
"level": 2
"level": 2,
"history": {
"v2.2": "站点停用后会同步暂停对该站点的辅种"
}
},
"VCBAnimeMonitor": {
"name": "整理VCB动漫压制组作品",
@@ -238,12 +241,13 @@
"BrushFlow": {
"name": "站点刷流",
"description": "自动托管刷流,将会提高对应站点的访问频率。",
"version": "2.7",
"version": "2.8",
"icon": "brush.jpg",
"author": "jxxghp,InfinityPacer",
"level": 2,
"history": {
"v2.7": "优化UI显示以及提升性能",
"v2.8": "优化UI显示以及提升性能",
"v2.7": "动态删除种子规则调整(请注意查阅插件文档),站点独立配置样式优化、日志优化,修复部分配置项无法配置小数的问题,修复部分场景可能导致重复下载的问题",
"v2.6": "修复排除订阅功能",
"v2.5": "增加H&R做种时间、下载器监控配置项刷流前置条件逻辑调整代理下载种子默认为关闭"
}
@@ -382,13 +386,12 @@
"RemoveLink": {
"name": "清理硬链接",
"description": "监控目录内文件被删除时,同步删除监控目录内所有和它硬链接的文件",
"version": "1.8",
"version": "1.6",
"icon": "Ombi_A.png",
"author": "DzAvril",
"level": 1,
"history": {
"v1.8": "修复空目录删除逻辑",
"v1.7": "增加定时清理空目录功能"
"v1.6": "提升插件性能"
}
},
"LinkMonitor": {
@@ -513,5 +516,13 @@
"icon": "Ntfy_A.png",
"author": "lethargicScribe",
"level": 1
},
"PluginAutoUpgrade": {
"name": "插件自动升级",
"description": "定时检测、升级插件。",
"version": "1.0",
"icon": "PluginAutoUpgrade.png",
"author": "hotlcc",
"level": 1
}
}

View File

@@ -85,7 +85,11 @@ class BrushConfig:
def __initialize_site_config(self):
if not self.site_config:
logger.error(f"没有设置站点配置,已关闭站点独立配置并恢复默认配置示例,请检查配置项")
self.site_config = self.__get_demo_site_config()
self.group_site_configs = {}
self.enable_site_config = False
return
# 定义允许覆盖的字段列表
allowed_fields = {
@@ -132,6 +136,47 @@ class BrushConfig:
self.enable_site_config = False
self.enabled = False
@staticmethod
def __get_demo_site_config() -> str:
desc = ("//以下为配置示例,请参考 "
"https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/README.md "
"进行配置,请注意,只需要保留实际配置内容(删除这段)\n")
config = """[{
"sitename": "站点1",
"seed_time": 96,
"hr_seed_time": 144
}, {
"sitename": "站点2",
"hr": "yes",
"size": "10-500",
"seeder": "5-10",
"pubtime": "5-120",
"seed_time": 96,
"save_path": "/downloads/site2",
"proxy_download": true,
"hr_seed_time": 144
}, {
"sitename": "站点3",
"freeleech": "free",
"hr": "yes",
"include": "",
"exclude": "",
"size": "10-500",
"seeder": "1",
"pubtime": "5-120",
"seed_time": 120,
"hr_seed_time": 144,
"seed_ratio": "",
"seed_size": "",
"download_time": "",
"seed_avgspeed": "",
"seed_inactivetime": "",
"save_path": "/downloads/site1",
"proxy_download": false,
"proxy_delete": false
}]"""
return desc + config
def get_site_config(self, sitename):
"""
根据站点名称获取特定的BrushConfig实例。如果没有找到站点特定的配置则返回全局的BrushConfig实例。
@@ -194,7 +239,7 @@ class BrushFlow(_PluginBase):
# 插件图标
plugin_icon = "brush.jpg"
# 插件版本
plugin_version = "2.7"
plugin_version = "2.8"
# 插件作者
plugin_author = "jxxghp,InfinityPacer"
# 作者主页
@@ -241,10 +286,6 @@ class BrushFlow(_PluginBase):
logger.info("站点刷流任务出错,无法获取插件配置")
return False
# 如果没有站点配置时,增加默认的配置项
if not config.get("site_config"):
config["site_config"] = self.__get_demo_site_config()
# 如果配置校验没有通过,那么这里修改配置文件后退出
if not self.__validate_and_fix_config(config=config):
self._brush_config = BrushConfig(config=config)
@@ -956,12 +997,6 @@ class BrushFlow(_PluginBase):
}
]
},
{
'component': 'VRow',
'content': [
]
},
{
'component': 'VRow',
'content': [
@@ -1163,7 +1198,7 @@ class BrushFlow(_PluginBase):
"component": "VDialog",
"props": {
"model": "dialog_closed",
"max-width": "50rem",
"max-width": "65rem",
"overlay-class": "v-dialog--scrollable v-overlay--scroll-blocked",
"content-class": "v-card v-card--density-default v-card--variant-elevated rounded-t"
},
@@ -1684,12 +1719,14 @@ class BrushFlow(_PluginBase):
size_condition_passed, reason = self.__evaluate_size_condition_for_brush(torrents_size=torrents_size)
self.__log_brush_conditions(passed=size_condition_passed, reason=reason)
if not size_condition_passed:
logger.info(f"刷流任务执行完成")
return
# 判断能否通过刷流前置条件
pre_condition_passed, reason = self.__evaluate_pre_conditions_for_brush()
self.__log_brush_conditions(passed=pre_condition_passed, reason=reason)
if not pre_condition_passed:
logger.info(f"刷流任务执行完成")
return
statistic_info = self.__get_statistic_info()
@@ -1760,7 +1797,7 @@ class BrushFlow(_PluginBase):
for torrent in torrents:
# 判断能否通过刷流前置条件
pre_condition_passed, reason = self.__evaluate_pre_conditions_for_brush(include_network_conditions=False)
self.__log_brush_conditions(passed=pre_condition_passed, reason=reason, torrent=torrent)
self.__log_brush_conditions(passed=pre_condition_passed, reason=reason)
if not pre_condition_passed:
return False
@@ -1912,10 +1949,19 @@ class BrushFlow(_PluginBase):
"""
brush_config = self.__get_brush_config(torrent.site_name)
# 排除重复种子
# 默认根据标题和站点名称进行排除
task_key = f"{torrent.site_name}{torrent.title}"
if any(task_key == f"{task.get('site_name')}{task.get('title')}" for task in torrent_tasks.values()):
return False, "重复种子"
# 部分站点标题会上新时携带后缀,这里进一步根据种子详情地址进行排除
if torrent.page_url:
task_page_url = f"{torrent.site_name}{torrent.page_url}"
if any(task_page_url == f"{task.get('site_name')}{task.get('page_url')}" for task in
torrent_tasks.values()):
return False, "重复种子"
# 促销条件
if brush_config.freeleech and torrent.downloadvolumefactor != 0:
return False, "非免费种子"
@@ -1948,7 +1994,7 @@ class BrushFlow(_PluginBase):
# 做种人数
if brush_config.seeder:
seeders_range = [int(n) for n in brush_config.seeder.split("-")]
seeders_range = [float(n) for n in brush_config.seeder.split("-")]
# 检查是否仅指定了一个数字,即做种人数需要小于等于该数字
if len(seeders_range) == 1:
# 当做种人数大于该数字时,不符合条件
@@ -1964,7 +2010,7 @@ class BrushFlow(_PluginBase):
pubdate_minutes = self.__get_pubminutes(torrent.pubdate)
pubdate_minutes = self.__adjust_site_pubminutes(pubdate_minutes, torrent)
if brush_config.pubtime:
pubtimes = [int(n) for n in brush_config.pubtime.split("-")]
pubtimes = [float(n) for n in brush_config.pubtime.split("-")]
if len(pubtimes) == 1:
# 单个值:选择发布时间小于等于该值的种子
if pubdate_minutes > pubtimes[0]:
@@ -1982,7 +2028,7 @@ class BrushFlow(_PluginBase):
"""
if not passed:
if not torrent:
logger.warn(f"种子没有通过前置刷流条件校验,原因:{reason}")
logger.warn(f"没有通过前置刷流条件校验,原因:{reason}")
else:
brush_config = self.__get_brush_config()
if brush_config.log_more:
@@ -2049,7 +2095,7 @@ class BrushFlow(_PluginBase):
need_delete_hashes = []
# 如果配置了删种阈值,则根据动态删种进行分组处理
# 如果配置了动态删除以及删种阈值,则根据动态删种进行分组处理
if brush_config.proxy_delete and brush_config.delete_size_range:
logger.info("已开启动态删种,按系统默认动态删种条件开始检查任务")
proxy_delete_hashs = self.__delete_torrent_for_proxy(torrents=check_torrents,
@@ -2237,6 +2283,22 @@ class BrushFlow(_PluginBase):
return True, reason if not hit_and_run else "H&R种子未设置H&R条件" + reason
def __evaluate_proxy_pre_conditions_for_delete(self, site_name: str, torrent_info: dict) -> Tuple[bool, str]:
"""
评估动态删除前置条件并返回是否应删除种子及其原因
"""
brush_config = self.__get_brush_config(sitename=site_name)
reason = "未能满足动态删除设置的前置删除条件"
if brush_config.download_time and torrent_info.get("downloaded") < torrent_info.get(
"total_size") and torrent_info.get("dltime") >= float(brush_config.download_time) * 3600:
reason = f"下载耗时 {torrent_info.get('dltime') / 3600:.1f} 小时,大于 {brush_config.download_time} 小时"
else:
return False, reason
return True, reason
def __delete_torrent_for_evaluate_conditions(self, torrents: List[Any], torrent_tasks: Dict[str, dict],
proxy_delete: bool = False) -> List:
"""
@@ -2273,11 +2335,54 @@ class BrushFlow(_PluginBase):
return delete_hashs
def __delete_torrent_for_evaluate_proxy_pre_conditions(self, torrents: List[Any],
torrent_tasks: Dict[str, dict]) -> List:
"""
根据动态删除前置条件排除H&R种子后删除种子并获取已删除列表
"""
brush_config = self.__get_brush_config()
delete_hashs = []
for torrent in torrents:
torrent_hash = self.__get_hash(torrent)
torrent_task = torrent_tasks.get(torrent_hash, None)
# 如果找不到种子任务,说明不在管理的种子范围内,直接跳过
if not torrent_task:
continue
# 如果是H&R种子前置条件中不进行处理
if torrent_task.get('hit_and_run', False):
continue
site_name = torrent_task.get("site_name", "")
torrent_title = torrent_task.get("title", "")
torrent_desc = torrent_task.get("description", "")
torrent_info = self.__get_torrent_info(torrent)
# 删除种子的具体实现可能会根据实际情况略有不同
should_delete, reason = self.__evaluate_proxy_pre_conditions_for_delete(site_name=site_name,
torrent_info=torrent_info)
if should_delete:
delete_hashs.append(torrent_hash)
self.__send_delete_message(site_name=site_name, torrent_title=torrent_title, torrent_desc=torrent_desc,
reason=reason)
logger.info(f"站点:{site_name}{reason},删除种子:{torrent_title}|{torrent_desc}")
else:
if brush_config.log_more:
logger.info(f"站点:{site_name}{reason},不删除种子:{torrent_title}|{torrent_desc}")
return delete_hashs
def __delete_torrent_for_proxy(self, torrents: List[Any], torrent_tasks: Dict[str, dict]) -> List:
"""
支持动态删除种子,当设置了动态删种全局和删除阈值时当做种体积达到删除阈值时优先按设置规则进行删除若还没有达到阈值则排除HR种子后按加入时间倒序进行删除
删除阈值100当做种体积 > 100G 时,则开始删除种子,直至降低至 100G
删除阈值50-100当做种体积 > 100G 时,则开始删除种子,直至降至为 50G
动态删除种子,删除规则如下;
- 不管做种体积是否超过设定的动态删除阈值默认优先执行排除H&R种子后满足「下载超时时间」的种子
- 上述规则执行完成后,当做种体积依旧超过设定的动态删除阈值时,继续执行下述种子删除规则
- 优先删除满足用户设置删除规则的全部种子,即便在删除过程中已经低于了阈值下限,也会继续删除
- 若删除后还没有达到阈值则在已完成种子中排除H&R种子后按做种时间倒序进行删除
- 动态删除阈值100当做种体积 > 100G 时,则开始删除种子,直至降低至 100G
- 动态删除阈值50-100当做种体积 > 100G 时,则开始删除种子,直至降至为 50G
"""
brush_config = self.__get_brush_config()
@@ -2285,29 +2390,49 @@ class BrushFlow(_PluginBase):
if not (brush_config.proxy_delete and brush_config.delete_size_range):
return []
# 获取种子信息Map
torrent_info_map = {self.__get_hash(torrent): self.__get_torrent_info(torrent=torrent) for torrent in torrents}
# 计算当前总做种体积
total_torrent_size = self.__calculate_seeding_torrents_size(torrent_tasks=torrent_tasks)
logger.info(
f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB正在准备计算满足动态前置删除条件的种子")
# 执行排除H&R种子后满足前置删除条件的种子
pre_delete_hashes = self.__delete_torrent_for_evaluate_proxy_pre_conditions(torrents=torrents,
torrent_tasks=torrent_tasks) or []
# 如果存在前置删除种子,这里进行额外判断,总做种体积排除前置删除种子的体积
if pre_delete_hashes:
pre_delete_total_size = sum(torrent_info_map[self.__get_hash(torrent)].get("total_size", 0)
for torrent in torrents if self.__get_hash(torrent) in pre_delete_hashes)
total_torrent_size = total_torrent_size - pre_delete_total_size
torrents = [torrent for torrent in torrents if self.__get_hash(torrent) not in pre_delete_hashes]
logger.info(
f"满足动态删除前置条件的种子共 {len(pre_delete_hashes)} 个,体积 {self.__bytes_to_gb(pre_delete_total_size):.1f} GB"
f"删除种子后,当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB")
else:
logger.info(f"没有找到任何满足动态删除前置条件的种子")
# 解析删除阈值范围
sizes = [float(size) * 1024 ** 3 for size in brush_config.delete_size_range.split("-")]
min_size = sizes[0] # 至少需要达到的做种体积
max_size = sizes[1] if len(sizes) > 1 else sizes[0] # 触发删除操作的做种体积上限
torrent_info_map = {self.__get_hash(torrent): self.__get_torrent_info(torrent=torrent) for torrent in torrents}
# 计算当前总做种体积
# total_torrent_size = sum(info.get("total_size", 0) for info in torrent_info_map.values())
total_torrent_size = self.__calculate_seeding_torrents_size(torrent_tasks=torrent_tasks)
# 当总体积未超过最大阈值时,不需要执行删除操作
if total_torrent_size < max_size:
logger.info(
f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB上限 {self.__bytes_to_gb(max_size):.1f} GB"
f"下限 {self.__bytes_to_gb(min_size):.1f} GB未触发动态删除")
return []
f"下限 {self.__bytes_to_gb(min_size):.1f} GB进一步触发动态删除")
return pre_delete_hashes or []
else:
logger.info(
f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB上限 {self.__bytes_to_gb(max_size):.1f} GB"
f"下限 {self.__bytes_to_gb(min_size):.1f} GB触发动态删除")
f"下限 {self.__bytes_to_gb(min_size):.1f} GB进一步触发动态删除")
need_delete_hashes = []
need_delete_hashes.extend(pre_delete_hashes)
# 即使开了动态删除,但是也有可能部分站点单独设置了关闭,这里根据种子托管进行分组,先处理不需要托管的种子,按设置的规则进行删除
proxy_delete_torrents, not_proxy_delete_torrents = self.__group_torrents_by_proxy_delete(torrents=torrents,
@@ -2332,7 +2457,6 @@ class BrushFlow(_PluginBase):
self.__get_hash(torrent) in proxy_delete_hashes)
# 在完成初始删除步骤后如果总体积仍然超过最小阈值则进一步找到已完成种子并排除HR种子后按做种时间正序进行删除
sites_names = set()
if total_torrent_size > min_size:
# 重新计算当前的种子列表,排除已删除的种子
remaining_hashes = list(
@@ -2365,14 +2489,15 @@ class BrushFlow(_PluginBase):
torrent_desc = torrent_task.get("description", "")
seeding_time = torrent_task.get("seeding_time", 0)
if seeding_time:
sites_names.add(site_name)
reason = f"触发动态删除,做种时间 {seeding_time / 3600:.1f} 小时,系统自动删除"
self.__send_delete_message(site_name=site_name, torrent_title=torrent_title,
torrent_desc=torrent_desc,
reason=reason)
logger.info(f"站点:{site_name}{reason},删除种子:{torrent_title}|{torrent_desc}")
msg = (f"站点:{''.join(sites_names)}\n内容:已完成 {len(need_delete_hashes)} 个种子删除,"
delete_sites = {torrent_tasks[hash_key].get('site_name', '') for hash_key in need_delete_hashes if
hash_key in torrent_tasks}
msg = (f"站点:{''.join(delete_sites)}\n内容:已完成 {len(need_delete_hashes)} 个种子删除,"
f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB\n原因:触发动态删除")
logger.info(msg)
self.__send_message(title="【刷流任务状态更新】", text=msg)
@@ -2754,7 +2879,7 @@ class BrushFlow(_PluginBase):
if response and response.ok:
torrent_content = response.content
else:
logger.error('代理下载种子失败,继续尝试传递种子地址到下载器进行下载')
logger.error('尝试通过MP下载种子失败,继续尝试传递种子地址到下载器进行下载')
if torrent_content:
state = self.qb.add_torrent(content=torrent_content,
download_dir=download_dir,
@@ -2769,7 +2894,7 @@ class BrushFlow(_PluginBase):
torrent_hash = self.qb.get_torrent_id_by_tag(tags=tag)
if not torrent_hash:
logger.error(f"{brush_config.downloader} 获取种子Hash失败"
f"{',请尝试启代理下载种子' if not brush_config.proxy_download else ''}")
f"{',请尝试启用「代理下载种子」配置项' if not brush_config.proxy_download else ''}")
return None
return torrent_hash
return None
@@ -2785,7 +2910,7 @@ class BrushFlow(_PluginBase):
if response and response.ok:
torrent_content = response.content
else:
logger.error('代理下载种子失败,继续尝试传递种子地址到下载器进行下载')
logger.error('尝试通过MP下载种子失败,继续尝试传递种子地址到下载器进行下载')
if torrent_content:
torrent = self.tr.add_torrent(content=torrent_content,
download_dir=download_dir,
@@ -3301,9 +3426,9 @@ class BrushFlow(_PluginBase):
@staticmethod
def __is_number_or_range(value):
"""
检查字符串是否表示单个数字或数字范围(如'5''5-10'
检查字符串是否表示单个数字或数字范围(如'5', '5.5', '5-10''5.5-10.2'
"""
return bool(re.match(r"^\d+(-\d+)?$", value))
return bool(re.match(r"^\d+(\.\d+)?(-\d+(\.\d+)?)?$", value))
@staticmethod
def __is_number(value):
@@ -3379,46 +3504,6 @@ class BrushFlow(_PluginBase):
}
return statistic_info
@staticmethod
def __get_demo_site_config() -> str:
desc = ("// 以下为配置示例请参考https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/README.md 进行配置\n"
"// 无关内容请使用 // 进行注释\n")
config = """[{
"sitename": "站点1",
"seed_time": 96,
"hr_seed_time": 144
}, {
"sitename": "站点2",
"hr": "yes",
"size": "10-500",
"seeder": "5-10",
"pubtime": "5-120",
"seed_time": 96,
"save_path": "/downloads/site2",
"proxy_download": true,
"hr_seed_time": 144
}, {
"sitename": "站点3",
"freeleech": "free",
"hr": "yes",
"include": "",
"exclude": "",
"size": "10-500",
"seeder": "1",
"pubtime": "5-120",
"seed_time": 120,
"hr_seed_time": 144,
"seed_ratio": "",
"seed_size": "",
"download_time": "",
"seed_avgspeed": "",
"seed_inactivetime": "",
"save_path": "/downloads/site1",
"proxy_download": false,
"proxy_delete": false,
}]"""
return desc + config
@staticmethod
def __is_valid_time_range(time_range: str) -> bool:
"""检查时间范围字符串是否有效:格式为"HH:MM-HH:MM",且时间有效"""

View File

@@ -177,11 +177,11 @@ class CrossSeed(_PluginBase):
# 插件图标
plugin_icon = "qingwa.png"
# 插件版本
plugin_version = "2.1"
plugin_version = "2.2"
# 插件作者
plugin_author = "233@qingwa"
# 作者主页
author_url = "https://new.qingwa.pro/"
author_url = "https://qingwapt.com/"
# 插件配置项ID前缀
plugin_config_prefix = "cross_seed_"
# 加载顺序
@@ -955,6 +955,11 @@ class CrossSeed(_PluginBase):
# 逐个站点查询可辅种数据
chunk_size = 100
for site_config in self._site_cs_infos:
# 检查站点是否已经停用
db_site = self.siteoper.get(site_config.id)
if db_site and not db_site.is_active:
logger.info(f"站点{site_config.name}已停用,跳过辅种")
continue
remote_tors: List[TorInfo] = []
total_size = len(pieces_hashes)
for i in range(0, len(pieces_hashes), chunk_size):

View File

@@ -0,0 +1,469 @@
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from datetime import datetime, timedelta
from threading import Event as ThreadEvent, RLock
from typing import Any, List, Dict, Tuple, Optional
import pytz
from app import schemas
from app.api.endpoints.plugin import install
from app.core.config import settings
from app.core.plugin import PluginManager
from app.log import logger
from app.plugins import _PluginBase
class PluginAutoUpgrade(_PluginBase):
# 插件名称
plugin_name = "插件自动升级"
# 插件描述
plugin_desc = "定时检测、升级插件。"
# 插件图标
plugin_icon = "PluginAutoUpgrade.png"
# 插件版本
plugin_version = "1.0"
# 插件作者
plugin_author = "hotlcc"
# 作者主页
author_url = "https://github.com/hotlcc"
# 插件配置项ID前缀
plugin_config_prefix = "com.hotlcc.pluginautoupgrade."
# 加载顺序
plugin_order = 66
# 可使用的用户级别
auth_level = 1
# 私有属性
# 调度器
__scheduler: Optional[BackgroundScheduler] = None
# 退出事件
__exit_event: ThreadEvent = ThreadEvent()
# 任务锁
__task_lock: RLock = RLock()
# 依赖组件
# 插件管理器
__plugin_manager: PluginManager = PluginManager()
# 配置相关
# 插件缺省配置
__config_default: Dict[str, Any] = {
'cron': '* 0/4 * * *'
}
# 插件用户配置
__config: Dict[str, Any] = {}
def init_plugin(self, config: dict = None):
"""
初始化插件
"""
# 加载插件配置
self.__config = config
# 停止现有服务
self.stop_service()
# 如果需要立即运行一次
if self.__get_config_item(config_key='run_once'):
if (self.__start_scheduler()):
self.__scheduler.add_job(func=self.__try_run,
trigger='date',
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
name=f'{self.plugin_name}-立即运行一次')
logger.info(f"立即运行一次成功")
# 关闭一次性开关
self.__config['run_once'] = False
self.update_config(self.__config)
def get_state(self) -> bool:
"""
获取插件状态
"""
state = True if self.__get_config_item(config_key='enable') \
and self.__get_config_item(config_key='cron') \
else False
return state
@staticmethod
def get_command() -> List[Dict[str, Any]]:
"""
定义远程控制命令
:return: 命令关键字、事件、描述、附带数据
"""
pass
def get_api(self) -> List[Dict[str, Any]]:
"""
获取插件API
"""
pass
def get_service(self) -> List[Dict[str, Any]]:
"""
注册插件公共服务
"""
try:
if self.get_state():
cron = self.__get_config_item(config_key='cron')
return [{
"id": "PluginAutoUpgradeTimerService",
"name": f"{self.plugin_name}定时服务",
"trigger": CronTrigger.from_crontab(cron),
"func": self.__try_run,
"kwargs": {}
}]
else:
return []
except Exception as e:
logger.error(f"注册插件公共服务异常: {str(e)}", exc_info=True)
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
# 建议的配置
config_suggest = {}
# 合并默认配置
config_suggest.update(self.__config_default)
# 定时周期
cron = self.__config_default.get('cron')
# 已安装的在线插件下拉框数据
installed_online_plugin_options = self.__get_installed_online_plugin_options()
form = [{
'component': 'VForm',
'content': [{ # 业务无关总控
'component': 'VRow',
'content': [{
'component': 'VCol',
'props': {
'cols': 12,
'xxl': 4, 'xl': 4, 'lg': 4, 'md': 4, 'sm': 6, 'xs': 12
},
'content': [{
'component': 'VSwitch',
'props': {
'model': 'enable',
'label': '启用插件',
'hint': '插件总开关'
}
}]
}, {
'component': 'VCol',
'props': {
'cols': 12,
'xxl': 4, 'xl': 4, 'lg': 4, 'md': 4, 'sm': 6, 'xs': 12
},
'content': [{
'component': 'VSwitch',
'props': {
'model': 'enable_notify',
'label': '发送通知',
'hint': '执行插件任务后是否发送通知'
}
}]
}, {
'component': 'VCol',
'props': {
'cols': 12,
'xxl': 4, 'xl': 4, 'lg': 4, 'md': 4, 'sm': 6, 'xs': 12
},
'content': [{
'component': 'VSwitch',
'props': {
'model': 'run_once',
'label': '立即运行一次',
'hint': '保存插件配置后是否立即触发一次插件任务运行'
}
}]
}]
}, {
'component': 'VRow',
'content': [{
'component': 'VCol',
'props': {
'cols': 12,
'xxl': 4, 'xl': 4, 'lg': 4, 'md': 4, 'sm': 6, 'xs': 12
},
'content': [{
'component': 'VTextField',
'props': {
'model': 'cron',
'label': '定时执行周期',
'placeholder': cron,
'hint': f'设置插件任务执行周期。支持5位cron表达式应避免任务执行过于频繁缺省时为{cron}'
}
}]
}, {
'component': 'VCol',
'props': {
'cols': 12,
'xxl': 4, 'xl': 4, 'lg': 4, 'md': 4, 'sm': 6, 'xs': 12
},
'content': [{
'component': 'VSelect',
'props': {
'model': 'include_plugins',
'label': '包含的插件',
'multiple': True,
'chips': True,
'items': installed_online_plugin_options,
'hint': '选择哪些插件需要自动升级,不选时默认全部已安装插件。'
}
}]
}, {
'component': 'VCol',
'props': {
'cols': 12,
'xxl': 4, 'xl': 4, 'lg': 4, 'md': 4, 'sm': 6, 'xs': 12
},
'content': [{
'component': 'VSelect',
'props': {
'model': 'exclude_plugins',
'label': '排除的插件',
'multiple': True,
'chips': True,
'items': installed_online_plugin_options,
'hint': '选择哪些插件需要排除升级(在【包含的插件】的基础上排除),不选时默认不排除。'
}
}]
}]
}]
}]
return form, config_suggest
def get_page(self) -> List[dict]:
pass
def stop_service(self):
"""
退出插件
"""
try:
logger.info('尝试停止插件服务...')
self.__exit_event.set()
self.__stop_scheduler()
logger.info('插件服务停止成功')
except Exception as e:
logger.error(f"插件服务停止异常: {str(e)}", exc_info=True)
finally:
self.__exit_event.clear()
def __get_config_item(self, config_key: str, use_default: bool = True) -> Any:
"""
获取插件配置项
:param config_key: 配置键
:param use_default: 是否使用缺省值
:return: 配置值
"""
if not config_key:
return None
config = self.__config if self.__config else {}
config_value = config.get(config_key)
if config_value is None and use_default:
config_default = self.__config_default if self.__config_default else {}
config_value = config_default.get(config_key)
return config_value
@classmethod
def __get_local_plugins(cls) -> List[schemas.Plugin]:
"""
获取所有本地插件信息
"""
local_plugins = cls.__plugin_manager.get_local_plugins()
return local_plugins
@classmethod
def __get_installed_local_plugins(cls) -> List[schemas.Plugin]:
"""
获取所有已安装的本地插件信息
"""
local_plugins = cls.__get_local_plugins()
installed_local_plugins = [local_plugin for local_plugin in local_plugins if local_plugin and local_plugin.installed]
return installed_local_plugins
@classmethod
def __get_installed_local_plugin(cls, plugin_id: str) -> List[schemas.Plugin]:
"""
获取指定的已安装的本地插件信息
"""
if not plugin_id:
return None
# 已安装的本地插件
installed_plugins = cls.__get_installed_local_plugins()
for installed_plugin in installed_plugins:
if installed_plugin and installed_plugin.id and installed_plugin.id == plugin_id:
return installed_plugin
return None
@classmethod
def __get_online_plugins(cls) -> List[schemas.Plugin]:
"""
获取所有在线插件
"""
online_plugins = cls.__plugin_manager.get_online_plugins()
return online_plugins
@classmethod
def __get_installed_online_plugins(cls) -> List[schemas.Plugin]:
"""
获取所有已安装的在线插件
"""
online_plugins = cls.__get_online_plugins()
installed_online_plugins = [online_plugin for online_plugin in online_plugins if online_plugin and online_plugin.installed]
return installed_online_plugins
@classmethod
def __get_installed_online_plugin_options(cls) -> Dict[str, Any]:
"""
获取所有已安装的在线插件的选项数据
"""
installed_online_plugin_options = []
installed_online_plugins = cls.__get_installed_online_plugins()
for installed_online_plugin in installed_online_plugins:
if not installed_online_plugin:
continue
installed_online_plugin_options.append({
'value': installed_online_plugin.id,
'title': installed_online_plugin.plugin_name
})
return installed_online_plugin_options
@classmethod
def __get_has_update_online_plugins(cls) -> List[schemas.Plugin]:
"""
获取所有可升级的在线插件
"""
installed_online_plugins = cls.__get_installed_online_plugins()
if not installed_online_plugins:
return None
has_update_online_plugins = [installed_online_plugin for installed_online_plugin in installed_online_plugins if installed_online_plugin and installed_online_plugin.has_update]
return has_update_online_plugins
def __start_scheduler(self, timezone=None) -> bool:
"""
启动调度器
:param timezone: 时区
"""
try:
if not self.__scheduler:
if not timezone:
timezone = settings.TZ
self.__scheduler = BackgroundScheduler(timezone=timezone)
logger.debug(f"插件服务调度器初始化完成: timezone = {str(timezone)}")
if not self.__scheduler.running:
self.__scheduler.start()
logger.debug(f"插件服务调度器启动成功")
self.__scheduler.print_jobs()
return True
except Exception as e:
logger.error(f"插件服务调度器启动异常: {str(e)}", exc_info=True)
return False
def __stop_scheduler(self):
"""
停止调度器
"""
try:
logger.info('尝试停止插件服务调度器...')
if self.__scheduler:
self.__scheduler.remove_all_jobs()
if self.__scheduler.running:
self.__scheduler.shutdown()
self.__scheduler = None
logger.info('插件服务调度器停止成功')
else:
logger.info('插件未启用服务调度器,无须停止')
except Exception as e:
logger.error(f"插件服务调度器停止异常: {str(e)}", exc_info=True)
def __check_allow_upgrade(self, plugin_id: str) -> bool:
"""
判断插件是否允许升级:包含、排除
"""
if not plugin_id:
return False
exclude_plugins = self.__get_config_item('exclude_plugins')
if exclude_plugins and plugin_id in exclude_plugins:
return False
include_plugins = self.__get_config_item('include_plugins')
if not include_plugins or plugin_id in include_plugins:
return True
else:
return False
def __try_run(self):
"""
尝试运行插件任务
"""
if not self.__task_lock.acquire(blocking=False):
logger.info('已有进行中的任务,本次不执行')
return
try:
self.__run()
finally:
self.__task_lock.release()
def __run(self):
""""
运行插件任务
"""
self.__upgrade_batch()
def __upgrade_batch(self):
"""
批量升级
"""
has_update_online_plugins = self.__get_has_update_online_plugins()
upgrade_results = []
for has_update_online_plugin in has_update_online_plugins:
upgrade_result = self.__upgrade_single(has_update_online_plugin)
if upgrade_result:
upgrade_results.append(upgrade_result)
self.__send_notify(results=upgrade_results)
def __upgrade_single(self, online_plugin: schemas.Plugin) -> Dict[str, Any]:
"""
单个升级
"""
if not online_plugin or not online_plugin.has_update or not online_plugin.id or not online_plugin.repo_url or not self.__check_allow_upgrade(plugin_id=online_plugin.id):
return None
installed_local_plugin = self.__get_installed_local_plugin(plugin_id=online_plugin.id)
if not installed_local_plugin:
return None
response = install(plugin_id=online_plugin.id, repo_url=online_plugin.repo_url, force=True)
logger.info(f"插件升级结果: plugin_name = {online_plugin.plugin_name}, plugin_version = v{installed_local_plugin.plugin_version} -> v{online_plugin.plugin_version}, success = {response.success}, message = {response.message}")
return {
'success': response.success,
'message': response.message,
'plugin_id': online_plugin.id,
'plugin_name': online_plugin.plugin_name,
'new_plugin_version': online_plugin.plugin_version,
'old_plugin_version': installed_local_plugin.plugin_version
}
def __send_notify(self, results: List[Dict[str, Any]]):
"""
发送通知
:param results: 插件升级结果
"""
if not results or not self.__get_config_item('enable_notify'):
return
text = self.__build_notify_message(results=results)
if not text:
return
self.post_message(title=f'{self.plugin_name}任务执行结果', text=text)
@staticmethod
def __build_notify_message(results: List[Dict[str, Any]]) -> str:
"""
构建通知消息内容
"""
text = ''
if not results:
return text
for result in results:
if not result:
continue
if result.get('success'):
text += f"{result.get('plugin_name')}升级[v{result.get('old_plugin_version')} -> v{result.get('new_plugin_version')}]成功\n"
else:
text += f"{result.get('plugin_name')}升级[v{result.get('old_plugin_version')} -> v{result.get('new_plugin_version')}]失败:{result.get('message')}\n"
return text

View File

@@ -11,7 +11,6 @@ from watchdog.observers import Observer
from app.log import logger
from app.plugins import _PluginBase
from app.schemas import Notification, NotificationType
from app.utils.timer import TimerUtils
state_lock = threading.Lock()
@@ -91,7 +90,7 @@ class RemoveLink(_PluginBase):
# 插件图标
plugin_icon = "Ombi_A.png"
# 插件版本
plugin_version = "1.8"
plugin_version = "1.6"
# 插件作者
plugin_author = "DzAvril"
# 作者主页
@@ -175,40 +174,6 @@ class RemoveLink(_PluginBase):
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_service(self) -> List[Dict[str, Any]]:
"""
注册插件公共服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron/interval/date/CronTrigger.from_crontab()",
"func": self.xxx,
"kwargs": {} # 定时器参数
}]
"""
if self._enabled:
# 随机时间
triggers = TimerUtils.random_scheduler(
num_executions=1,
begin_hour=0,
end_hour=1,
min_interval=1,
max_interval=60,
)
ret_jobs = []
for trigger in triggers:
ret_jobs.append(
{
"id": f"RemoveLink|{trigger.hour}:{trigger.minute}",
"name": "清理空文件夹",
"trigger": "cron",
"func": self.delete_empty_folders,
"kwargs": {"hour": trigger.hour, "minute": trigger.minute},
}
)
return ret_jobs
return []
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
return [
{
@@ -308,26 +273,26 @@ class RemoveLink(_PluginBase):
],
},
{
"component": "VRow",
"content": [
'component': 'VRow',
'content': [
{
"component": "VCol",
"props": {
"cols": 12,
'component': 'VCol',
'props': {
'cols': 12,
},
"content": [
'content': [
{
"component": "VAlert",
"props": {
"type": "info",
"variant": "tonal",
"text": "监控目录如有多个需换行,源目录和硬链接目录都需要添加到监控目录中;如需实现删除硬链接时不删除源文件,可把源文件目录配置到不删除目录中。",
},
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '监控目录如有多个需换行,源目录和硬链接目录都需要添加到监控目录中;如需实现删除硬链接时不删除源文件,可把源文件目录配置到不删除目录中。'
}
}
],
]
}
],
},
]
}
],
}
], {
@@ -364,25 +329,6 @@ class RemoveLink(_PluginBase):
return True
return False
def delete_empty_folders(self):
"""
删除空目录
"""
for mon_path in self.monitor_dirs.split("\n"):
for subdir, dirs, files in os.walk(mon_path, topdown=False):
for dir in dirs:
dir_path = os.path.join(subdir, dir)
# 检查当前目录是否为空
if not os.listdir(dir_path) and not self.__is_excluded(dir_path):
os.rmdir(dir_path)
logger.info(f"删除空目录:{dir_path}")
if self._notify:
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【清理硬链接】",
text=f"清理空文件夹:[{dir_path}]\n",
)
def handle_deleted(self, file_path: Path):
"""
处理删除事件
@@ -412,10 +358,7 @@ class RemoveLink(_PluginBase):
mtype=NotificationType.SiteMessage,
title=f"【清理硬链接】",
text=f"监控到删除源文件:[{file_path}]\n"
f"同步删除硬链接文件:[{path}]",
f"同步删除硬链接文件:[{path}]",
)
except Exception as e:
logger.error(
"删除硬链接文件发生错误:%s - %s" % (str(e), traceback.format_exc())
)
logger.error("删除硬链接文件发生错误:%s - %s" % (str(e), traceback.format_exc()))