Files
archived-MoviePilot-Plugins/plugins/cleaninvalidseed/__init__.py
2024-05-13 12:15:59 +00:00

487 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))