feat:种子下载使用缓存

This commit is contained in:
jxxghp
2025-08-20 22:03:18 +08:00
parent 22b2140c94
commit dadc525d0b
6 changed files with 125 additions and 78 deletions

View File

@@ -686,13 +686,13 @@ class ChainBase(metaclass=ABCMeta):
return self.run_module("filter_torrents", rule_groups=rule_groups,
torrent_list=torrent_list, mediainfo=mediainfo)
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
def download(self, content: Union[Path, str, bytes], download_dir: Path, cookie: str,
episodes: Set[int] = None, category: Optional[str] = None, label: Optional[str] = None,
downloader: Optional[str] = None
) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]:
"""
根据种子文件,选择并添加下载任务
:param content: 种子文件地址或者磁力链接
:param content: 种子文件地址或者磁力链接或者种子内容
:param download_dir: 下载目录
:param cookie: cookie
:param episodes: 需要下载的集数
@@ -705,15 +705,16 @@ class ChainBase(metaclass=ABCMeta):
cookie=cookie, episodes=episodes, category=category, label=label,
downloader=downloader)
def download_added(self, context: Context, download_dir: Path, torrent_path: Path = None) -> None:
def download_added(self, context: Context, download_dir: Path, torrent_content: Union[str, bytes] = None) -> None:
"""
添加下载任务成功后,从站点下载字幕,保存到下载目录
:param context: 上下文,包括识别信息、媒体信息、种子信息
:param download_dir: 下载目录
:param torrent_path: 种子文件地址
:param torrent_content: 种子内容如果有则直接使用该内容否则从context中获取种子文件路径
:return: None该方法可被多个模块同时处理
"""
return self.run_module("download_added", context=context, torrent_path=torrent_path,
return self.run_module("download_added", context=context,
torrent_content=torrent_content,
download_dir=download_dir)
def list_torrents(self, status: TorrentStatus = None,

View File

@@ -35,10 +35,10 @@ class DownloadChain(ChainBase):
channel: MessageChannel = None,
source: Optional[str] = None,
userid: Union[str, int] = None
) -> Tuple[Optional[Union[Path, str]], str, list]:
) -> Tuple[Optional[Union[str, bytes]], str, list]:
"""
下载种子文件,如果是磁力链,会返回磁力链接本身
:return: 种子路径,种子目录名,种子文件清单
:return: 种子内容,种子目录名,种子文件清单
"""
def __get_redict_url(url: str, ua: Optional[str] = None, cookie: Optional[str] = None) -> Optional[str]:
@@ -117,7 +117,7 @@ class DownloadChain(ChainBase):
logger.error(f"{torrent.title} 无法获取下载地址:{torrent.enclosure}")
return None, "", []
# 下载种子文件
torrent_file, content, download_folder, files, error_msg = TorrentHelper().download_torrent(
_, content, download_folder, files, error_msg = TorrentHelper().download_torrent(
url=torrent_url,
cookie=site_cookie,
ua=torrent.site_ua or settings.USER_AGENT,
@@ -127,7 +127,7 @@ class DownloadChain(ChainBase):
# 磁力链
return content, "", []
if not torrent_file:
if not content:
logger.error(f"下载种子文件失败:{torrent.title} - {torrent_url}")
self.post_message(Notification(
channel=channel,
@@ -139,9 +139,11 @@ class DownloadChain(ChainBase):
return None, "", []
# 返回 种子文件路径,种子目录名,种子文件清单
return torrent_file, download_folder, files
return content, download_folder, files
def download_single(self, context: Context, torrent_file: Path = None,
def download_single(self, context: Context,
torrent_file: Path = None,
torrent_content: Optional[Union[str, bytes]] = None,
episodes: Set[int] = None,
channel: MessageChannel = None,
source: Optional[str] = None,
@@ -154,6 +156,7 @@ class DownloadChain(ChainBase):
下载及发送通知
:param context: 资源上下文
:param torrent_file: 种子文件路径
:param torrent_content: 种子内容(磁力链或种子文件内容)
:param episodes: 需要下载的集数
:param channel: 通知渠道
:param source: 来源消息通知、Subscribe、Manual等
@@ -207,18 +210,21 @@ class DownloadChain(ChainBase):
# 实际下载的集数
download_episodes = StringUtils.format_ep(list(episodes)) if episodes else None
_folder_name = ""
if not torrent_file:
if not torrent_file and not torrent_content:
# 下载种子文件,得到的可能是文件也可能是磁力链
content, _folder_name, _file_list = self.download_torrent(_torrent,
channel=channel,
source=source,
userid=userid)
if not content:
return None
else:
content = torrent_file
torrent_content, _folder_name, _file_list = self.download_torrent(_torrent,
channel=channel,
source=source,
userid=userid)
elif torrent_file:
torrent_content = torrent_file.read_bytes()
# 获取种子文件的文件夹名和文件清单
_folder_name, _file_list = TorrentHelper().get_torrent_info(torrent_file)
else:
_folder_name, _file_list = TorrentHelper().get_fileinfo_from_torrent_content(torrent_content)
if not torrent_content:
return None
# 下载目录
if save_path:
@@ -249,7 +255,7 @@ class DownloadChain(ChainBase):
return None
# 添加下载
result: Optional[tuple] = self.download(content=content,
result: Optional[tuple] = self.download(content=torrent_content,
cookie=_torrent.site_cookie,
episodes=episodes,
download_dir=download_dir,
@@ -346,7 +352,7 @@ class DownloadChain(ChainBase):
username=username,
)
# 下载成功后处理
self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file)
self.download_added(context=context, download_dir=download_dir, torrent_content=torrent_content)
# 广播事件
self.eventmanager.send_event(EventType.DownloadAdded, {
"hash": _hash,

View File

@@ -6,6 +6,7 @@ from urllib.parse import unquote
from torrentool.api import Torrent
from app.core.cache import get_file_cache_backend
from app.core.config import settings
from app.core.context import Context, TorrentInfo, MediaInfo
from app.core.meta import MetaBase
@@ -35,27 +36,29 @@ class TorrentHelper(metaclass=WeakSingleton):
-> Tuple[Optional[Path], Optional[Union[str, bytes]], Optional[str], Optional[list], Optional[str]]:
"""
把种子下载到本地
:return: 种子保存路径、种子内容、种子主目录、种子文件清单、错误信息
:return: 种子临时文件相对路径【实际已无效】, 种子内容、种子主目录、种子文件清单、错误信息
"""
if url.startswith("magnet:"):
return None, url, "", [], f"磁力链接"
# 构建 torrent 种子文件的存储路径
file_path = (Path(settings.TEMP_PATH) / StringUtils.md5_hash(url)).with_suffix(".torrent")
if file_path.exists():
# 构建 torrent 种子文件的临时文件名
file_path = Path(StringUtils.md5_hash(url)).with_suffix(".torrent")
# 缓存处理器
cache_backend = get_file_cache_backend()
# 读取缓存的种子文件
torrent_content = cache_backend.get(file_path.as_posix(), region="torrents")
if torrent_content:
# 缓存已存在
try:
# 获取种子目录和文件清单
folder_name, file_list = self.get_torrent_info(file_path)
folder_name, file_list = self.get_fileinfo_from_torrent_content(torrent_content)
# 无法获取信息,则认为缓存文件无效
if not folder_name and not file_list:
raise ValueError("无效的缓存种子文件")
# 获取种子数据
content = file_path.read_bytes()
# 成功拿到种子数据
return file_path, content, folder_name, file_list, ""
return file_path, torrent_content, folder_name, file_list, ""
except Exception as err:
logger.error(f"处理缓存的种子文件 {file_path} 时出错: {err},将重新下载")
file_path.unlink(missing_ok=True)
# 请求种子文件
# 下载种子文件
req = RequestUtils(
ua=ua,
cookies=cookie,
@@ -74,11 +77,11 @@ class TorrentHelper(metaclass=WeakSingleton):
).get_res(url=url, allow_redirects=False)
if req and req.status_code == 200:
if not req.content:
return None, None, "", [], "未下载到种子数据"
return file_path, None, "", [], "未下载到种子数据"
# 解析内容格式
if req.content.startswith(b"magnet:"):
# 磁力链接
return None, req.text, "", [], f"获取到磁力链接"
return file_path, req.text, "", [], f"获取到磁力链接"
if "下载种子文件".encode("utf-8") in req.content:
# 首次下载提示页面
skip_flag = False
@@ -116,34 +119,33 @@ class TorrentHelper(metaclass=WeakSingleton):
except Exception as err:
logger.warn(f"触发了站点首次种子下载,尝试自动跳过时出现错误:{str(err)},链接:{url}")
if not skip_flag:
return None, None, "", [], "种子数据有误请确认链接是否正确如为PT站点则需手工在站点下载一次种子"
return file_path, None, "", [], "种子数据有误请确认链接是否正确如为PT站点则需手工在站点下载一次种子"
# 种子内容
if req.content:
# 检查是不是种子文件,如果不是仍然抛出异常
try:
# 保存到文件
file_path.write_bytes(req.content)
# 保存到缓存
cache_backend.set(file_path.as_posix(), req.content, region="torrents")
# 获取种子目录和文件清单
folder_name, file_list = self.get_torrent_info(file_path)
folder_name, file_list = self.get_fileinfo_from_torrent_content(req.content)
# 成功拿到种子数据
return file_path, req.content, folder_name, file_list, ""
except Exception as err:
logger.error(f"种子文件解析失败:{str(err)}")
# 种子数据仍然错误
return None, None, "", [], "种子数据有误,请确认链接是否正确"
return file_path, None, "", [], "种子数据有误,请确认链接是否正确"
# 返回失败
return None, None, "", [], ""
return file_path, None, "", [], ""
elif req is None:
return None, None, "", [], "无法打开链接"
return file_path, None, "", [], "无法打开链接"
elif req.status_code == 429:
return None, None, "", [], "触发站点流控,请稍后重试"
return file_path, None, "", [], "触发站点流控,请稍后重试"
else:
# 把错误的种子记下来,避免重复使用
self.add_invalid(url)
return None, None, "", [], f"下载种子出错,状态码:{req.status_code}"
return file_path, None, "", [], f"下载种子出错,状态码:{req.status_code}"
@staticmethod
def get_torrent_info(torrent_path: Path) -> Tuple[str, List[str]]:
def get_torrent_info(self, torrent_path: Path) -> Tuple[str, List[str]]:
"""
获取种子文件的文件夹名和文件清单
:param torrent_path: 种子文件路径
@@ -154,32 +156,62 @@ class TorrentHelper(metaclass=WeakSingleton):
try:
torrentinfo = Torrent.from_file(torrent_path)
# 获取文件清单
if (not torrentinfo.files
or (len(torrentinfo.files) == 1
and torrentinfo.files[0].name == torrentinfo.name)):
# 单文件种子目录名返回空
folder_name = ""
# 单文件种子
file_list = [torrentinfo.name]
else:
# 目录名
folder_name = torrentinfo.name
# 文件清单,如果一级目录与种子名相同则去掉
file_list = []
for fileinfo in torrentinfo.files:
file_path = Path(fileinfo.name)
# 根路径
root_path = file_path.parts[0]
if root_path == folder_name:
file_list.append(str(file_path.relative_to(root_path)))
else:
file_list.append(fileinfo.name)
logger.debug(f"解析种子:{torrent_path.name} => 目录:{folder_name},文件清单:{file_list}")
return folder_name, file_list
return self.get_fileinfo_from_torrent(torrentinfo)
except Exception as err:
logger.error(f"种子文件解析失败:{str(err)}")
return "", []
@staticmethod
def get_fileinfo_from_torrent(torrent: Torrent) -> Tuple[str, List[str]]:
"""
从种子文件中获取文件清单
:param torrent: 种子文件对象
:return: 文件夹名、文件清单,单文件种子返回空文件夹名
"""
if not torrent or not torrent.files:
return "", []
# 获取文件清单
if len(torrent.files) == 1 and torrent.files[0].name == torrent.name:
# 单文件种子目录名返回空
folder_name = ""
# 单文件种子
file_list = [torrent.name]
else:
# 目录名
folder_name = torrent.name
# 文件清单,如果一级目录与种子名相同则去掉
file_list = []
for fileinfo in torrent.files:
file_path = Path(fileinfo.name)
# 根路径
root_path = file_path.parts[0]
if root_path == folder_name:
file_list.append(str(file_path.relative_to(root_path)))
else:
file_list.append(fileinfo.name)
logger.debug(f"解析种子:{torrent.name} => 目录:{folder_name},文件清单:{file_list}")
return folder_name, file_list
def get_fileinfo_from_torrent_content(self, torrent_content: Union[str, bytes]) -> Tuple[str, List[str]]:
"""
从种子内容中获取文件夹名和文件清单
:param torrent_content: 种子内容
:return: 文件夹名、文件清单,单文件种子返回空文件夹名
"""
if not torrent_content:
return "", []
try:
if isinstance(torrent_content, bytes):
# 如果是字节流,则转换为字符串
torrent_content = torrent_content.decode('utf-8', errors='ignore')
# 解析种子内容
torrentinfo = Torrent.from_string(torrent_content)
# 获取文件清单
return self.get_fileinfo_from_torrent(torrentinfo)
except Exception as err:
logger.error(f"种子内容解析失败:{str(err)}")
return "", []
@staticmethod
def get_url_filename(req: Any, url: str) -> str:
"""

View File

@@ -92,12 +92,12 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
logger.info(f"Qbittorrent下载器 {name} 连接断开,尝试重连 ...")
server.reconnect()
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
def download(self, content: Union[Path, str, bytes], download_dir: Path, cookie: str,
episodes: Set[int] = None, category: Optional[str] = None, label: Optional[str] = None,
downloader: Optional[str] = None) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]:
"""
根据种子文件,选择并添加下载任务
:param content: 种子文件地址或者磁力链接
:param content: 种子文件地址或者磁力链接或者种子内容
:param download_dir: 下载目录
:param cookie: cookie
:param episodes: 需要下载的集数
@@ -115,7 +115,10 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
if isinstance(content, Path):
torrentinfo = Torrent.from_file(content)
else:
torrentinfo = Torrent.from_string(content)
if isinstance(content, bytes):
torrentinfo = Torrent.from_string(content.decode("utf-8", errors='ignore'))
else:
torrentinfo = Torrent.from_string(content)
return torrentinfo.name, torrentinfo.total_size
except Exception as e:
logger.error(f"获取种子名称失败:{e}")
@@ -123,6 +126,7 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
if not content:
return None, None, None, "下载内容为空"
if isinstance(content, Path) and not content.exists():
logger.error(f"种子文件不存在:{content}")
return None, None, None, f"种子文件不存在:{content}"

View File

@@ -63,19 +63,19 @@ class SubtitleModule(_ModuleBase):
def test(self):
pass
def download_added(self, context: Context, download_dir: Path, torrent_path: Path = None) -> None:
def download_added(self, context: Context, download_dir: Path, torrent_content: Union[str, bytes] = None):
"""
添加下载任务成功后,从站点下载字幕,保存到下载目录
:param context: 上下文,包括识别信息、媒体信息、种子信息
:param download_dir: 下载目录
:param torrent_path: 种子文件地址
:param torrent_content: 种子内容,如果是种子文件,则为文件内容,否则为种子字符串
:return: None该方法可被多个模块同时处理
"""
if not settings.DOWNLOAD_SUBTITLE:
return None
return
# 没有种子文件不处理
if not torrent_path:
if not torrent_content:
return
# 没有详情页不处理
@@ -85,7 +85,7 @@ class SubtitleModule(_ModuleBase):
# 字幕下载目录
logger.info("开始从站点下载字幕:%s" % torrent.page_url)
# 获取种子信息
folder_name, _ = TorrentHelper.get_torrent_info(torrent_path)
folder_name, _ = TorrentHelper().get_fileinfo_from_torrent_content(torrent_content)
# 文件保存目录如果是单文件种子则folder_name是空此时文件保存目录就是下载目录
download_dir = download_dir / folder_name
# 等待目录存在

View File

@@ -93,12 +93,12 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
logger.info(f"Transmission下载器 {name} 连接断开,尝试重连 ...")
server.reconnect()
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
def download(self, content: Union[Path, str, bytes], download_dir: Path, cookie: str,
episodes: Set[int] = None, category: Optional[str] = None, label: Optional[str] = None,
downloader: Optional[str] = None) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]:
"""
根据种子文件,选择并添加下载任务
:param content: 种子文件地址或者磁力链接
:param content: 种子文件地址或者磁力链接或种子内容
:param download_dir: 下载目录
:param cookie: cookie
:param episodes: 需要下载的集数
@@ -116,7 +116,10 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
if isinstance(content, Path):
torrentinfo = Torrent.from_file(content)
else:
torrentinfo = Torrent.from_string(content)
if isinstance(content, bytes):
torrentinfo = Torrent.from_string(content.decode("utf-8", errors='ignore'))
else:
torrentinfo = Torrent.from_string(content)
return torrentinfo.name, torrentinfo.total_size
except Exception as e:
logger.error(f"获取种子名称失败:{e}")
@@ -124,6 +127,7 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
if not content:
return None, None, None, "下载内容为空"
if isinstance(content, Path) and not content.exists():
return None, None, None, f"种子文件不存在:{content}"