From dadc525d0b1b770c32011803c5fbe3bff88a75ff Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 20 Aug 2025 22:03:18 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E7=A7=8D=E5=AD=90=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E4=BD=BF=E7=94=A8=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/chain/__init__.py | 11 +-- app/chain/download.py | 40 +++++---- app/helper/torrent.py | 122 +++++++++++++++++---------- app/modules/qbittorrent/__init__.py | 10 ++- app/modules/subtitle/__init__.py | 10 +-- app/modules/transmission/__init__.py | 10 ++- 6 files changed, 125 insertions(+), 78 deletions(-) diff --git a/app/chain/__init__.py b/app/chain/__init__.py index f5899060..56a62e4a 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -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, diff --git a/app/chain/download.py b/app/chain/download.py index 28e80ef7..5b71800d 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -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, diff --git a/app/helper/torrent.py b/app/helper/torrent.py index 3aed486d..7c50abe8 100644 --- a/app/helper/torrent.py +++ b/app/helper/torrent.py @@ -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: """ diff --git a/app/modules/qbittorrent/__init__.py b/app/modules/qbittorrent/__init__.py index c0af0029..2e9b836d 100644 --- a/app/modules/qbittorrent/__init__.py +++ b/app/modules/qbittorrent/__init__.py @@ -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}" diff --git a/app/modules/subtitle/__init__.py b/app/modules/subtitle/__init__.py index acb65afc..83b2f66e 100644 --- a/app/modules/subtitle/__init__.py +++ b/app/modules/subtitle/__init__.py @@ -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 # 等待目录存在 diff --git a/app/modules/transmission/__init__.py b/app/modules/transmission/__init__.py index 48d43e39..c1c23053 100644 --- a/app/modules/transmission/__init__.py +++ b/app/modules/transmission/__init__.py @@ -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}"