feat: add exact subtitle search

This commit is contained in:
jxxghp
2026-06-09 17:04:17 +08:00
parent e3c5a94c52
commit 210aac0937
8 changed files with 903 additions and 7 deletions

View File

@@ -29,6 +29,15 @@ def _parse_site_list(sites: Optional[str]) -> Optional[List[int]]:
return [int(site) for site in sites.split(",") if site] if sites else None
def _parse_media_type(mtype: Optional[str]) -> Optional[MediaType]:
"""
解析媒体类型,兼容前端和 Agent 使用的 movie/tv 取值。
"""
if not mtype:
return None
return MediaType.from_agent(mtype) or MediaType(mtype)
def _sse_event(data: dict) -> str:
"""
转换为SSE事件
@@ -180,7 +189,7 @@ async def search_by_id_stream(
根据TMDBID/豆瓣ID渐进式搜索站点资源返回格式为SSE
"""
media_type = MediaType(mtype) if mtype else None
media_type = _parse_media_type(mtype)
media_season = int(season) if season else None
site_list = _parse_site_list(sites)
media_chain = MediaChain()
@@ -386,10 +395,7 @@ async def search_by_id(
"""
根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/bangumi:
"""
if mtype:
media_type = MediaType(mtype)
else:
media_type = None
media_type = _parse_media_type(mtype)
if season:
media_season = int(season)
else:
@@ -643,6 +649,193 @@ async def search_subtitle_by_title(
)
async def _build_subtitle_search_source(
mediaid: str,
mtype: Optional[str] = None,
title: Optional[str] = None,
year: Optional[str] = None,
season: Optional[str] = None,
episode: Optional[str] = None,
sites: Optional[str] = None,
stream: bool = False,
) -> Any:
"""
根据媒体ID构建字幕精确搜索调用兼容多种媒体ID来源。
"""
media_type = _parse_media_type(mtype)
media_season = int(season) if season else None
media_episode = int(episode) if episode else None
site_list = _parse_site_list(sites)
media_chain = MediaChain()
search_chain = SearchChain()
def call_search(**kwargs):
"""
根据调用模式返回普通搜索协程或流式搜索迭代器。
"""
params = {
**kwargs,
"mtype": media_type,
"season": media_season,
"episode": media_episode,
"sites": site_list,
"cache_local": True,
}
if stream:
return search_chain.async_search_subtitles_by_id_stream(**params)
return search_chain.async_search_subtitles_by_id(**params)
if mediaid.startswith("tmdb:"):
tmdbid = int(mediaid.replace("tmdb:", ""))
if settings.RECOGNIZE_SOURCE == "douban":
doubaninfo = await media_chain.async_get_doubaninfo_by_tmdbid(
tmdbid=tmdbid, mtype=media_type
)
if not doubaninfo:
return None, "未识别到豆瓣媒体信息"
return call_search(doubanid=doubaninfo.get("id")), ""
return call_search(tmdbid=tmdbid), ""
if mediaid.startswith("douban:"):
doubanid = mediaid.replace("douban:", "")
if settings.RECOGNIZE_SOURCE == "themoviedb":
tmdbinfo = await media_chain.async_get_tmdbinfo_by_doubanid(
doubanid=doubanid, mtype=media_type
)
if not tmdbinfo:
return None, "未识别到TMDB媒体信息"
if tmdbinfo.get("season") and not media_season:
media_season = tmdbinfo.get("season")
return call_search(tmdbid=tmdbinfo.get("id")), ""
return call_search(doubanid=doubanid), ""
if mediaid.startswith("bangumi:"):
bangumiid = int(mediaid.replace("bangumi:", ""))
if settings.RECOGNIZE_SOURCE == "themoviedb":
tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid(
bangumiid=bangumiid
)
if not tmdbinfo:
return None, "未识别到TMDB媒体信息"
return call_search(tmdbid=tmdbinfo.get("id")), ""
doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid(
bangumiid=bangumiid
)
if not doubaninfo:
return None, "未识别到豆瓣媒体信息"
return call_search(doubanid=doubaninfo.get("id")), ""
event_data = MediaRecognizeConvertEventData(
mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE
)
event = await eventmanager.async_send_event(
ChainEventType.MediaRecognizeConvert, event_data
)
if event and event.event_data and event.event_data.media_dict:
event_data = event.event_data
search_id = event_data.media_dict.get("id")
if event_data.convert_type == "themoviedb":
return call_search(tmdbid=search_id), ""
if event_data.convert_type == "douban":
return call_search(doubanid=search_id), ""
if not title:
return None, "未知的媒体ID"
meta = MetaInfo(title)
if year:
meta.year = year
if media_type:
meta.type = media_type
if media_season:
meta.type = MediaType.TV
meta.begin_season = media_season
mediainfo = await media_chain.async_recognize_by_meta(
meta,
obtain_images=False,
)
if not mediainfo:
return None, "未识别到媒体信息"
if settings.RECOGNIZE_SOURCE == "themoviedb":
return call_search(tmdbid=mediainfo.tmdb_id), ""
return call_search(doubanid=mediainfo.douban_id), ""
@router.get("/subtitle/media/{mediaid}/stream", summary="渐进式精确搜索字幕")
async def search_subtitle_by_id_stream(
request: Request,
mediaid: str,
mtype: Optional[str] = None,
title: Optional[str] = None,
year: Optional[str] = None,
season: Optional[str] = None,
episode: Optional[str] = None,
sites: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_resource_token),
) -> Any:
"""
根据TMDBID/豆瓣ID渐进式精确搜索站点字幕资源返回格式为SSE。
"""
subtitles, message = await _build_subtitle_search_source(
mediaid=mediaid,
mtype=mtype,
title=title,
year=year,
season=season,
episode=episode,
sites=sites,
stream=True,
)
async def event_source():
"""
输出字幕精确搜索流事件。
"""
if not subtitles:
yield {"type": "error", "success": False, "message": message or "未搜索到任何字幕"}
return
async for event in subtitles:
yield event
return StreamingResponse(
_stream_search_events(request, event_source()), media_type="text/event-stream"
)
@router.get("/subtitle/media/{mediaid}", summary="精确搜索字幕", response_model=schemas.Response)
async def search_subtitle_by_id(
mediaid: str,
mtype: Optional[str] = None,
title: Optional[str] = None,
year: Optional[str] = None,
season: Optional[str] = None,
episode: Optional[str] = None,
sites: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
根据TMDBID/豆瓣ID精确搜索站点字幕资源。
"""
subtitles, message = await _build_subtitle_search_source(
mediaid=mediaid,
mtype=mtype,
title=title,
year=year,
season=season,
episode=episode,
sites=sites,
)
if not subtitles:
return schemas.Response(success=False, message=message or "未搜索到任何字幕")
subtitles = await subtitles
if not subtitles:
return schemas.Response(success=False, message="未搜索到任何字幕")
return schemas.Response(
success=True, data=[subtitle.to_dict() for subtitle in subtitles]
)
@router.post("/recommend", summary="AI推荐资源", response_model=schemas.Response)
async def recommend_search_results(
filtered_indices: Optional[List[int]] = Body(