mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +08:00
fix(download): avoid subtitle save-path collision with temp downloads
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]] = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
Reference in New Issue
Block a user