mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 01:16:50 +08:00
fix(search): keep large SSE searches connected (#6186)
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from typing import List, Any, Optional, AsyncIterator
|
import time
|
||||||
|
from typing import Any, AsyncIterator, Iterator, List, Optional
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Body, Request
|
from fastapi import APIRouter, Depends, Body, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
@@ -23,6 +25,12 @@ router = APIRouter()
|
|||||||
|
|
||||||
_SSE_APPEND_FLUSH_INTERVAL = 1
|
_SSE_APPEND_FLUSH_INTERVAL = 1
|
||||||
_SSE_APPEND_MAX_ITEMS = 48
|
_SSE_APPEND_MAX_ITEMS = 48
|
||||||
|
_SSE_HEARTBEAT_INTERVAL = 15
|
||||||
|
_SSE_REPLACE_MAX_ITEMS = 48
|
||||||
|
_SSE_RESPONSE_HEADERS = {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _parse_site_list(sites: Optional[str]) -> Optional[List[int]]:
|
def _parse_site_list(sites: Optional[str]) -> Optional[List[int]]:
|
||||||
@@ -180,11 +188,40 @@ def _merge_append_event(pending_event: Optional[dict], event: dict) -> dict:
|
|||||||
return merged_event
|
return merged_event
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_replace_event_batches(event: dict) -> Iterator[dict]:
|
||||||
|
"""
|
||||||
|
将超大的最终替换事件拆成有序批次,避免单个 SSE 消息承载全部完整对象。
|
||||||
|
"""
|
||||||
|
items = event.get("items")
|
||||||
|
if (
|
||||||
|
event.get("type") != "replace"
|
||||||
|
or not isinstance(items, list)
|
||||||
|
or len(items) <= _SSE_REPLACE_MAX_ITEMS
|
||||||
|
):
|
||||||
|
yield event
|
||||||
|
return
|
||||||
|
|
||||||
|
batch_count = (len(items) + _SSE_REPLACE_MAX_ITEMS - 1) // _SSE_REPLACE_MAX_ITEMS
|
||||||
|
for batch_index in range(batch_count):
|
||||||
|
start = batch_index * _SSE_REPLACE_MAX_ITEMS
|
||||||
|
batch_event = dict(event)
|
||||||
|
batch_event.update(
|
||||||
|
{
|
||||||
|
"type": "replace" if batch_index == 0 else "append",
|
||||||
|
"items": items[start:start + _SSE_REPLACE_MAX_ITEMS],
|
||||||
|
"replace_batch": True,
|
||||||
|
"batch_index": batch_index,
|
||||||
|
"batch_count": batch_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
yield batch_event
|
||||||
|
|
||||||
|
|
||||||
async def _iter_batched_search_events(
|
async def _iter_batched_search_events(
|
||||||
event_source: AsyncIterator[dict],
|
event_source: AsyncIterator[dict],
|
||||||
) -> AsyncIterator[dict]:
|
) -> AsyncIterator[dict]:
|
||||||
"""
|
"""
|
||||||
对搜索流事件做轻量批处理,避免站点结果集中返回时产生过密 SSE。
|
对搜索流事件做轻量批处理,并在上游长时间静默时发送心跳。
|
||||||
"""
|
"""
|
||||||
iterator = event_source.__aiter__()
|
iterator = event_source.__aiter__()
|
||||||
pending_append_event: Optional[dict] = None
|
pending_append_event: Optional[dict] = None
|
||||||
@@ -195,13 +232,19 @@ async def _iter_batched_search_events(
|
|||||||
if next_event_task is None:
|
if next_event_task is None:
|
||||||
next_event_task = asyncio.create_task(anext(iterator))
|
next_event_task = asyncio.create_task(anext(iterator))
|
||||||
|
|
||||||
timeout = _SSE_APPEND_FLUSH_INTERVAL if pending_append_event else None
|
timeout = (
|
||||||
|
_SSE_APPEND_FLUSH_INTERVAL
|
||||||
|
if pending_append_event
|
||||||
|
else _SSE_HEARTBEAT_INTERVAL
|
||||||
|
)
|
||||||
done, _ = await asyncio.wait({next_event_task}, timeout=timeout)
|
done, _ = await asyncio.wait({next_event_task}, timeout=timeout)
|
||||||
|
|
||||||
if not done:
|
if not done:
|
||||||
if pending_append_event:
|
if pending_append_event:
|
||||||
yield pending_append_event
|
yield pending_append_event
|
||||||
pending_append_event = None
|
pending_append_event = None
|
||||||
|
else:
|
||||||
|
yield {"type": "heartbeat"}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -227,7 +270,8 @@ async def _iter_batched_search_events(
|
|||||||
yield pending_append_event
|
yield pending_append_event
|
||||||
pending_append_event = None
|
pending_append_event = None
|
||||||
|
|
||||||
yield event
|
for batched_event in _iter_replace_event_batches(event):
|
||||||
|
yield batched_event
|
||||||
finally:
|
finally:
|
||||||
if next_event_task and not next_event_task.done():
|
if next_event_task and not next_event_task.done():
|
||||||
next_event_task.cancel()
|
next_event_task.cancel()
|
||||||
@@ -239,13 +283,29 @@ async def _iter_batched_search_events(
|
|||||||
|
|
||||||
async def _stream_search_events(request: Request, event_source: AsyncIterator[dict]):
|
async def _stream_search_events(request: Request, event_source: AsyncIterator[dict]):
|
||||||
"""
|
"""
|
||||||
输出搜索SSE事件
|
输出搜索 SSE 事件,并记录连接生命周期与传输规模。
|
||||||
"""
|
"""
|
||||||
locale = LocaleHelper.get_locale_from_request(request)
|
locale = LocaleHelper.get_locale_from_request(request)
|
||||||
|
search_id = uuid4().hex[:12]
|
||||||
|
request_path = getattr(getattr(request, "url", None), "path", "unknown")
|
||||||
|
started_at = time.monotonic()
|
||||||
|
event_count = 0
|
||||||
|
transmitted_bytes = 0
|
||||||
|
last_event_type = "none"
|
||||||
|
last_stage = "none"
|
||||||
|
termination_reason = "source_exhausted"
|
||||||
|
logger.info(f"渐进式搜索流已建立,搜索ID:{search_id},路径:{request_path}")
|
||||||
try:
|
try:
|
||||||
has_sent_final_replace = False
|
has_sent_final_replace = False
|
||||||
async for event in _iter_batched_search_events(event_source):
|
async for event in _iter_batched_search_events(event_source):
|
||||||
|
last_event_type = event.get("type") or "unknown"
|
||||||
|
last_stage = event.get("stage") or last_stage
|
||||||
if await request.is_disconnected():
|
if await request.is_disconnected():
|
||||||
|
termination_reason = "client_disconnected"
|
||||||
|
logger.warning(
|
||||||
|
f"渐进式搜索客户端已断开,搜索ID:{search_id},路径:{request_path},"
|
||||||
|
f"事件:{last_event_type},阶段:{last_stage}"
|
||||||
|
)
|
||||||
break
|
break
|
||||||
# 精确搜索会先发送 replace,再发送 done。done 再带整包 items 只会重复占用带宽和前端内存。
|
# 精确搜索会先发送 replace,再发送 done。done 再带整包 items 只会重复占用带宽和前端内存。
|
||||||
if event.get("type") == "replace" and event.get("items"):
|
if event.get("type") == "replace" and event.get("items"):
|
||||||
@@ -257,13 +317,36 @@ async def _stream_search_events(request: Request, event_source: AsyncIterator[di
|
|||||||
and event.get("items")
|
and event.get("items")
|
||||||
):
|
):
|
||||||
event = {key: value for key, value in event.items() if key != "items"}
|
event = {key: value for key, value in event.items() if key != "items"}
|
||||||
yield _sse_event(event, locale=locale)
|
payload = _sse_event(event, locale=locale)
|
||||||
|
event_count += 1
|
||||||
|
transmitted_bytes += len(payload.encode("utf-8"))
|
||||||
|
if event.get("type") == "done":
|
||||||
|
termination_reason = "completed"
|
||||||
|
yield payload
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
termination_reason = "cancelled"
|
||||||
|
logger.warning(
|
||||||
|
f"渐进式搜索流已取消,搜索ID:{search_id},路径:{request_path},"
|
||||||
|
f"事件:{last_event_type},阶段:{last_stage}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
|
termination_reason = "error"
|
||||||
logger.error(f"渐进式搜索出错:{err}", exc_info=True)
|
logger.error(f"渐进式搜索出错:{err}", exc_info=True)
|
||||||
yield _sse_event(
|
payload = _sse_event(
|
||||||
{"type": "error", "success": False, "message": str(err)},
|
{"type": "error", "success": False, "message": str(err)},
|
||||||
locale=locale,
|
locale=locale,
|
||||||
)
|
)
|
||||||
|
event_count += 1
|
||||||
|
transmitted_bytes += len(payload.encode("utf-8"))
|
||||||
|
yield payload
|
||||||
|
finally:
|
||||||
|
elapsed = time.monotonic() - started_at
|
||||||
|
logger.info(
|
||||||
|
f"渐进式搜索流结束,搜索ID:{search_id},路径:{request_path},"
|
||||||
|
f"状态:{termination_reason},事件数:{event_count},"
|
||||||
|
f"发送字节:{transmitted_bytes},耗时:{elapsed:.2f}秒"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context])
|
@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context])
|
||||||
@@ -319,6 +402,7 @@ async def search_by_id_stream(
|
|||||||
search_chain = SearchChain()
|
search_chain = SearchChain()
|
||||||
|
|
||||||
async def event_source():
|
async def event_source():
|
||||||
|
"""解析媒体身份并输出精确搜索流事件。"""
|
||||||
search_params, message = await _resolve_media_search_params(
|
search_params, message = await _resolve_media_search_params(
|
||||||
mediaid=mediaid,
|
mediaid=mediaid,
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
@@ -341,7 +425,9 @@ async def search_by_id_stream(
|
|||||||
yield event
|
yield event
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
_stream_search_events(request, event_source()), media_type="text/event-stream"
|
_stream_search_events(request, event_source()),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers=_SSE_RESPONSE_HEADERS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -401,7 +487,9 @@ async def search_by_title_stream(
|
|||||||
title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True
|
title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True
|
||||||
)
|
)
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
_stream_search_events(request, event_source), media_type="text/event-stream"
|
_stream_search_events(request, event_source),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers=_SSE_RESPONSE_HEADERS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -446,6 +534,7 @@ async def search_subtitle_by_title_stream(
|
|||||||
_iter_signed_subtitle_search_events(event_source),
|
_iter_signed_subtitle_search_events(event_source),
|
||||||
),
|
),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
|
headers=_SSE_RESPONSE_HEADERS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -558,6 +647,7 @@ async def search_subtitle_by_id_stream(
|
|||||||
_iter_signed_subtitle_search_events(event_source()),
|
_iter_signed_subtitle_search_events(event_source()),
|
||||||
),
|
),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
|
headers=_SSE_RESPONSE_HEADERS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返
|
|||||||
| GET | `/api/v1/search/last/context` | 获取上一次搜索结果及可复用搜索参数,`params.result_type` 为 `torrent` 或 `subtitle` |
|
| GET | `/api/v1/search/last/context` | 获取上一次搜索结果及可复用搜索参数,`params.result_type` 为 `torrent` 或 `subtitle` |
|
||||||
| POST | `/api/v1/search/recommend` | 获取 AI 推荐资源,请求体:`filtered_indices`、`check_only`、`force` |
|
| POST | `/api/v1/search/recommend` | 获取 AI 推荐资源,请求体:`filtered_indices`、`check_only`、`force` |
|
||||||
|
|
||||||
|
渐进式搜索在无业务事件时每 15 秒发送 `{"type":"heartbeat"}`,客户端应将其仅用于连接保活。超过 48 条的最终 `replace` 会分批发送:首批 `type=replace`,后续批次 `type=append`,所有批次均带 `replace_batch=true`、从 0 开始的 `batch_index`、`batch_count` 和最终 `total_items`;客户端必须按顺序收齐后再原子替换结果。最终 `done` 在已发送 `replace` 后不重复携带 `items`。
|
||||||
|
|
||||||
#### AniList 榜单 / 探索
|
#### AniList 榜单 / 探索
|
||||||
|
|
||||||
AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-chinese` 代理查询。代理不可用时自动回退 AniList 官方 GraphQL,并合并 `anilist-chinese` 每日数据集;媒体标题优先使用项目提供的中文标题,未提供中文标题时回退 AniList 原语言标题。
|
AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-chinese` 代理查询。代理不可用时自动回退 AniList 官方 GraphQL,并合并 `anilist-chinese` 每日数据集;媒体标题优先使用项目提供的中文标题,未提供中文标题时回退 AniList 原语言标题。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: moviepilot-api
|
name: moviepilot-api
|
||||||
version: 4
|
version: 5
|
||||||
description: >-
|
description: >-
|
||||||
Use this skill when you need to call MoviePilot REST API endpoints directly
|
Use this skill when you need to call MoviePilot REST API endpoints directly
|
||||||
with the bundled Python client. Covers MoviePilot HTTP endpoints across media
|
with the bundled Python client. Covers MoviePilot HTTP endpoints across media
|
||||||
@@ -169,6 +169,8 @@ AniList endpoints prefer the `anilist-chinese` proxy and fall back to official A
|
|||||||
| GET | `/api/v1/search/last/context` | Get latest search results with replayable params. `params.result_type` is `torrent` or `subtitle` |
|
| GET | `/api/v1/search/last/context` | Get latest search results with replayable params. `params.result_type` is `torrent` or `subtitle` |
|
||||||
| POST | `/api/v1/search/recommend` | AI recommended resources. Body: `filtered_indices`, `check_only`, `force` |
|
| POST | `/api/v1/search/recommend` | AI recommended resources. Body: `filtered_indices`, `check_only`, `force` |
|
||||||
|
|
||||||
|
Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business events; use it only to keep the connection alive. Final `replace` payloads above 48 items are batched: the first event uses `type=replace`, later events use `type=append`, and every batch includes `replace_batch=true`, zero-based `batch_index`, `batch_count`, and final `total_items`. Collect all batches in order and replace the visible result atomically. After a `replace`, the final `done` event omits duplicate `items`.
|
||||||
|
|
||||||
### Download (8 endpoints)
|
### Download (8 endpoints)
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
|||||||
"""本次修改过的内置技能必须递增版本,确保用户端同步更新。"""
|
"""本次修改过的内置技能必须递增版本,确保用户端同步更新。"""
|
||||||
expected_versions = {
|
expected_versions = {
|
||||||
"database-operation": "3",
|
"database-operation": "3",
|
||||||
"moviepilot-api": "4",
|
"moviepilot-api": "5",
|
||||||
"moviepilot-cli": "6",
|
"moviepilot-cli": "6",
|
||||||
"moviepilot-update": "3",
|
"moviepilot-update": "3",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import app.api.endpoints.search as search_endpoint
|
||||||
|
|
||||||
|
|
||||||
|
def test_large_replace_event_is_split_into_ordered_batches(monkeypatch):
|
||||||
|
"""超大最终结果应拆成首个 replace 和后续 append 批次。"""
|
||||||
|
monkeypatch.setattr(search_endpoint, "_SSE_REPLACE_MAX_ITEMS", 2)
|
||||||
|
source_event = {
|
||||||
|
"type": "replace",
|
||||||
|
"stage": "filtered",
|
||||||
|
"items": [1, 2, 3, 4, 5],
|
||||||
|
"total_items": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _collect_events():
|
||||||
|
"""通过完整批处理适配器收集拆分后的最终结果。"""
|
||||||
|
|
||||||
|
async def _source():
|
||||||
|
"""输出一个超大最终替换事件。"""
|
||||||
|
yield source_event
|
||||||
|
|
||||||
|
return [
|
||||||
|
event
|
||||||
|
async for event in search_endpoint._iter_batched_search_events(_source())
|
||||||
|
]
|
||||||
|
|
||||||
|
events = asyncio.run(_collect_events())
|
||||||
|
|
||||||
|
assert [event["type"] for event in events] == ["replace", "append", "append"]
|
||||||
|
assert [event["batch_index"] for event in events] == [0, 1, 2]
|
||||||
|
assert all(event["batch_count"] == 3 for event in events)
|
||||||
|
assert all(event["replace_batch"] for event in events)
|
||||||
|
assert [item for event in events for item in event["items"]] == source_event["items"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_small_replace_event_keeps_original_protocol(monkeypatch):
|
||||||
|
"""小型最终结果应保持单个 replace 事件,兼容现有客户端。"""
|
||||||
|
monkeypatch.setattr(search_endpoint, "_SSE_REPLACE_MAX_ITEMS", 2)
|
||||||
|
source_event = {
|
||||||
|
"type": "replace",
|
||||||
|
"items": [1, 2],
|
||||||
|
"total_items": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert list(search_endpoint._iter_replace_event_batches(source_event)) == [source_event]
|
||||||
|
|
||||||
|
|
||||||
|
def test_batched_search_events_emit_heartbeat_while_source_is_idle(monkeypatch):
|
||||||
|
"""上游长时间无业务事件时应持续输出心跳,避免连接被空闲超时关闭。"""
|
||||||
|
monkeypatch.setattr(search_endpoint, "_SSE_HEARTBEAT_INTERVAL", 0.01)
|
||||||
|
|
||||||
|
async def _read_heartbeat():
|
||||||
|
"""读取首个心跳并关闭仍在等待的上游迭代器。"""
|
||||||
|
blocker = asyncio.Event()
|
||||||
|
|
||||||
|
async def _source():
|
||||||
|
"""模拟长时间处于过滤匹配阶段的事件源。"""
|
||||||
|
await blocker.wait()
|
||||||
|
yield {"type": "done"}
|
||||||
|
|
||||||
|
events = search_endpoint._iter_batched_search_events(_source())
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(anext(events), timeout=0.5)
|
||||||
|
finally:
|
||||||
|
await events.aclose()
|
||||||
|
|
||||||
|
assert asyncio.run(_read_heartbeat()) == {"type": "heartbeat"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_stream_response_disables_proxy_buffering(monkeypatch):
|
||||||
|
"""搜索 SSE 响应应显式禁用缓存和 Nginx 代理缓冲。"""
|
||||||
|
|
||||||
|
class FakeSearchChain:
|
||||||
|
"""提供无需外部依赖的空搜索流。"""
|
||||||
|
|
||||||
|
def async_search_by_title_stream(self, **_kwargs):
|
||||||
|
"""返回立即完成的搜索流。"""
|
||||||
|
|
||||||
|
async def _source():
|
||||||
|
"""输出一个完成事件。"""
|
||||||
|
yield {"type": "done", "stage": "done", "total_items": 0}
|
||||||
|
|
||||||
|
return _source()
|
||||||
|
|
||||||
|
monkeypatch.setattr(search_endpoint, "SearchChain", FakeSearchChain)
|
||||||
|
|
||||||
|
async def _never_disconnected():
|
||||||
|
"""模拟始终在线的 SSE 客户端。"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
request = SimpleNamespace(
|
||||||
|
url=SimpleNamespace(path="/api/v1/search/title/stream"),
|
||||||
|
is_disconnected=_never_disconnected,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = asyncio.run(
|
||||||
|
search_endpoint.search_by_title_stream(request=request, keyword="Demo", _=None)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.headers["cache-control"] == "no-cache"
|
||||||
|
assert response.headers["x-accel-buffering"] == "no"
|
||||||
Reference in New Issue
Block a user