mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 08:26:53 +08:00
fix: offload log zip generation (#5948)
This commit is contained in:
+23
-12
@@ -10,6 +10,7 @@ from typing import Any, Optional, Union, Annotated
|
|||||||
from urllib.parse import urljoin, urlparse
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
|
import anyio
|
||||||
import pillow_avif # noqa 用于自动注册AVIF支持
|
import pillow_avif # noqa 用于自动注册AVIF支持
|
||||||
from anyio import Path as AsyncPath
|
from anyio import Path as AsyncPath
|
||||||
from app.helper.sites import SitesHelper # noqa # noqa
|
from app.helper.sites import SitesHelper # noqa # noqa
|
||||||
@@ -444,35 +445,45 @@ async def _build_log_zip_response(name: str) -> StreamingResponse:
|
|||||||
`name` 到固定目录的映射约束。zip 内使用日志根目录相对路径,便于区分
|
`name` 到固定目录的映射约束。zip 内使用日志根目录相对路径,便于区分
|
||||||
主程序日志与插件日志。
|
主程序日志与插件日志。
|
||||||
"""
|
"""
|
||||||
|
zip_data, zip_stem = await anyio.to_thread.run_sync(_build_log_zip_data, name)
|
||||||
|
headers = {
|
||||||
|
"Content-Disposition": f'attachment; filename="{zip_stem}.zip"'
|
||||||
|
}
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([zip_data]),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_log_zip_data(name: str) -> tuple[bytes, str]:
|
||||||
|
"""
|
||||||
|
同步生成日志 zip 内容和文件名前缀。
|
||||||
|
|
||||||
|
日志收集、路径解析、文件读取和压缩都属于可能阻塞的本地 I/O;调用方需要
|
||||||
|
将本函数放到 worker thread 中执行,避免日志下载占用 ASGI 事件循环。
|
||||||
|
"""
|
||||||
log_files = _collect_named_log_files(name)
|
log_files = _collect_named_log_files(name)
|
||||||
if not log_files:
|
if not log_files:
|
||||||
raise HTTPException(status_code=404, detail="Not Found")
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
|
|
||||||
log_root = settings.LOG_PATH
|
log_root = settings.LOG_PATH
|
||||||
async_log_root = AsyncPath(log_root)
|
|
||||||
zip_buffer = io.BytesIO()
|
zip_buffer = io.BytesIO()
|
||||||
filename_time = datetime.now().strftime("%Y%m%d-%H%M%S")
|
filename_time = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
safe_name = (name or "logs").strip().lower() or "logs"
|
safe_name = (name or "logs").strip().lower() or "logs"
|
||||||
zip_stem = f"{safe_name}-logs-{filename_time}"
|
zip_stem = f"{safe_name}-logs-{filename_time}"
|
||||||
with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
|
with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||||
for log_file in log_files:
|
for log_file in log_files:
|
||||||
if not await SecurityUtils.async_is_safe_path(
|
if not SecurityUtils.is_safe_path(
|
||||||
base_path=async_log_root,
|
base_path=log_root,
|
||||||
user_path=AsyncPath(log_file),
|
user_path=log_file,
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=404, detail="Not Found")
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
arcname = f"{zip_stem}/{log_file.name}"
|
arcname = f"{zip_stem}/{log_file.name}"
|
||||||
archive.write(log_file, arcname)
|
archive.write(log_file, arcname)
|
||||||
|
|
||||||
zip_buffer.seek(0)
|
zip_buffer.seek(0)
|
||||||
headers = {
|
return zip_buffer.getvalue(), zip_stem
|
||||||
"Content-Disposition": f'attachment; filename="{zip_stem}.zip"'
|
|
||||||
}
|
|
||||||
return StreamingResponse(
|
|
||||||
iter([zip_buffer.getvalue()]),
|
|
||||||
media_type="application/zip",
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_nettest_url(url: str) -> Optional[str]:
|
def _validate_nettest_url(url: str) -> Optional[str]:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
|
import threading
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -111,6 +112,34 @@ def test_download_plugin_logs_packages_plugin_files_only(isolated_log_path):
|
|||||||
assert "moviepilot.log" not in names
|
assert "moviepilot.log" not in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_log_zip_generation_runs_outside_event_loop_thread(monkeypatch, isolated_log_path):
|
||||||
|
"""日志压缩 I/O 必须离开事件循环线程执行,避免大日志下载阻塞其他请求。"""
|
||||||
|
(isolated_log_path / "moviepilot.log").write_text("current", encoding="utf-8")
|
||||||
|
event_loop_thread = threading.current_thread().name
|
||||||
|
write_threads = []
|
||||||
|
original_write = zipfile.ZipFile.write
|
||||||
|
|
||||||
|
def capture_write_thread(self, filename, arcname=None, compress_type=None, compresslevel=None):
|
||||||
|
"""记录实际 zip 写入线程,并保持原始 ZipFile.write 行为。"""
|
||||||
|
write_threads.append(threading.current_thread().name)
|
||||||
|
return original_write(
|
||||||
|
self,
|
||||||
|
filename,
|
||||||
|
arcname=arcname,
|
||||||
|
compress_type=compress_type,
|
||||||
|
compresslevel=compresslevel,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(zipfile.ZipFile, "write", capture_write_thread)
|
||||||
|
|
||||||
|
response = asyncio.run(system_endpoint.download_logging(name="moviepilot", _=SimpleNamespace()))
|
||||||
|
body = asyncio.run(_read_streaming_body(response))
|
||||||
|
|
||||||
|
assert body
|
||||||
|
assert write_threads
|
||||||
|
assert all(thread_name != event_loop_thread for thread_name in write_threads)
|
||||||
|
|
||||||
|
|
||||||
async def _read_streaming_body(response) -> bytes:
|
async def _read_streaming_body(response) -> bytes:
|
||||||
"""读取 StreamingResponse 内容,便于断言 zip 文件条目。"""
|
"""读取 StreamingResponse 内容,便于断言 zip 文件条目。"""
|
||||||
return b"".join([chunk async for chunk in response.body_iterator])
|
return b"".join([chunk async for chunk in response.body_iterator])
|
||||||
|
|||||||
Reference in New Issue
Block a user