diff --git a/icons/clean_a.png b/icons/clean_a.png new file mode 100755 index 0000000..5887721 Binary files /dev/null and b/icons/clean_a.png differ diff --git a/package.json b/package.json index fef90d9..cb8496c 100644 --- a/package.json +++ b/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": "定时清理已经被站点删除的种子及对应源文件" + } } -} +} \ No newline at end of file diff --git a/plugins/cleaninvalidseed/__init__.py b/plugins/cleaninvalidseed/__init__.py new file mode 100644 index 0000000..6488cb6 --- /dev/null +++ b/plugins/cleaninvalidseed/__init__.py @@ -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))