diff --git a/app/application/chain/events.py b/app/application/chain/events.py index f45bdcc22..372eab9da 100644 --- a/app/application/chain/events.py +++ b/app/application/chain/events.py @@ -98,6 +98,8 @@ class DownloadProcessingSnapshot: context: Context download_dir: Path torrent_content: str | bytes + download_hash: str | None = None + downloader: str | None = None def snapshot_download_notification(message: Message | None) -> dict[str, Any] | None: @@ -112,8 +114,10 @@ def snapshot_download_processing( context: Context, download_dir: Path, torrent_content: str | bytes, + download_hash: str | None = None, + downloader: str | None = None, ) -> dict[str, Any]: - """冻结下载模块与字幕处理需要的 JSON 输入,并无损编码种子字节。""" + """冻结下载后处理输入,并保留查询下载器实际内容路径所需的身份。""" if isinstance(torrent_content, bytes): content = { "kind": "bytes", @@ -125,6 +129,8 @@ def snapshot_download_processing( "context": context.to_dict(), "download_dir": download_dir.as_posix(), "torrent_content": content, + "download_hash": download_hash, + "downloader": downloader, })) @@ -133,10 +139,16 @@ def restore_download_processing(payload: dict[str, Any]) -> DownloadProcessingSn context = payload.get("context") content = payload.get("torrent_content") download_dir = payload.get("download_dir") + download_hash = payload.get("download_hash") + downloader = payload.get("downloader") if not isinstance(context, dict) or not isinstance(content, dict): raise ValueError("下载后处理快照缺少 context 或 torrent_content") if not isinstance(download_dir, str) or not download_dir: raise ValueError("下载后处理快照缺少 download_dir") + if download_hash is not None and not isinstance(download_hash, str): + raise ValueError("下载后处理快照的 download_hash 无效") + if downloader is not None and not isinstance(downloader, str): + raise ValueError("下载后处理快照的 downloader 无效") kind = content.get("kind") value = content.get("value") if not isinstance(value, str): @@ -157,6 +169,8 @@ def restore_download_processing(payload: dict[str, Any]) -> DownloadProcessingSn context=_restore_context(context), download_dir=Path(download_dir), torrent_content=restored_content, + download_hash=download_hash, + downloader=downloader, ) diff --git a/app/chain/download/history.py b/app/chain/download/history.py index 3ea221dcc..04ac08c3b 100644 --- a/app/chain/download/history.py +++ b/app/chain/download/history.py @@ -150,6 +150,8 @@ class DownloadHistoryOwner(_DownloadOwnerBase): context=context, download_dir=download_dir, torrent_content=torrent_content, + download_hash=download_hash, + downloader=downloader, ) durable_event_writer = getattr(self, "durable_event_writer", None) @@ -170,5 +172,7 @@ class DownloadHistoryOwner(_DownloadOwnerBase): context=context, download_dir=download_dir, torrent_content=torrent_content, + download_hash=download_hash, + downloader=downloader, ) self.eventmanager.send_event(EventType.DownloadAdded, event_payload) diff --git a/app/chain/download/processing.py b/app/chain/download/processing.py index 850151d0f..eb0fcfce4 100644 --- a/app/chain/download/processing.py +++ b/app/chain/download/processing.py @@ -63,9 +63,14 @@ class DownloadProcessingOwner(_DownloadOwnerBase): context: Context, download_dir: Path, torrent_content: Union[str, bytes], + download_hash: Optional[str] = None, + downloader: Optional[str] = None, ) -> None: """ - 后台执行下载成功后的附加处理,避免站点字幕下载阻塞添加下载响应。 + 后台执行下载成功后的附加处理,并传递下载器身份以解析实际内容路径。 + + TempPath 下载器会在完成前把内容放在不同于 save_path 的目录中; + 字幕处理需要该身份才能把字幕写入当前内容目录,随下载器迁移一起移动。 """ def _run_download_added() -> None: @@ -79,6 +84,8 @@ class DownloadProcessingOwner(_DownloadOwnerBase): context=context, download_dir=download_dir, torrent_content=torrent_content, + download_hash=download_hash, + downloader=downloader, ) except Exception as e: logger.error(f"执行下载成功后处理失败:{str(e)}") diff --git a/app/chain/download/subtitle.py b/app/chain/download/subtitle.py index 1eede99c9..bb913ed78 100644 --- a/app/chain/download/subtitle.py +++ b/app/chain/download/subtitle.py @@ -432,6 +432,53 @@ class DownloadSubtitleOwner(_DownloadOwnerBase): self.run_module("site_subtitle_links", context=context), ) + def _resolve_torrent_content_dir( + self, + *, + download_hash: Optional[str], + downloader: Optional[str], + default_storage: str, + ) -> Tuple[Optional[str], Optional[Path]]: + """ + 查询下载器当前内容路径,返回其父目录和对应存储。 + + 下载器启用 TempPath 时,任务的 content_path 在完成前可能位于 + save_path 之外;使用其父目录可以避免在最终目录预建同名目录,破坏下载器迁移。 + """ + if not download_hash: + return None, None + try: + torrents = self.list_torrents( + hashs=[download_hash], + downloader=downloader, + ) + except Exception as err: + logger.debug(f"查询下载任务实际内容路径失败:{str(err)}") + return None, None + if not torrents: + return None, None + + torrent = next( + ( + item for item in torrents + if str(getattr(item, "hash", "")) == str(download_hash) + ), + torrents[0], + ) + content_path = getattr(torrent, "content_path", None) + if not content_path: + return None, None + content_uri = FileURI.from_uri(str(content_path)) + if not content_uri.path: + return None, None + storage = content_uri.storage or default_storage + if storage == "local" and default_storage != "local": + storage = default_storage + # content_path 指向单文件时是文件本身,指向多文件种子时是根目录; + # 统一返回其父目录,保留下方按 folder_name 拼接的既有路径规则。 + content_dir = Path(content_uri.path).parent + return storage, content_dir + def _save_site_subtitle_response( self, *, @@ -508,12 +555,16 @@ class DownloadSubtitleOwner(_DownloadOwnerBase): context: Context, download_dir: Path, torrent_content: Optional[Union[str, bytes]] = None, + download_hash: Optional[str] = None, + downloader: Optional[str] = None, ) -> None: """ 添加下载任务成功后,从站点下载字幕,保存到下载目录 :param context: 上下文,包括识别信息、媒体信息、种子信息 :param download_dir: 下载目录 :param torrent_content: 种子内容,如果是种子文件,则为文件内容,否则为种子字符串 + :param download_hash: 下载器任务 Hash,用于查询实际内容路径 + :param downloader: 下载器名称 """ if not self.runtime_config.download_subtitle: return @@ -543,23 +594,20 @@ class DownloadSubtitleOwner(_DownloadOwnerBase): logger.error("下载目录路径为空,无法保存字幕") return download_dir = Path(fileURI.path) + content_storage, content_dir = self._resolve_torrent_content_dir( + download_hash=download_hash, + downloader=downloader, + default_storage=storage, + ) + if content_dir: + storage = content_storage or storage + download_dir = content_dir for _ in range(30): found = storage_chain.get_file_item(storage, download_dir / folder_name) if found: working_dir_item = found break time.sleep(1) - # 目录仍然不存在,且有文件夹名,则创建目录 - if not working_dir_item and folder_name: - parent_dir_item = storage_chain.get_folder(storage, download_dir) - if parent_dir_item: - working_dir_item = storage_chain.create_folder( - parent_dir_item, - folder_name - ) - else: - logger.error(f"下载根目录不存在,无法创建字幕文件夹:{download_dir}") - return if not working_dir_item: logger.error(f"下载目录不存在,无法保存字幕:{download_dir / folder_name}") return diff --git a/app/startup/composition/outbox.py b/app/startup/composition/outbox.py index 9a7346445..077e458f7 100644 --- a/app/startup/composition/outbox.py +++ b/app/startup/composition/outbox.py @@ -105,6 +105,8 @@ def build_outbox_handlers() -> dict[ context=snapshot.context, download_dir=snapshot.download_dir, torrent_content=snapshot.torrent_content, + download_hash=snapshot.download_hash, + downloader=snapshot.downloader, ) handlers: dict[str, Callable[[ClaimedOutboxMessage], None]] = { diff --git a/tests/test_chain_durable_events.py b/tests/test_chain_durable_events.py index 3bcd9196e..d4af49cd3 100644 --- a/tests/test_chain_durable_events.py +++ b/tests/test_chain_durable_events.py @@ -253,6 +253,8 @@ def test_durable_snapshots_are_json_and_restore_plugin_runtime_objects(): context=context, download_dir=Path("/downloads"), torrent_content=b"torrent-bytes", + download_hash="hash-1", + downloader="qb", ) restored_processing = restore_download_processing(processing_snapshot) restored_transfer = restore_transfer_result(transfer_snapshot) @@ -261,6 +263,8 @@ def test_durable_snapshots_are_json_and_restore_plugin_runtime_objects(): assert isinstance(restored_processing.context, Context) assert restored_processing.download_dir == Path("/downloads") assert restored_processing.torrent_content == b"torrent-bytes" + assert restored_processing.download_hash == "hash-1" + assert restored_processing.downloader == "qb" assert isinstance(restored_transfer["fileitem"], FileItem) assert type(restored_transfer["meta"]) is type(meta) assert isinstance(restored_transfer["mediainfo"], MediaInfo) diff --git a/tests/test_download_chain.py b/tests/test_download_chain.py index 82e209b93..43190714a 100644 --- a/tests/test_download_chain.py +++ b/tests/test_download_chain.py @@ -226,9 +226,128 @@ def test_download_single_submits_download_added_to_background(monkeypatch): context=context, download_dir=Path("/downloads"), torrent_content=b"torrent-content", + download_hash="hash123", + downloader="qb", ) +def test_download_site_subtitles_uses_downloader_content_path_without_creating_save_folder( + monkeypatch, +): + """TempPath 任务应使用下载器当前内容目录,不能在 save_path 预建同名目录。""" + accessed_paths = [] + + class _FakeTorrentHelper: + """提供固定的多文件种子目录名。""" + + def get_fileinfo_from_torrent_content(self, _content): + """返回多文件种子目录名。""" + return "Demo.Movie", [] + + class _FakeStorageChain: + """只让 TempPath 下的实际内容目录可见。""" + + def get_file_item(self, storage, path): + """记录查询并返回 TempPath 内容目录。""" + accessed_paths.append((storage, path)) + if path == Path("/downloading/Demo.Movie"): + return FileItem( + storage=storage, + type="dir", + path=path.as_posix(), + name=path.name, + ) + return None + + def create_folder(self, *_args, **_kwargs): + """若生产代码尝试预建目录则让测试失败。""" + pytest.fail("TempPath 字幕处理不应在 save_path 预建目录") + + monkeypatch.setattr(download_subtitle, "TorrentHelper", _FakeTorrentHelper) + monkeypatch.setattr(download_subtitle, "StorageChain", _FakeStorageChain) + + chain = DownloadChain.__new__(DownloadChain) + chain.runtime_config = SimpleNamespace( + download_subtitle=True, + proxy=None, + temporary_path=Path("/tmp/moviepilot-test"), + subtitle_extensions=tuple(settings.RMT_SUBEXT), + user_agent="MoviePilotTest", + ) + chain.list_torrents = MagicMock(return_value=[DownloaderTorrent( + hash="hash123", + downloader="qb", + content_path="/downloading/Demo.Movie", + )]) + chain._site_subtitle_links = MagicMock(return_value=[]) + context = Context( + torrent_info=TorrentInfo(page_url="https://example.com/torrent/1"), + ) + + chain.download_site_subtitles( + context=context, + download_dir=Path("/downloads"), + torrent_content=b"torrent-content", + download_hash="hash123", + downloader="qb", + ) + + assert accessed_paths == [("local", Path("/downloading/Demo.Movie"))] + chain.list_torrents.assert_called_once_with( + hashs=["hash123"], + downloader="qb", + ) + + +def test_download_site_subtitles_does_not_create_missing_save_folder(monkeypatch): + """找不到实际内容目录时应放弃字幕写入,不能制造迁移冲突目录。""" + class _FakeTorrentHelper: + """提供固定的多文件种子目录名。""" + + def get_fileinfo_from_torrent_content(self, _content): + """返回多文件种子目录名。""" + return "Demo.Movie", [] + + class _FakeStorageChain: + """模拟最终 save_path 下尚未出现种子目录。""" + + def get_file_item(self, _storage, _path): + """始终报告目标目录不存在。""" + return None + + def create_folder(self, *_args, **_kwargs): + """若生产代码尝试预建目录则让测试失败。""" + pytest.fail("缺失目录时不应预建 save_path 子目录") + + monkeypatch.setattr(download_subtitle, "TorrentHelper", _FakeTorrentHelper) + monkeypatch.setattr(download_subtitle, "StorageChain", _FakeStorageChain) + monkeypatch.setattr(download_subtitle.time, "sleep", lambda _seconds: None) + + chain = DownloadChain.__new__(DownloadChain) + chain.runtime_config = SimpleNamespace( + download_subtitle=True, + proxy=None, + temporary_path=Path("/tmp/moviepilot-test"), + subtitle_extensions=tuple(settings.RMT_SUBEXT), + user_agent="MoviePilotTest", + ) + chain.list_torrents = MagicMock(return_value=[]) + chain._site_subtitle_links = MagicMock() + context = Context( + torrent_info=TorrentInfo(page_url="https://example.com/torrent/1"), + ) + + chain.download_site_subtitles( + context=context, + download_dir=Path("/downloads"), + torrent_content=b"torrent-content", + download_hash="hash123", + downloader="qb", + ) + + chain._site_subtitle_links.assert_not_called() + + def test_download_single_supplements_category_before_download_event(monkeypatch): """下载事件和目录选择前应已有 TMDB 分类,同时保留原识别源身份。""" captured = {}