import re import threading import time from datetime import datetime, timedelta from typing import List, Tuple, Dict, Any, Optional import pytz from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from app.core.config import settings from app.log import logger from app.modules.qbittorrent import Qbittorrent from app.modules.transmission import Transmission from app.plugins import _PluginBase from app.schemas import NotificationType from app.utils.string import StringUtils lock = threading.Lock() class TorrentRemover(_PluginBase): # 插件名称 plugin_name = "自动删种" # 插件描述 plugin_desc = "自动删除下载器中的下载任务。" # 插件图标 plugin_icon = "torrent.png" # 主题色 plugin_color = "#02853F" # 插件版本 plugin_version = "1.0" # 插件作者 plugin_author = "jxxghp" # 作者主页 author_url = "https://github.com/jxxghp" # 插件配置项ID前缀 plugin_config_prefix = "torrentremover_" # 加载顺序 plugin_order = 8 # 可使用的用户级别 auth_level = 2 # 私有属性 qb = None tr = None _event = threading.Event() _scheduler = None _enabled = False _onlyonce = False _notify = False # pause/delete _downloaders = [] _action = "pause" _cron = None _samedata = False _mponly = False _size = None _ratio = None _time = None _upspeed = None _labels = None _pathkeywords = None _trackerkeywords = None _errorkeywords = None _torrentstates = None _torrentcategorys = None def init_plugin(self, config: dict = None): if config: self._enabled = config.get("enabled") self._onlyonce = config.get("onlyonce") self._notify = config.get("notify") self._downloaders = config.get("downloaders") or [] self._action = config.get("action") self._cron = config.get("cron") self._samedata = config.get("samedata") self._mponly = config.get("mponly") self._size = config.get("size") or "" self._ratio = config.get("ratio") self._time = config.get("time") self._upspeed = config.get("upspeed") self._labels = config.get("labels") or "" self._pathkeywords = config.get("pathkeywords") or "" self._trackerkeywords = config.get("trackerkeywords") or "" self._errorkeywords = config.get("errorkeywords") or "" self._torrentstates = config.get("torrentstates") or "" self._torrentcategorys = config.get("torrentcategorys") or "" self.stop_service() if self.get_state() or self._onlyonce: self._scheduler = BackgroundScheduler(timezone=settings.TZ) self.qb = Qbittorrent() self.tr = Transmission() if self._cron: try: self._scheduler.add_job(func=self.delete_torrents, trigger=CronTrigger.from_crontab(self._cron), name="自动删种服务") logger.info(f"自动删种服务启动,周期:{self._cron}") except Exception as err: logger.error(f"自动删种服务启动失败:{str(err)}") self.systemmessage.put(f"自动删种服务启动失败:{str(err)}") if self._onlyonce: logger.info(f"自动删种服务启动,立即运行一次") self._scheduler.add_job(func=self.delete_torrents, trigger='date', run_date=datetime.now( tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3) ) # 关闭一次性开关 self._onlyonce = False # 保存设置 self.update_config({ "enabled": self._enabled, "notify": self._notify, "onlyonce": self._onlyonce, "action": self._action, "cron": self._cron, "samedata": self._samedata, "mponly": self._mponly, "size": self._size, "ratio": self._ratio, "time": self._time, "upspeed": self._upspeed, "labels": self._labels, "pathkeywords": self._pathkeywords, "trackerkeywords": self._trackerkeywords, "errorkeywords": self._errorkeywords, "torrentstates": self._torrentstates, "torrentcategorys": self._torrentcategorys }) if self._scheduler.get_jobs(): # 启动服务 self._scheduler.print_jobs() self._scheduler.start() def get_state(self) -> bool: return True if self._enabled and self._cron and self._downloaders else False @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]]: 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': 'notify', 'label': '发送通知', } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': { 'cols': 12, 'md': 6 }, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'cron', 'label': '执行周期', 'placeholder': '0 0 0 ? *' } } ] }, { 'component': 'VCol', 'props': { 'cols': 12, 'md': 6 }, 'content': [ { 'component': 'VSelect', 'props': { 'model': 'action', 'label': '动作', 'items': [ {'title': '暂停', 'value': 'pause'}, {'title': '删除种子', 'value': 'delete'}, {'title': '删除种子和文件', 'value': 'deletefile'} ] } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': { 'cols': 12 }, 'content': [ { 'component': 'VSelect', 'props': { 'chips': True, 'multiple': True, 'model': 'downloaders', 'label': '下载器', 'items': [ {'title': 'Qbittorrent', 'value': 'qbittorrent'}, {'title': 'Transmission', 'value': 'transmission'} ] } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': { 'cols': 6 }, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'size', 'label': '种子大小(GB)', 'placeholder': '例如1-10' } } ] }, { 'component': 'VCol', 'props': { 'cols': 6 }, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'ratio', 'label': '分享率', 'placeholder': '' } } ] }, { 'component': 'VCol', 'props': { 'cols': 6 }, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'time', 'label': '做种时间(小时)', 'placeholder': '' } } ] }, { 'component': 'VCol', 'props': { 'cols': 6 }, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'upspeed', 'label': '平均上传速度', 'placeholder': '' } } ] }, { 'component': 'VCol', 'props': { 'cols': 6 }, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'labels', 'label': '标签', 'placeholder': '用,分隔多个标签' } } ] }, { 'component': 'VCol', 'props': { 'cols': 6 }, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'pathkeywords', 'label': '保存路径关键词', 'placeholder': '支持正式表达式' } } ] }, { 'component': 'VCol', 'props': { 'cols': 6 }, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'trackerkeywords', 'label': 'Tracker关键词', 'placeholder': '支持正式表达式' } } ] }, { 'component': 'VCol', 'props': { 'cols': 6 }, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'errorkeywords', 'label': '错误信息关键词', 'placeholder': '支持正式表达式' } } ] }, { 'component': 'VCol', 'props': { 'cols': 6 }, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'torrentstates', 'label': '任务状态', 'placeholder': '用,分隔多个状态' } } ] }, { 'component': 'VCol', 'props': { 'cols': 6 }, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'torrentcategorys', 'label': '任务分类', 'placeholder': '用,分隔多个分类' } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': { 'cols': 12, 'md': 4 }, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'samedata', 'label': '处理辅种', } } ] }, { 'component': 'VCol', 'props': { 'cols': 12, 'md': 4 }, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'mponly', 'label': '仅MoviePilot任务', } } ] }, { 'component': 'VCol', 'props': { 'cols': 12, 'md': 4 }, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'onlyonce', 'label': '立即运行一次', } } ] } ] } ] } ], { "enabled": False, "notify": False, "onlyonce": False, "action": 'pause', "cron": '0 0 0 ? *', "samedata": False, "mponly": False, "size": "", "ratio": "", "time": "", "upspeed": "", "labels": "", "pathkeywords": "", "trackerkeywords": "", "errorkeywords": "", "torrentstates": "", "torrentcategorys": "" } 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._event.set() self._scheduler.shutdown() self._event.clear() self._scheduler = None except Exception as e: print(str(e)) def __get_downloader(self, dtype: str): """ 根据类型返回下载器实例 """ if dtype == "qbittorrent": return self.qb elif dtype == "transmission": return self.tr else: return None def delete_torrents(self): """ 定时删除下载器中的下载任务 """ for downloader in self._downloaders: try: with lock: # 获取需删除种子列表 torrents = self.get_remove_torrents(downloader) logger.info(f"自动删种任务 获取符合处理条件种子数 {len(torrents)}") # 下载器 downlader_obj = self.__get_downloader(downloader) if self._action == "pause": message_text = f"{downloader.title()} 共暂停{len(torrents)}个种子" for torrent in torrents: if self._event.is_set(): logger.info(f"自动删种服务停止") return text_item = f"{torrent.get('name')} " \ f"来自站点:{torrent.get('site')} " \ f"大小:{StringUtils.str_filesize(torrent.get('size'))} GB" # 暂停种子 downlader_obj.stop_torrents(ids=[torrent.get("id")]) logger.info(f"自动删种任务 暂停种子:{text_item}") message_text = f"{message_text}\n{text_item}" elif self._action == "delete": message_text = f"{downloader.title()} 共删除{len(torrents)}个种子" for torrent in torrents: if self._event.is_set(): logger.info(f"自动删种服务停止") return text_item = f"{torrent.get('name')} " \ f"来自站点:{torrent.get('site')} " \ f"大小:{StringUtils.str_filesize(torrent.get('size'))} GB" # 删除种子 downlader_obj.delete_torrents(delete_file=False, ids=[torrent.get("id")]) logger.info(f"自动删种任务 删除种子:{text_item}") message_text = f"{message_text}\n{text_item}" elif self._action == "deletefile": message_text = f"{downloader.title()} 共删除{len(torrents)}个种子及文件" for torrent in torrents: if self._event.is_set(): logger.info(f"自动删种服务停止") return text_item = f"{torrent.get('name')} " \ f"来自站点:{torrent.get('site')} " \ f"大小:{StringUtils.str_filesize(torrent.get('size'))} GB" # 删除种子 downlader_obj.delete_torrents(delete_file=True, ids=[torrent.get("id")]) logger.info(f"自动删种任务 删除种子及文件:{text_item}") message_text = f"{message_text}\n{text_item}" else: continue if torrents and message_text and self._notify: self.post_message( mtype=NotificationType.SiteMessage, title=f"【自动删种任务执行完成】", text=message_text ) except Exception as e: logger.error(f"自动删种任务异常:{str(e)}") def __get_qb_torrent(self, torrent: Any) -> Optional[dict]: """ 检查QB下载任务是否符合条件 """ # 完成时间 date_done = torrent.completion_on if torrent.completion_on > 0 else torrent.added_on # 现在时间 date_now = int(time.mktime(datetime.now().timetuple())) # 做种时间 torrent_seeding_time = date_now - date_done if date_done else 0 # 平均上传速度 torrent_upload_avs = torrent.uploaded / torrent_seeding_time if torrent_seeding_time else 0 # 大小 单位:GB sizes = self._size.split(',') if self._size else [] minsize = sizes[0] * 1024 * 1024 * 1024 if sizes else 0 maxsize = sizes[-1] * 1024 * 1024 * 1024 if sizes else 0 # 分享率 if self._ratio and torrent.ratio <= float(self._ratio): return None # 做种时间 单位:小时 if self._time and torrent_seeding_time <= float(self._time) * 3600: return None # 文件大小 if self._size and (torrent.size >= maxsize or torrent.size <= minsize): return None if self._upspeed and torrent_upload_avs >= float(self._upspeed) * 1024: return None if self._pathkeywords and not re.findall(self._pathkeywords, torrent.save_path, re.I): return None if self._trackerkeywords and not re.findall(self._trackerkeywords, torrent.tracker, re.I): return None if self._torrentstates and torrent.state not in self._torrentstates: return None if self._torrentcategorys and torrent.category not in self._torrentcategorys: return None return { "id": torrent.hash, "name": torrent.name, "site": StringUtils.get_url_sld(torrent.tracker), "size": torrent.size } def __get_tr_torrent(self, torrent: Any) -> Optional[dict]: """ 检查TR下载任务是否符合条件 """ # 完成时间 date_done = torrent.date_done or torrent.date_added # 现在时间 date_now = int(time.mktime(datetime.now().timetuple())) # 做种时间 torrent_seeding_time = date_now - int(time.mktime(date_done.timetuple())) if date_done else 0 # 上传量 torrent_uploaded = torrent.ratio * torrent.total_size # 平均上传速茺 torrent_upload_avs = torrent_uploaded / torrent_seeding_time if torrent_seeding_time else 0 # 大小 单位:GB sizes = self._size.split(',') if self._size else [] minsize = sizes[0] * 1024 * 1024 * 1024 if sizes else 0 maxsize = sizes[-1] * 1024 * 1024 * 1024 if sizes else 0 # 分享率 if self._ratio and torrent.ratio <= float(self._ratio): return None if self._time and torrent_seeding_time <= float(self._time) * 3600: return None if self._size and (torrent.total_size >= maxsize or torrent.total_size <= minsize): return None if self._upspeed and torrent_upload_avs >= float(self._upspeed) * 1024: return None if self._pathkeywords and not re.findall(self._pathkeywords, torrent.download_dir, re.I): return None if self._trackerkeywords: if not torrent.trackers: return None else: tacker_key_flag = False for tracker in torrent.trackers: if re.findall(self._trackerkeywords, tracker.get("announce", ""), re.I): tacker_key_flag = True break if not tacker_key_flag: return None if self._errorkeywords and not re.findall(self._errorkeywords, torrent.error_string, re.I): return None return { "id": torrent.hashString, "name": torrent.name, "site": torrent.trackers[0].get("sitename") if torrent.trackers else "", "size": torrent.total_size } def get_remove_torrents(self, downloader: str): """ 获取自动删种任务种子 """ remove_torrents = [] # 下载器对象 downloader_obj = self.__get_downloader(downloader) # 标题 if self._labels: tags = self._labels.split(',') else: tags = [] if self._mponly: tags.extend(settings.TORRENT_TAG) # 查询种子 torrents, error_flag = downloader_obj.get_torrents(tags=tags or None) if error_flag: return [] # 处理种子 for torrent in torrents: if downloader == "qbittorrent": item = self.__get_qb_torrent(torrent) else: item = self.__get_tr_torrent(torrent) if not item: continue remove_torrents.append(item) # 处理辅种 if self._samedata and remove_torrents: remove_torrents_plus = [] for remove_torrent in remove_torrents: name = remove_torrent.get("name") size = remove_torrent.get("size") for torrent in torrents: if torrent.name == name \ and torrent.size == size \ and torrent.hash not in [t.get("id") for t in remove_torrents]: remove_torrents_plus.append({ "id": torrent.hash, "name": torrent.name, "site": StringUtils.get_url_sld(torrent.tracker), "size": torrent.size }) remove_torrents.extend(remove_torrents_plus) return remove_torrents