import os import re from datetime import datetime, timedelta from threading import Event from typing import Any, List, Dict, Tuple, Optional import pytz from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from lxml import etree from ruamel.yaml import CommentedMap from app.core.config import settings from app.db.site_oper import SiteOper from app.helper.sites import SitesHelper from app.core.event import eventmanager from app.db.models.site import Site from app.helper.torrent import TorrentHelper from app.log import logger from app.modules.qbittorrent import Qbittorrent from app.modules.transmission import Transmission from app.plugins import _PluginBase from app.plugins.iyuuautoseed.iyuu_helper import IyuuHelper from app.schemas import NotificationType from app.schemas.types import EventType from app.utils.http import RequestUtils from app.utils.string import StringUtils class IYUUAutoSeed(_PluginBase): # 插件名称 plugin_name = "IYUU自动辅种" # 插件描述 plugin_desc = "基于IYUU官方Api实现自动辅种。" # 插件图标 plugin_icon = "iyuu.png" # 主题色 plugin_color = "#F3B70B" # 插件版本 plugin_version = "1.0" # 插件作者 plugin_author = "jxxghp" # 作者主页 author_url = "https://github.com/jxxghp" # 插件配置项ID前缀 plugin_config_prefix = "iyuuautoseed_" # 加载顺序 plugin_order = 17 # 可使用的用户级别 auth_level = 2 # 私有属性 _scheduler = None iyuuhelper = None qb = None tr = None sites = None siteoper = None torrent = None # 开关 _enabled = False _cron = None _onlyonce = False _token = None _downloaders = [] _sites = [] _notify = False _nolabels = None _nopaths = None _clearcache = False # 退出事件 _event = Event() # 种子链接xpaths _torrent_xpaths = [ "//form[contains(@action, 'download.php?id=')]/@action", "//a[contains(@href, 'download.php?hash=')]/@href", "//a[contains(@href, 'download.php?id=')]/@href", "//a[@class='index'][contains(@href, '/dl/')]/@href", ] _torrent_tags = ["已整理", "辅种"] # 待校全种子hash清单 _recheck_torrents = {} _is_recheck_running = False # 辅种缓存,出错的种子不再重复辅种,可清除 _error_caches = [] # 辅种缓存,辅种成功的种子,可清除 _success_caches = [] # 辅种缓存,出错的种子不再重复辅种,且无法清除。种子被删除404等情况 _permanent_error_caches = [] # 辅种计数 total = 0 realtotal = 0 success = 0 exist = 0 fail = 0 cached = 0 def init_plugin(self, config: dict = None): self.sites = SitesHelper() self.siteoper = SiteOper() self.torrent = TorrentHelper() # 读取配置 if config: self._enabled = config.get("enabled") self._onlyonce = config.get("onlyonce") self._cron = config.get("cron") self._token = config.get("token") self._downloaders = config.get("downloaders") self._sites = config.get("sites") or [] self._notify = config.get("notify") self._nolabels = config.get("nolabels") self._nopaths = config.get("nopaths") self._clearcache = config.get("clearcache") self._permanent_error_caches = [] if self._clearcache else config.get("permanent_error_caches") or [] self._error_caches = [] if self._clearcache else config.get("error_caches") or [] self._success_caches = [] if self._clearcache else config.get("success_caches") or [] # 过滤掉已删除的站点 self._sites = [site.get("id") for site in self.sites.get_indexers() if not site.get("public") and site.get("id") in self._sites] self.__update_config() # 停止现有任务 self.stop_service() # 启动定时任务 & 立即运行一次 if self.get_state() or self._onlyonce: self.iyuuhelper = IyuuHelper(token=self._token) self._scheduler = BackgroundScheduler(timezone=settings.TZ) self.qb = Qbittorrent() self.tr = Transmission() if self._cron: try: self._scheduler.add_job(self.auto_seed, CronTrigger.from_crontab(self._cron)) 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(self.auto_seed, 'date', run_date=datetime.now( tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3) ) # 关闭一次性开关 self._onlyonce = False if self._clearcache: # 关闭清除缓存开关 self._clearcache = False if self._clearcache or self._onlyonce: # 保存配置 self.__update_config() if self._scheduler.get_jobs(): # 追加种子校验服务 self._scheduler.add_job(self.check_recheck, 'interval', minutes=3) # 启动服务 self._scheduler.print_jobs() self._scheduler.start() def get_state(self) -> bool: return True if self._enabled and self._cron and self._token 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]]: """ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 """ # 站点的可选项 site_options = [{"title": site.name, "value": site.id} for site in self.siteoper.list_order_by_pri()] 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': 'token', 'label': 'IYUU Token', } } ] }, { 'component': 'VCol', 'props': { 'cols': 12, 'md': 6 }, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'cron', 'label': '执行周期', 'placeholder': '0 0 0 ? *' } } ] }, ] }, { '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': 12 }, 'content': [ { 'component': 'VSelect', 'props': { 'chips': True, 'multiple': True, 'model': 'sites', 'label': '辅种站点', 'items': site_options } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': { 'cols': 12 }, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'nolabels', 'label': '不辅种标签', 'placeholder': '使用,分隔多个标签' } } ] }, { 'component': 'VCol', 'props': { 'cols': 12 }, 'content': [ { 'component': 'VTextarea', 'props': { 'model': 'nopaths', 'label': '不辅种数据文件目录', 'rows': 3, 'placeholder': '每一行一个目录' } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': { 'cols': 12, 'md': 6 }, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'onlyonce', 'label': '立即运行一次', } } ] }, { 'component': 'VCol', 'props': { 'cols': 12, 'md': 6 }, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'clearcache', 'label': '清除缓存后运行', } } ] } ] } ] } ], { "enabled": False, "onlyonce": False, "notify": False, "clearcache": False, "cron": "", "token": "", "downloaders": [], "sites": [], "nopaths": "", "nolabels": "" } def get_page(self) -> List[dict]: pass def __update_config(self): self.update_config({ "enabled": self._enabled, "onlyonce": self._onlyonce, "clearcache": self._clearcache, "cron": self._cron, "token": self._token, "downloaders": self._downloaders, "sites": self._sites, "notify": self._notify, "nolabels": self._nolabels, "nopaths": self._nopaths, "success_caches": self._success_caches, "error_caches": self._error_caches, "permanent_error_caches": self._permanent_error_caches }) def __get_downloader(self, dtype: str): """ 根据类型返回下载器实例 """ if dtype == "qbittorrent": return self.qb elif dtype == "transmission": return self.tr else: return None def auto_seed(self): """ 开始辅种 """ if not self.iyuuhelper: return logger.info("开始辅种任务 ...") # 计数器初始化 self.total = 0 self.realtotal = 0 self.success = 0 self.exist = 0 self.fail = 0 self.cached = 0 # 扫描下载器辅种 for downloader in self._downloaders: logger.info(f"开始扫描下载器 {downloader} ...") downloader_obj = self.__get_downloader(downloader) # 获取下载器中已完成的种子 torrents = downloader_obj.get_completed_torrents() if torrents: logger.info(f"下载器 {downloader} 已完成种子数:{len(torrents)}") else: logger.info(f"下载器 {downloader} 没有已完成种子") continue hash_strs = [] for torrent in torrents: if self._event.is_set(): logger.info(f"辅种服务停止") return # 获取种子hash hash_str = self.__get_hash(torrent, downloader) if hash_str in self._error_caches or hash_str in self._permanent_error_caches: logger.info(f"种子 {hash_str} 辅种失败且已缓存,跳过 ...") continue save_path = self.__get_save_path(torrent, downloader) if self._nopaths and save_path: # 过滤不需要转移的路径 nopath_skip = False for nopath in self._nopaths.split('\n'): if os.path.normpath(save_path).startswith(os.path.normpath(nopath)): logger.info(f"种子 {hash_str} 保存路径 {save_path} 不需要辅种,跳过 ...") nopath_skip = True break if nopath_skip: continue # 获取种子标签 torrent_labels = self.__get_label(torrent, downloader) if torrent_labels and self._nolabels: is_skip = False for label in self._nolabels.split(','): if label in torrent_labels: logger.info(f"种子 {hash_str} 含有不辅种标签 {label},跳过 ...") is_skip = True break if is_skip: continue hash_strs.append({ "hash": hash_str, "save_path": save_path }) if hash_strs: logger.info(f"总共需要辅种的种子数:{len(hash_strs)}") # 分组处理,减少IYUU Api请求次数 chunk_size = 200 for i in range(0, len(hash_strs), chunk_size): # 切片操作 chunk = hash_strs[i:i + chunk_size] # 处理分组 self.__seed_torrents(hash_strs=chunk, downloader=downloader) # 触发校验检查 self.check_recheck() else: logger.info(f"没有需要辅种的种子") # 保存缓存 self.__update_config() # 发送消息 if self._notify: if self.success or self.fail: self.post_message( mtype=NotificationType.SiteMessage, title="【IYUU自动辅种任务完成】", text=f"服务器返回可辅种总数:{self.total}\n" f"实际可辅种数:{self.realtotal}\n" f"已存在:{self.exist}\n" f"成功:{self.success}\n" f"失败:{self.fail}\n" f"{self.cached} 条失败记录已加入缓存" ) logger.info("辅种任务执行完成") def check_recheck(self): """ 定时检查下载器中种子是否校验完成,校验完成且完整的自动开始辅种 """ if not self._recheck_torrents: return if self._is_recheck_running: return self._is_recheck_running = True for downloader in self._downloaders: # 需要检查的种子 recheck_torrents = self._recheck_torrents.get(downloader) or [] if not recheck_torrents: continue logger.info(f"开始检查下载器 {downloader} 的校验任务 ...") # 下载器 downloader_obj = self.__get_downloader(downloader) # 获取下载器中的种子状态 torrents, _ = downloader_obj.get_torrents(ids=recheck_torrents) if torrents: can_seeding_torrents = [] for torrent in torrents: # 获取种子hash hash_str = self.__get_hash(torrent, downloader) if self.__can_seeding(torrent, downloader): can_seeding_torrents.append(hash_str) if can_seeding_torrents: logger.info(f"共 {len(can_seeding_torrents)} 个任务校验完成,开始辅种 ...") # 开始任务 downloader_obj.start_torrents(ids=can_seeding_torrents) # 去除已经处理过的种子 self._recheck_torrents[downloader] = list( set(recheck_torrents).difference(set(can_seeding_torrents))) elif torrents is None: logger.info(f"下载器 {downloader} 查询校验任务失败,将在下次继续查询 ...") continue else: logger.info(f"下载器 {downloader} 中没有需要检查的校验任务,清空待处理列表 ...") self._recheck_torrents[downloader] = [] self._is_recheck_running = False def __seed_torrents(self, hash_strs: list, downloader: str): """ 执行一批种子的辅种 """ if not hash_strs: return logger.info(f"下载器 {downloader} 开始查询辅种,数量:{len(hash_strs)} ...") # 下载器中的Hashs hashs = [item.get("hash") for item in hash_strs] # 每个Hash的保存目录 save_paths = {} for item in hash_strs: save_paths[item.get("hash")] = item.get("save_path") # 查询可辅种数据 seed_list, msg = self.iyuuhelper.get_seed_info(hashs) if not isinstance(seed_list, dict): logger.warn(f"当前种子列表没有可辅种的站点:{msg}") return else: logger.info(f"IYUU返回可辅种数:{len(seed_list)}") # 遍历 for current_hash, seed_info in seed_list.items(): if not seed_info: continue seed_torrents = seed_info.get("torrent") if not isinstance(seed_torrents, list): seed_torrents = [seed_torrents] # 本次辅种成功的种子 success_torrents = [] for seed in seed_torrents: if not seed: continue if not isinstance(seed, dict): continue if not seed.get("sid") or not seed.get("info_hash"): continue if seed.get("info_hash") in hashs: logger.info(f"{seed.get('info_hash')} 已在下载器中,跳过 ...") continue if seed.get("info_hash") in self._success_caches: logger.info(f"{seed.get('info_hash')} 已处理过辅种,跳过 ...") continue if seed.get("info_hash") in self._error_caches or seed.get("info_hash") in self._permanent_error_caches: logger.info(f"种子 {seed.get('info_hash')} 辅种失败且已缓存,跳过 ...") continue # 添加任务 success = self.__download_torrent(seed=seed, downloader=downloader, save_path=save_paths.get(current_hash)) if success: success_torrents.append(seed.get("info_hash")) # 辅种成功的去重放入历史 if len(success_torrents) > 0: self.__save_history(current_hash=current_hash, downloader=downloader, success_torrents=success_torrents) logger.info(f"下载器 {downloader} 辅种完成") def __save_history(self, current_hash: str, downloader: str, success_torrents: []): """ [ { "downloader":"2", "torrents":[ "248103a801762a66c201f39df7ea325f8eda521b", "bd13835c16a5865b01490962a90b3ec48889c1f0" ] }, { "downloader":"3", "torrents":[ "248103a801762a66c201f39df7ea325f8eda521b", "bd13835c16a5865b01490962a90b3ec48889c1f0" ] } ] """ try: # 查询当前Hash的辅种历史 seed_history = self.get_data(key=current_hash) or [] new_history = True if len(seed_history) > 0: for history in seed_history: if not history: continue if not isinstance(history, dict): continue if not history.get("downloader"): continue # 如果本次辅种下载器之前有过记录则继续添加 if str(history.get("downloader")) == downloader: history_torrents = history.get("torrents") or [] history["torrents"] = list(set(history_torrents + success_torrents)) new_history = False break # 本次辅种下载器之前没有成功记录则新增 if new_history: seed_history.append({ "downloader": downloader, "torrents": list(set(success_torrents)) }) # 保存历史 self.save_data(key=current_hash, value=seed_history) except Exception as e: print(str(e)) def __download(self, downloader: str, content: bytes, save_path: str) -> Optional[str]: """ 添加下载任务 """ if downloader == "qbittorrent": # 生成随机Tag tag = StringUtils.generate_random_str(10) state = self.qb.add_torrent(content=content, download_dir=save_path, is_paused=True, tag=["已整理", "辅种", tag]) if not state: return None else: # 获取种子Hash torrent_hash = self.qb.get_torrent_id_by_tag(tags=tag) if not torrent_hash: logger.error(f"{downloader} 获取种子Hash失败") return None return torrent_hash elif downloader == "transmission": # 添加任务 torrent = self.tr.add_torrent(content=content, download_dir=save_path, is_paused=True, labels=["已整理", "辅种"]) if not torrent: return None else: return torrent.hashString logger.error(f"不支持的下载器:{downloader}") return None def __download_torrent(self, seed: dict, downloader: str, save_path: str): """ 下载种子 torrent: { "sid": 3, "torrent_id": 377467, "info_hash": "a444850638e7a6f6220e2efdde94099c53358159" } """ def __is_special_site(url): """ 判断是否为特殊站点(是否需要添加https) """ if "hdsky.me" in url: return False return True self.total += 1 # 获取种子站点及下载地址模板 site_url, download_page = self.iyuuhelper.get_torrent_url(seed.get("sid")) if not site_url or not download_page: # 加入缓存 self._error_caches.append(seed.get("info_hash")) self.fail += 1 self.cached += 1 return False # 查询站点 site_domain = StringUtils.get_url_domain(site_url) # 站点信息 site_info = self.sites.get_indexer(site_domain) if not site_info: logger.debug(f"没有维护种子对应的站点:{site_url}") return False if self._sites and site_info.get('id') not in self._sites: logger.info("当前站点不在选择的辅种站点范围,跳过 ...") return False self.realtotal += 1 # 查询hash值是否已经在下载器中 downloader_obj = self.__get_downloader(downloader) torrent_info, _ = downloader_obj.get_torrents(ids=[seed.get("info_hash")]) if torrent_info: logger.info(f"{seed.get('info_hash')} 已在下载器中,跳过 ...") self.exist += 1 return False # 站点流控 check, checkmsg = self.sites.check(site_domain) if check: logger.warn(checkmsg) self.fail += 1 return False # 下载种子 torrent_url = self.__get_download_url(seed=seed, site=site_info, base_url=download_page) if not torrent_url: # 加入失败缓存 self._error_caches.append(seed.get("info_hash")) self.fail += 1 self.cached += 1 return False # 强制使用Https if __is_special_site(torrent_url): if "?" in torrent_url: torrent_url += "&https=1" else: torrent_url += "?https=1" # 下载种子文件 _, content, _, _, error_msg = self.torrent.download_torrent( url=torrent_url, cookie=site_info.get("cookie"), ua=site_info.get("ua") or settings.USER_AGENT, proxy=site_info.get("proxy")) if not content: # 下载失败 self.fail += 1 # 加入失败缓存 if error_msg and ('无法打开链接' in error_msg or '触发站点流控' in error_msg): self._error_caches.append(seed.get("info_hash")) else: # 种子不存在的情况 self._permanent_error_caches.append(seed.get("info_hash")) logger.error(f"下载种子文件失败:{torrent_url}") return False # 添加下载,辅种任务默认暂停 logger.info(f"添加下载任务:{torrent_url} ...") download_id = self.__download(downloader=downloader, content=content, save_path=save_path) if not download_id: # 下载失败 self.fail += 1 # 加入失败缓存 self._error_caches.append(seed.get("info_hash")) return False else: self.success += 1 # 追加校验任务 logger.info(f"添加校验检查任务:{download_id} ...") if not self._recheck_torrents.get(downloader): self._recheck_torrents[downloader] = [] self._recheck_torrents[downloader].append(download_id) # 下载成功 logger.info(f"成功添加辅种下载,站点:{site_info.get('name')},种子链接:{torrent_url}") # TR会自动校验 if downloader == "qbittorrent": # 开始校验种子 downloader_obj.recheck_torrents(ids=[download_id]) # 成功也加入缓存,有一些改了路径校验不通过的,手动删除后,下一次又会辅上 self._success_caches.append(seed.get("info_hash")) return True @staticmethod def __get_hash(torrent: Any, dl_type: str): """ 获取种子hash """ try: return torrent.get("hash") if dl_type == "qbittorrent" else torrent.hashString except Exception as e: print(str(e)) return "" @staticmethod def __get_label(torrent: Any, dl_type: str): """ 获取种子标签 """ try: return [str(tag).strip() for tag in torrent.get("tags").split(',')] \ if dl_type == "qbittorrent" else torrent.labels or [] except Exception as e: print(str(e)) return [] @staticmethod def __can_seeding(torrent: Any, dl_type: str): """ 判断种子是否可以做种并处于暂停状态 """ try: return torrent.get("state") == "pausedUP" if dl_type == "qbittorrent" \ else (torrent.status.stopped and torrent.percent_done == 1) except Exception as e: print(str(e)) return False @staticmethod def __get_save_path(torrent: Any, dl_type: str): """ 获取种子保存路径 """ try: return torrent.get("save_path") if dl_type == "qbittorrent" else torrent.download_dir except Exception as e: print(str(e)) return "" def __get_download_url(self, seed: dict, site: CommentedMap, base_url: str): """ 拼装种子下载链接 """ def __is_special_site(url): """ 判断是否为特殊站点 """ spec_params = ["hash=", "authkey="] if any(field in base_url for field in spec_params): return True if "hdchina.org" in url: return True if "hdsky.me" in url: return True if "hdcity.in" in url: return True if "totheglory.im" in url: return True return False try: if __is_special_site(site.get('url')): # 从详情页面获取下载链接 return self.__get_torrent_url_from_page(seed=seed, site=site) else: download_url = base_url.replace( "id={}", "id={id}" ).replace( "/{}", "/{id}" ).replace( "/{torrent_key}", "" ).format( **{ "id": seed.get("torrent_id"), "passkey": site.get("passkey") or '', "uid": site.get("uid") or '', } ) if download_url.count("{"): logger.warn(f"当前不支持该站点的辅助任务,Url转换失败:{seed}") return None download_url = re.sub(r"[&?]passkey=", "", re.sub(r"[&?]uid=", "", download_url, flags=re.IGNORECASE), flags=re.IGNORECASE) return f"{site.get('url')}{download_url}" except Exception as e: logger.warn(f"站点 {site.get('name')} Url转换失败:{str(e)},尝试通过详情页面获取种子下载链接 ...") return self.__get_torrent_url_from_page(seed=seed, site=site) def __get_torrent_url_from_page(self, seed: dict, site: dict): """ 从详情页面获取下载链接 """ if not site.get('url'): logger.warn(f"站点 {site.get('name')} 未获取站点地址,无法获取种子下载链接") return None try: page_url = f"{site.get('url')}details.php?id={seed.get('torrent_id')}&hit=1" logger.info(f"正在获取种子下载链接:{page_url} ...") res = RequestUtils( cookies=site.get("cookie"), ua=site.get("ua"), proxies=settings.PROXY if site.get("proxy") else None ).get_res(url=page_url) if res is not None and res.status_code in (200, 500): if "charset=utf-8" in res.text or "charset=UTF-8" in res.text: res.encoding = "UTF-8" else: res.encoding = res.apparent_encoding if not res.text: logger.warn(f"获取种子下载链接失败,页面内容为空:{page_url}") return None # 使用xpath从页面中获取下载链接 html = etree.HTML(res.text) for xpath in self._torrent_xpaths: download_url = html.xpath(xpath) if download_url: download_url = download_url[0] logger.info(f"获取种子下载链接成功:{download_url}") if not download_url.startswith("http"): if download_url.startswith("/"): download_url = f"{site.get('url')}{download_url[1:]}" else: download_url = f"{site.get('url')}{download_url}" return download_url logger.warn(f"获取种子下载链接失败,未找到下载链接:{page_url}") return None else: logger.error(f"获取种子下载链接失败,请求失败:{page_url},{res.status_code if res else ''}") return None except Exception as e: logger.warn(f"获取种子下载链接失败:{str(e)}") return None 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)) @eventmanager.register(EventType.SiteDeleted) def site_deleted(self, event): """ 删除对应站点选中 """ site_id = event.event_data.get("site_id") config = self.get_config() if config: sites = config.get("sites") if sites: if isinstance(sites, str): sites = [sites] # 删除对应站点 if site_id: sites = [site for site in sites if int(site) != int(site_id)] else: # 清空 sites = [] # 若无站点,则停止 if len(sites) == 0: self._enabled = False self._sites = sites # 保存配置 self.__update_config()