Files
MoviePilot-Plugins/plugins.v2/crossseed/__init__.py
2024-09-26 17:22:39 +08:00

1233 lines
50 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 hashlib
import os
import re
import time
from datetime import datetime, timedelta
from pathlib import Path
from threading import Event
from typing import Any, Dict, List, Optional, Tuple, Union
import pytz
import requests
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from bencode import bdecode, bencode
from app.core.config import settings
from app.core.event import eventmanager
from app.db.site_oper import SiteOper
from app.helper.sites import SitesHelper
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.schemas import NotificationType
from app.schemas.types import EventType
from app.utils.string import StringUtils
from app.utils.timer import TimerUtils
class CSSiteConfig(object):
"""
站点辅种配置类
"""
def __init__(
self,
name: str = None,
url: str = None,
passkey: str = None,
id: int = None,
cookie: str = None,
ua: str = None,
proxy: bool = None,
query_gap: int = 1,
) -> None:
self.name = name
self.url = url
self.passkey = passkey
self.id = id
self.cookie = cookie
self.ua = ua
self.proxy = proxy
self.query_gap = query_gap
def get_api_url(self):
if self.name == "憨憨":
return f"{self.url}nexusapi/pieces-hash"
return f"{self.url}api/pieces-hash"
def get_torrent_url(self, torrent_id: str):
return f"{self.url}download.php?id={torrent_id}&passkey={self.passkey}"
class TorInfo:
def __init__(
self,
site_name: str = None,
torrent_path: str = None,
file_path: str = None,
info_hash: str = None,
pieces_hash: str = None,
torrent_id: str = None,
) -> None:
self.site_name = site_name
self.torrent_path = torrent_path
self.file_path = file_path
self.info_hash = info_hash
self.pieces_hash = pieces_hash
self.torrent_id = torrent_id
self.torrent_announce = None
@staticmethod
def local(torrent_path: str, info_hash: str, pieces_hash: str):
return TorInfo(
torrent_path=torrent_path, info_hash=info_hash, pieces_hash=pieces_hash
)
@staticmethod
def remote(site_name: str, pieces_hash: str, torrent_id: str):
return TorInfo(
site_name=site_name, pieces_hash=pieces_hash, torrent_id=torrent_id
)
@staticmethod
def from_data(data: bytes) -> Tuple[Optional[Any], Optional[str]]:
try:
torrent = bdecode(data)
info = torrent["info"]
pieces = info["pieces"]
info_hash = hashlib.sha1(bencode(info)).hexdigest()
pieces_hash = hashlib.sha1(pieces).hexdigest()
local_tor = TorInfo(info_hash=info_hash, pieces_hash=pieces_hash)
# 从种子中获取 announce, qb可能存在获取不到的情况会存在于fastresume文件中
if "announce" in torrent:
local_tor.torrent_announce = torrent["announce"]
return local_tor, None
except Exception as err:
return None, str(err)
def get_name_id_tag(self):
return f"{self.site_name}:{self.torrent_id}"
def get_name_pieces_tag(self):
return f"{self.site_name}:{self.pieces_hash}"
class CrossSeedHelper(object):
_version = "0.2.0"
@staticmethod
def get_local_torrent_info(torrent_path: Path | str) -> Tuple[Optional[TorInfo], str]:
try:
if isinstance(torrent_path, Path):
torrent_data = torrent_path.read_bytes()
else:
with open(torrent_path, "rb") as f:
torrent_data = f.read()
local_tor, err = TorInfo.from_data(torrent_data)
if not local_tor:
return None, err
local_tor.torrent_path = str(torrent_path)
return local_tor, ""
except Exception as err:
return None, str(err)
@staticmethod
def get_target_torrent(
site: CSSiteConfig,
pieces_hash_set: List[str]
) -> Tuple[Optional[List[TorInfo]], Optional[str]]:
"""
返回pieces_hash对应的种子信息包括站点id,pieces_hash,种子id
"""
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "CrossSeedHelper",
}
data = {"passkey": site.passkey, "pieces_hash": pieces_hash_set}
remote_torrent_infos = []
try:
response = requests.post(
site.get_api_url(),
headers=headers,
json=data,
timeout=10,
proxies=settings.PROXY if site.proxy else None,
)
response.raise_for_status()
rsp_body = response.json()
if isinstance(rsp_body["data"], dict):
for pieces_hash, torrent_id in rsp_body["data"].items():
remote_torrent_infos.append(
TorInfo.remote(site.name, pieces_hash, torrent_id)
)
time.sleep(site.query_gap)
except requests.exceptions.RequestException as e:
return None, f"站点{site.name}请求失败:{e}"
return remote_torrent_infos, None
class CrossSeed(_PluginBase):
# 插件名称
plugin_name = "青蛙辅种助手"
# 插件描述
plugin_desc = "参考ReseedPuppy和IYUU辅种插件实现自动辅种支持站点青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。"
# 插件图标
plugin_icon = "qingwa.png"
# 插件版本
plugin_version = "2.3"
# 插件作者
plugin_author = "233@qingwa"
# 作者主页
author_url = "https://qingwapt.com/"
# 插件配置项ID前缀
plugin_config_prefix = "cross_seed_"
# 加载顺序
plugin_order = 17
# 可使用的用户级别
auth_level = 2
# 私有属性
_scheduler = None
cross_helper = None
qb = None
tr = None
sites = None
siteoper = None
torrent = None
# 开关
_enabled = False
_cron = None
_onlyonce = False
_token = None
_downloaders = []
_sites = []
_torrentpath = None
_notify = False
_nolabels = None
_nopaths = None
_clearcache = False
# 退出事件
_event = Event()
_torrent_tags = ["已整理", "辅种"]
# 待校全种子hash清单
_recheck_torrents = {}
_is_recheck_running = False
# 辅种缓存,出错的种子不再重复辅种,可清除
_error_caches = []
# 辅种缓存,辅种成功的种子,可清除
_success_caches = []
# 辅种缓存出错的种子不再重复辅种且无法清除。种子被删除404等情况
_permanent_error_caches = []
_torrentpaths = []
_site_cs_infos = []
# 辅种计数
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") # passkey格式 青蛙:xxxxxx,站点名称:xxxxxxx
self._downloaders = config.get("downloaders")
self._torrentpath = config.get("torrentpath") # 种子路径和下载器对应 /qb,/tr
self._torrentpaths = self._torrentpath.strip().split(",")
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 []
# 过滤掉已删除的站点
inner_site_list = self.siteoper.list_order_by_pri()
all_sites = [(site.id, site.name) for site in inner_site_list] + [
(site.get("id"), site.get("name")) for site in self.__custom_sites()
]
self._sites = [site_id for site_id, site_name in all_sites if site_id in self._sites]
# 整理所有可用内部站点信息
all_site_cs_info_map: dict[str, CSSiteConfig] = dict()
for site in inner_site_list:
if site.is_active:
all_site_cs_info_map[site.name] = CSSiteConfig(
name=site.name,
url=site.url,
id=site.id,
cookie=site.cookie,
ua=site.ua,
proxy=True if site.proxy else False,
)
for site in self.__custom_sites():
all_site_cs_info_map[site.get("name")] = CSSiteConfig(
name=site.get("name"),
url=site.get("url"),
id=site.get("id"),
cookie=site.get("cookie"),
ua=site.get("ua"),
proxy=site.get("proxy"),
)
self._sites = [site.id for site in all_site_cs_info_map.values() if site.id in self._sites]
site_names = [site.name for site in all_site_cs_info_map.values() if site.id in self._sites]
# 整理passkey映射关系
site_name_key_map = dict()
site_name_gap_map = dict()
for site_key in self._token.strip().split("\n"):
site_key_arr = re.split(r"[\s:]+", site_key.strip())
site_name = site_key_arr[0]
if len(site_key_arr) > 1:
site_name_key_map[site_name] = site_key_arr[1]
if len(site_key_arr) > 2:
if str.isdigit(site_key_arr[2]):
site_name_gap_map[site_name] = int(site_key_arr[2])
else:
logger.warn(
f"站点{site_name}配置的查询请求间隔时间不为整数,不能生效, 请修改 {site_key_arr[2]}"
)
# 只给选中的站点补全站点配置
self._site_cs_infos: List[CSSiteConfig] = []
# 根据配置来补充passkey
for site_name in site_names:
site_key = site_name_key_map.get(site_name)
if not site_key:
logger.warning(
f"未找到站点{site_name}的passkey, 请检查passkey配置是否有误站点{site_name}将跳过辅种")
continue
site_cs_info = all_site_cs_info_map.get(site_name)
site_cs_info.passkey = site_key
# 追加设置的请求间隔时间
site_query_gap = site_name_gap_map.get(site_name)
if site_query_gap:
site_cs_info.query_gap = site_query_gap
self._site_cs_infos.append(site_cs_info)
self.__update_config()
# 停止现有任务
self.stop_service()
# 启动定时任务 & 立即运行一次
if self.get_state() or self._onlyonce:
self.cross_helper = CrossSeedHelper()
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
self.qb = Qbittorrent()
self.tr = Transmission()
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._scheduler.get_jobs():
# 追加种子校验服务
self._scheduler.add_job(self.check_recheck, 'interval', minutes=3)
# 启动服务
self._scheduler.print_jobs()
self._scheduler.start()
if self._clearcache:
# 关闭清除缓存开关
self._clearcache = False
if self._clearcache or self._onlyonce:
# 保存配置
self.__update_config()
def get_state(self) -> bool:
return True if self._enabled and self._token and self._downloaders and self._torrentpath else False
@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.get_state():
# 如果开启了定时任务,并且参数齐全
if self._cron:
return [{
"id": "CrossSeed",
"name": "青蛙辅种助手",
"trigger": CronTrigger.from_crontab(self._cron),
"func": self.auto_seed,
"kwargs": {}
}]
else:
# 随机时间
triggers = TimerUtils.random_scheduler(num_executions=1,
begin_hour=2,
end_hour=7,
max_interval=290,
min_interval=0)
ret_jobs = []
for trigger in triggers:
ret_jobs.append({
"id": f"CrossSeed|{trigger.hour}:{trigger.minute}",
"name": "青蛙辅种助手",
"trigger": "cron",
"func": self.auto_seed,
"kwargs": {
"hour": trigger.hour,
"minute": trigger.minute
}
})
return ret_jobs
elif self._enabled:
logger.warn(f"青蛙辅种助手插件参数不全,定时任务未正常启动")
return []
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
# 站点的可选项(内置站点 + 自定义站点)
customSites = self.__custom_sites()
# 站点的可选项
site_options = ([{"title": site.name, "value": site.id}
for site in self.siteoper.list_order_by_pri()]
+ [{"title": site.get("name"), "value": site.get("id")}
for site in customSites])
# 测试版本,只支持青蛙
# site_options = [s for s in site_options if s["title"]=="青蛙"]
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
},
'content': [
{
'component': 'VSelect',
'props': {
'chips': True,
'multiple': True,
'model': 'sites',
'label': '辅种站点',
'items': site_options
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 12
},
'content': [
{
'component': 'VTextarea',
'props': {
'model': 'token',
'label': '站点Passkey',
'rows': 3,
'placeholder': '每行一个, 格式为 站点名称:Passkey ,站点名称为上面选择的名称,例如青蛙为 青蛙:xxxxxx 其中xxxxxx替换为你的Passkey'
}
}
]
},
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSelect',
'props': {
'chips': True,
'multiple': True,
'model': 'downloaders',
'label': '辅种下载器',
'items': [
{'title': 'Qbittorrent', 'value': 'qbittorrent'},
{'title': 'Transmission', 'value': 'transmission'}
]
}
}
]
},
{
'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,
'md': 12
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'torrentpath',
'label': '种子文件目录',
'placeholder': '多个目录逗号分隔,按下载器顺序对应填写,每个下载器只能有一个种子目录'
}
}
]
}
]
},
{
'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': '清除缓存后运行',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '1. 定时任务周期建议每次辅种间隔时间大于1天不填写每天上午2点到7点随机辅种一次 '
'2. 支持辅种站点列表青蛙、AGSVPT、红豆饭、麒麟、UBits、聆音等配置passkey时站点名称需严格和上面选项一致只有选中的站点会辅种passkey可保存多个 '
'3. 请勿与IYUU辅种插件同时添加相同站点可能会有冲突且意义不大'
'4. 测试站点是否支持的方法:【站点域名/api/pieces-hash】接口访问返回405则大概率支持 '
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '【进阶设置】如果辅种过程中访问/api/pieces-hash接口偶尔会失败可以设置请求间隔时间。 '
'可以在passkey后增加 :3 来将某个站点的请求间隔调整为3秒3可以改为其他数字只能为整数默认请求间隔为1秒。 '
'示例配置 站点名称:Passkey:3'
}
}
]
}
]
}
]
}
], {
"enabled": False,
"onlyonce": False,
"notify": False,
"clearcache": False,
"cron": "",
"token": "",
"downloaders": [],
"torrentpath": "",
"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,
"torrentpath": self._torrentpath,
"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):
"""
开始辅种
"""
logger.info("开始辅种任务 ...")
# 计数器初始化
self.total = 0
self.realtotal = 0
self.success = 0
self.exist = 0
self.fail = 0
self.cached = 0
# 扫描下载器辅种
for idx, downloader in enumerate(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)
# 获取种子文件路径
torrent_path = Path(self._torrentpaths[idx]) / f"{hash_str}.torrent"
torrent_info = None
if not torrent_path.exists():
if False and downloader == "qbittorrent":
# qb开启SQLite功能后将不再以hash命名的方式保存torrent文件
# TODO 导出功能需要qb4.5.0以上版本才支持
logger.warn(f"QB种子文件不存在{torrent_path} 尝试远程导出种子")
try:
torrent_data = torrent.export()
torrent_info, err = TorInfo.from_data(torrent_data)
except Exception as e:
err = str(e)
if not torrent_info:
logger.error(f"尝试远程导出种子 {hash_str} 出错 {err}")
continue
else:
logger.error(f"种子文件不存在:{torrent_path}")
continue
# 读取种子文件具体信息
if not torrent_info:
torrent_info, err = self.cross_helper.get_local_torrent_info(torrent_path)
if not torrent_info:
logger.error(f"未能读取到种子文件具体信息:{torrent_path} {err}")
continue
# 用站点+pieces_hash记录该站点是否已经在该下载器中,需要从tracker补充站点名字
tracker_urls = set()
try:
if downloader == "qbittorrent":
for i in torrent.trackers:
if "https" in i.get("url"):
tracker_urls.add(i.get("url"))
elif downloader == "transmission":
if torrent_info and torrent_info.torrent_announce:
if "https" in torrent_info.torrent_announce:
tracker_urls.add(torrent_info.torrent_announce)
except Exception as err:
logger.warn(f"尝试获取 {downloader} 的tracker出错 {err}")
# 根据tracker补充站点信息
for tracker in tracker_urls:
# 优先通过passkey获取站点名
for site_config in self._site_cs_infos:
if site_config.passkey in tracker:
torrent_info.site_name = site_config.name
break
if not torrent_info.site_name:
# 尝试通过域名获取站点信息
tracker_domain = StringUtils.get_url_domain(tracker)
site_info = self.sites.get_indexer(tracker_domain)
if site_info:
torrent_info.site_name = site_info.get("name")
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,
"torrent_info": torrent_info
})
if hash_strs:
self.__seed_torrents(hash_strs=hash_strs, 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="【青蛙辅种助手辅种任务完成】",
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)} ...")
# 每个Hash的保存目录
save_paths = {}
pieces_hash_set = set()
site_pieces_hash_set = set()
for item in hash_strs:
tor_info: TorInfo = item.get("torrent_info")
save_paths[tor_info.pieces_hash] = item.get("save_path")
pieces_hash_set.add(tor_info.pieces_hash)
if tor_info.site_name:
site_pieces_hash_set.add(tor_info.get_name_pieces_tag())
logger.info(f"去重后,总共需要辅种查询的种子数:{len(pieces_hash_set)}")
pieces_hashes = list(pieces_hash_set)
# 分站点逐个批次辅种
# 逐个站点查询可辅种数据
chunk_size = 100
for site_config in self._site_cs_infos:
# 检查站点是否已经停用
db_site = self.siteoper.get(site_config.id)
if db_site and not db_site.is_active:
logger.info(f"站点{site_config.name}已停用,跳过辅种")
continue
remote_tors: List[TorInfo] = []
total_size = len(pieces_hashes)
for i in range(0, len(pieces_hashes), chunk_size):
if self._event.is_set():
logger.info(f"辅种服务停止")
return
# 切片操作
chunk = pieces_hashes[i:i + chunk_size]
# 处理分组
chunk_tors, err_msg = self.cross_helper.get_target_torrent(site_config, chunk)
if not chunk_tors and err_msg:
logger.info(
f"查询站点{site_config.name}可辅种的信息出错 {err_msg},进度={i + 1}/{total_size}"
)
else:
logger.info(
f"站点{site_config.name}本批次的可辅种/查询数={len(chunk_tors)}/{len(chunk)},进度={i + 1}/{total_size}"
)
remote_tors = remote_tors + chunk_tors
logger.info(f"站点{site_config.name}返回可以辅种的种子总数为{len(remote_tors)}")
# 去除已经下载过的种子
local_cnt = 0
not_local_tors = []
for tor_info in remote_tors:
if (
tor_info
and tor_info.site_name
and tor_info.pieces_hash
and tor_info.get_name_pieces_tag() in site_pieces_hash_set
):
local_cnt = local_cnt + 1
else:
not_local_tors.append(tor_info)
logger.info(f"站点{site_config.name}正在做种或已经辅种过的种子数为{local_cnt}")
for tor_info in not_local_tors:
if self._event.is_set():
logger.info(f"辅种服务停止")
return
if not tor_info:
continue
if not tor_info.torrent_id or not tor_info.pieces_hash:
continue
if tor_info.get_name_id_tag() in self._success_caches:
logger.info(f"{tor_info.get_name_id_tag()} 已处理过辅种,跳过 ...")
continue
if tor_info.get_name_id_tag() in self._error_caches or tor_info.get_name_id_tag() in self._permanent_error_caches:
logger.info(f"种子 {tor_info.get_name_id_tag()} 辅种失败且已缓存,跳过 ...")
continue
# 添加任务
self.__download_torrent(tor=tor_info, site_config=site_config,
downloader=downloader,
save_path=save_paths.get(tor_info.pieces_hash))
logger.info(f"下载器 {downloader} 辅种完成")
def __download(self, downloader: str, content: Union[bytes, str],
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} 下载任务添加成功,但获取任务信息失败!")
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,
tor: TorInfo,
site_config: CSSiteConfig,
downloader: str,
save_path: str,
):
"""
下载种子
"""
self.total += 1
self.realtotal += 1
# 下载种子
torrent_url = site_config.get_torrent_url(tor.torrent_id)
# 下载种子文件
_, content, _, _, error_msg = self.torrent.download_torrent(
url=torrent_url,
cookie=site_config.cookie,
ua=site_config.ua or settings.USER_AGENT,
proxy=True if site_config.proxy else False)
# 兼容种子无法访问的情况
if not content or (isinstance(content, bytes) and "你没有该权限".encode(encoding="utf-8") in content):
# 下载失败
self.fail += 1
self.cached += 1
# 加入失败缓存
if error_msg and ('无法打开链接' in error_msg or '触发站点流控' in error_msg):
self._error_caches.append(tor.get_name_id_tag())
else:
# 种子不存在的情况
self._permanent_error_caches.append(tor.get_name_id_tag())
logger.error(f"下载种子文件失败:{tor.get_name_id_tag()}")
return False
# 添加任务前查询校验一次,避免重复添加,导致暂停的任务被重新开始
tmp_tor_info, err_msg = TorInfo.from_data(content)
if tmp_tor_info and tmp_tor_info.info_hash:
tors, msg = self.__get_downloader(downloader).get_torrents(ids=[tmp_tor_info.info_hash])
if tors:
self.exist += 1
self._success_caches.append(tor.get_name_id_tag())
logger.info(f"下载的种子{tor.get_name_id_tag()}已存在, 跳过")
return True
else:
logger.warn(f"获取下载种子的信息出错{err_msg},不能检查该种子是否已暂停")
# 添加下载,辅种任务默认暂停
logger.info(f"添加下载任务:{tor.get_name_id_tag()} ...")
download_id = self.__download(downloader=downloader,
content=content,
save_path=save_path)
if not download_id:
# 下载失败
self.fail += 1
self.cached += 1
# 加入失败缓存
self._error_caches.append(tor.get_name_id_tag())
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"成功添加辅种下载,站点种子:{tor.get_name_id_tag()}")
# TR会自动校验
if downloader == "qbittorrent":
# 开始校验种子
self.__get_downloader(downloader).recheck_torrents(ids=[download_id])
# 成功也加入缓存,有一些改了路径校验不通过的,手动删除后,下一次又会辅上
self._success_caches.append(tor.get_name_id_tag())
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 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 __custom_sites(self) -> List[Any]:
custom_sites = []
custom_sites_config = self.get_config("CustomSites")
if custom_sites_config and custom_sites_config.get("enabled"):
custom_sites = custom_sites_config.get("sites")
return custom_sites
@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()