mirror of
https://github.com/d0zingcat/MoviePilot-Plugins.git
synced 2026-05-13 15:09:12 +00:00
Initial plugin cleaninvalidseeds
This commit is contained in:
BIN
icons/clean_a.png
Executable file
BIN
icons/clean_a.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
16
package.json
16
package.json
@@ -522,7 +522,7 @@
|
||||
"icon": "words.png",
|
||||
"author": "honue",
|
||||
"level": 1
|
||||
},
|
||||
},
|
||||
"NeoDBSync": {
|
||||
"name": "NeoDB 想看",
|
||||
"description": "同步 NeoDB 想看条目,自动添加订阅。",
|
||||
@@ -695,5 +695,17 @@
|
||||
"icon": "Duplicati_A.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1
|
||||
},
|
||||
"CleanInvalidSeeds": {
|
||||
"name": "清理QB无效做种",
|
||||
"description": "清理已经被站点删除的种子及对应源文件,仅支持QB",
|
||||
"labels": "Qbittorrent",
|
||||
"version": "1.0",
|
||||
"icon": "clean_a.png",
|
||||
"author": "DzAvril",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.0": "定时清理已经被站点删除的种子及对应源文件"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
486
plugins/cleaninvalidseed/__init__.py
Normal file
486
plugins/cleaninvalidseed/__init__.py
Normal file
@@ -0,0 +1,486 @@
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from app.modules.qbittorrent import Qbittorrent
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.plugins import _PluginBase
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
from app.log import logger
|
||||
from app.schemas import NotificationType
|
||||
|
||||
|
||||
class CleanInvalidSeed(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "清理QB无效做种"
|
||||
# 插件描述
|
||||
plugin_desc = "清理已经被站点删除的种子及源文件,仅支持QB"
|
||||
# 插件图标
|
||||
plugin_icon = "clean_a.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.0"
|
||||
# 插件作者
|
||||
plugin_author = "DzAvril"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/DzAvril"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "cleaninvalidseed"
|
||||
# 加载顺序
|
||||
plugin_order = 1
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 私有属性
|
||||
_qb = None
|
||||
_detect_invalid_files = False
|
||||
_delete_invalid_files = False
|
||||
_delete_invalid_torrents = False
|
||||
_download_dirs = ""
|
||||
_exclude_keywords = ""
|
||||
# 定时器
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._cron = config.get("cron")
|
||||
self._notify = config.get("notify")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._delete_invalid_torrents = config.get("delete_invalid_torrents")
|
||||
self._delete_invalid_files = config.get("delete_invalid_files")
|
||||
self._detect_invalid_files = config.get("detect_invalid_files")
|
||||
self._download_dirs = config.get("download_dirs")
|
||||
self._exclude_keywords = config.get("exclude_keywords")
|
||||
self._qb = Qbittorrent()
|
||||
|
||||
# 加载模块
|
||||
if self._onlyonce:
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
logger.info(f"清理无效种子服务启动,立即运行一次")
|
||||
self._scheduler.add_job(
|
||||
func=self.clean_invalid_seed,
|
||||
trigger="date",
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ))
|
||||
+ timedelta(seconds=3),
|
||||
name="清理无效种子",
|
||||
)
|
||||
# 关闭一次性开关
|
||||
self._onlyonce = False
|
||||
self.update_config(
|
||||
{
|
||||
"onlyonce": False,
|
||||
"cron": self._cron,
|
||||
"enabled": self._enabled,
|
||||
"notify": self._notify,
|
||||
"delete_invalid_torrents": self._delete_invalid_torrents,
|
||||
"delete_invalid_files": self._delete_invalid_files,
|
||||
"detect_invalid_files": self._detect_invalid_files,
|
||||
"download_dirs": self._download_dirs,
|
||||
"exclude_keywords": self._exclude_keywords,
|
||||
}
|
||||
)
|
||||
|
||||
# 启动任务
|
||||
if self._scheduler.get_jobs():
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件公共服务
|
||||
[{
|
||||
"id": "服务ID",
|
||||
"name": "服务名称",
|
||||
"trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 定时器参数
|
||||
}]
|
||||
"""
|
||||
if self._enabled and self._cron:
|
||||
return [
|
||||
{
|
||||
"id": "CleanInvalidSeed",
|
||||
"name": "清理QB无效做种",
|
||||
"trigger": CronTrigger.from_crontab(self._cron),
|
||||
"func": self.clean_invalid_seed,
|
||||
"kwargs": {},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def get_all_torrents(self):
|
||||
all_torrents, error = self._qb.get_torrents()
|
||||
if error:
|
||||
logger.error(f"获取QB种子失败: {error}")
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理QB无效做种】",
|
||||
text=f"获取QB种子失败,请检查QB配置",
|
||||
)
|
||||
return []
|
||||
|
||||
if not all_torrents:
|
||||
logger.warning("QB没有种子")
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理QB无效做种】",
|
||||
text=f"QB中没有种子",
|
||||
)
|
||||
return []
|
||||
return all_torrents
|
||||
|
||||
def clean_invalid_seed(self):
|
||||
all_torrents = self.get_all_torrents()
|
||||
temp_invalid_torrents = []
|
||||
working_tracker_set = set()
|
||||
# 第一轮筛选出所有未工作的种子
|
||||
for torrent in all_torrents:
|
||||
trackers = torrent.trackers
|
||||
is_invalid = True
|
||||
for tracker in trackers:
|
||||
if tracker.get('tier') == -1:
|
||||
continue
|
||||
if not (tracker.get('status') == 4):
|
||||
is_invalid = False
|
||||
tracker_domian = StringUtils.get_url_netloc((tracker.get('url')))[1]
|
||||
working_tracker_set.add(tracker_domian)
|
||||
if is_invalid:
|
||||
temp_invalid_torrents.append(torrent)
|
||||
logger.info(f"初筛共有{len(temp_invalid_torrents)}个无效做种")
|
||||
# 第二轮筛选出tracker有正常工作种子而当前种子未工作的,避免因临时关站或tracker失效导致误删的问题
|
||||
invalid_torrents = []
|
||||
message = "检测到失效种子\n"
|
||||
for torrent in temp_invalid_torrents:
|
||||
trackers = torrent.trackers
|
||||
is_invalid = False
|
||||
for tracker in trackers:
|
||||
if tracker.get('tier') == -1:
|
||||
continue
|
||||
tracker_domian = StringUtils.get_url_netloc((tracker.get('url')))[1]
|
||||
if tracker_domian in working_tracker_set:
|
||||
# tracker是正常的,说明该种子是无效的
|
||||
is_invalid = True
|
||||
message += f'失效种子:{torrent.name},Tracker: {tracker_domian},大小:{StringUtils.str_filesize(torrent.size)},原因:{tracker.msg}\n'
|
||||
if self._delete_invalid_torrents:
|
||||
# 只删除种子不删除文件,以防其它站点辅种
|
||||
self._qb.delete_torrents(False, torrent.get('hash'))
|
||||
break
|
||||
if is_invalid:
|
||||
invalid_torrents.append(torrent)
|
||||
message += f'共筛选出{len(invalid_torrents)}个无效种子\n'
|
||||
if self._delete_invalid_torrents:
|
||||
message += f'***已成功清理!***\n'
|
||||
logger.info(message)
|
||||
if self._notify and len(invalid_torrents) != 0:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理无效做种】",
|
||||
text=message,
|
||||
)
|
||||
if self._detect_invalid_files:
|
||||
self.detect_invalid_files()
|
||||
|
||||
def detect_invalid_files(self):
|
||||
logger.info("开始检测未做种的无效源文件")
|
||||
all_torrents = self.get_all_torrents()
|
||||
source_path_map = {}
|
||||
source_paths = []
|
||||
total_size = 0
|
||||
deleted_file_cnt = 0
|
||||
exclude_key_words = self._exclude_keywords.split("\n")
|
||||
for path in self._download_dirs.split("\n"):
|
||||
mp_path, qb_path = path.split(":")
|
||||
source_path_map[mp_path] = qb_path
|
||||
source_paths.append(mp_path)
|
||||
# 所有做种源文件路径
|
||||
content_path_set = set()
|
||||
for torrent in all_torrents:
|
||||
content_path_set.add(torrent.content_path)
|
||||
|
||||
message = '检测到未做种无效源文件:\n'
|
||||
for source_path_str in source_paths:
|
||||
source_path = Path(source_path_str)
|
||||
source_files = []
|
||||
# 获取source_path下的所有文件包括文件夹
|
||||
for file in source_path.iterdir():
|
||||
source_files.append(file)
|
||||
for source_file in source_files:
|
||||
skip = False
|
||||
for key_word in exclude_key_words:
|
||||
if key_word in source_file.name:
|
||||
logger.info(f"命中关键字{key_word},跳过{str(source_file)}")
|
||||
skip = True
|
||||
break
|
||||
if skip:
|
||||
continue
|
||||
# 将mp_path替换成 qb_path
|
||||
qb_path = (str(source_file)).replace(source_path_str, source_path_map[source_path_str])
|
||||
# todo: 优化性能
|
||||
is_exist = False
|
||||
for content_path in content_path_set:
|
||||
if qb_path in content_path:
|
||||
is_exist = True
|
||||
break
|
||||
|
||||
if not is_exist:
|
||||
deleted_file_cnt += 1
|
||||
message += f'{str(source_file)}\n'
|
||||
total_size += self.get_size(source_file)
|
||||
if self._delete_invalid_files:
|
||||
if source_file.is_file():
|
||||
source_file.unlink()
|
||||
elif source_file.is_dir():
|
||||
shutil.rmtree(source_file)
|
||||
|
||||
message += f'检测到{deleted_file_cnt}个未做种的无效源文件,共占用{StringUtils.str_filesize(total_size)}空间。\n'
|
||||
if self._delete_invalid_files:
|
||||
message += f'***已删除无效源文件,释放{StringUtils.str_filesize(total_size)}空间!***\n'
|
||||
logger.info(message)
|
||||
if self._notify and deleted_file_cnt != 0:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理无效做种】",
|
||||
text=message,
|
||||
)
|
||||
|
||||
def get_size(self, path: Path):
|
||||
total_size = 0
|
||||
if path.is_file():
|
||||
return path.stat().st_size
|
||||
# rglob 方法用于递归遍历所有文件和目录
|
||||
for entry in path.rglob('*'):
|
||||
if entry.is_file():
|
||||
total_size += entry.stat().st_size
|
||||
return total_size
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"component": "VForm",
|
||||
"content": [
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "enabled",
|
||||
"label": "启用插件",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "notify",
|
||||
"label": "开启通知",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "onlyonce",
|
||||
"label": "立即运行一次",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "delete_invalid_torrents",
|
||||
"label": "删除无效种子",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "detect_invalid_files",
|
||||
"label": "检测无效源文件",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "delete_invalid_files",
|
||||
"label": "删除无效源文件",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {"model": "cron", "label": "执行周期"},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "download_dirs",
|
||||
"label": "下载目录映射",
|
||||
"rows": 5,
|
||||
"placeholder": "填写要监控的源文件目录,并设置MP和QB的目录映射关系,如/mp/download:/qb/download,多个目录请换行",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "exclude_keywords",
|
||||
"label": "过滤关键字",
|
||||
"rows": 5,
|
||||
"placeholder": "多个关键字请换行,仅针对删除源文件",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VAlert",
|
||||
"props": {
|
||||
"type": "info",
|
||||
"variant": "tonal",
|
||||
"text": "要监控的源文件目录填入源文件所在目录,并设置MP和QB的目录映射关系,如/mp/download:/qb/download,多个目录请换行。映射主要不要有多余的'/'",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VAlert",
|
||||
"props": {
|
||||
"type": "info",
|
||||
"variant": "tonal",
|
||||
"text": "谨慎起见删除种子/源文件功能做了开关,请确认无误后再开启删除功能",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"notify": False,
|
||||
"download_dirs": "",
|
||||
"delete_invalid_torrents": False,
|
||||
"delete_invalid_files": False,
|
||||
"detect_invalid_files": False,
|
||||
"onlyonce": False,
|
||||
"cron": "0 0 * * *",
|
||||
}
|
||||
|
||||
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))
|
||||
Reference in New Issue
Block a user