fix(download): avoid subtitle save-path collision with temp downloads

This commit is contained in:
jxxghp
2026-09-05 07:40:40 +08:00
parent 1f3d2b7fdf
commit 22f7ec30b9
7 changed files with 211 additions and 13 deletions
+15 -1
View File
@@ -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,
)
+4
View File
@@ -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)
+8 -1
View File
@@ -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)}")
+59 -11
View File
@@ -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
+2
View File
@@ -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]] = {
+4
View File
@@ -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)
+119
View File
@@ -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 = {}