Files
MoviePilot/app/modules/bangumi/__init__.py
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

683 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Bangumi 模块,提供番剧识别、搜索、每日放送、人物和刮削接口。
"""
from dataclasses import dataclass
from typing import Any, List, Optional, Tuple, Union
from app.adapters.network.http import RequestUtils
from app.domain.context import MediaInfo
from app.domain.media import is_media_source_enabled
from app.domain.meta.metabase import MetaBase
from app.domain.projection.bangumi import resolve_media_type as resolve_bangumi_media_type
from app.domain.scraper import MediaScraperHelper
from app.modules import _ModuleBase
from app.modules._base.media import MediaAuxiliaryProviderMixin
from app.modules.bangumi.bangumi import BangumiApi
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.schemas.context import MediaImageSet
from app.schemas.context import MediaPerson as _SchemaMediaPerson
from app.schemas.types import (
MediaRecognizeType,
MediaSource,
MediaSourceSelection,
MediaType,
ModuleType,
)
@dataclass(frozen=True, slots=True)
class BangumiConfigSnapshot:
"""
一次模块配置加载周期内稳定使用的网络设置。
:param proxy: 宿主 HTTP 代理配置,None 表示不使用代理
:param proxy_enabled: 是否启用自定义 Bangumi 数据接口,默认关闭
:param api_domain: 自定义数据接口域名或 URL;空值使用官方接口
"""
proxy: Any
proxy_enabled: bool = False
api_domain: str = ""
@dataclass(frozen=True, slots=True)
class _BangumiRecognitionPlan:
"""
决定一次 Bangumi 识别使用明确身份还是标题候选。
:param media_id: 已校验的正整数条目标识,None 表示按标题搜索
:param meta: 标题解析线索,明确身份查询时可为 None
:param label: 用于识别日志的条目标识或搜索名称
"""
media_id: Optional[int]
meta: Optional[MetaBase]
label: str
def require_meta(self) -> MetaBase:
"""
读取标题搜索所需的解析线索。
:return: 计划保存的标题线索
:raises RuntimeError: 计划不包含 meta,调用方错误进入标题搜索分支
"""
if self.meta is None:
raise RuntimeError("Bangumi 标题识别计划缺少解析元数据")
return self.meta
@dataclass(frozen=True, slots=True)
class _BangumiSearchPlan:
"""
保存来源是否启用以及有效的标题搜索线索。
:param enabled: 请求来源选择是否包含 Bangumi
:param meta: 有效标题线索;来源未启用或名称缺失时为 None
"""
enabled: bool
meta: Optional[MetaBase]
class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase):
"""
提供 Bangumi 媒体识别、人物查询及刮削能力。
"""
auxiliary_media_source = MediaSource.Bangumi
CONFIG_WATCH = {
"PROXY_HOST",
"BANGUMI_PROXY_ENABLE",
"BANGUMI_API_DOMAIN",
}
bangumiapi: Optional[BangumiApi] = None
scraper: Optional[MediaScraperHelper] = None
_config: BangumiConfigSnapshot = BangumiConfigSnapshot(proxy=None)
def _require_api(self) -> BangumiApi:
"""
取得已激活的 Bangumi 客户端,避免把尚未初始化的兼容属性当作有效实例。
:return: 模块初始化时创建的客户端
:raises RuntimeError: 模块尚未初始化
"""
if self.bangumiapi is None:
raise RuntimeError("Bangumi 模块尚未初始化")
return self.bangumiapi
def _require_scraper(self) -> MediaScraperHelper:
"""
取得模块初始化时创建的刮削器。
:return: 已初始化的刮削器
:raises RuntimeError: 模块尚未初始化
"""
if self.scraper is None:
raise RuntimeError("Bangumi 刮削器尚未初始化")
return self.scraper
def init_module(self) -> None:
"""
初始化Bangumi客户端。
"""
self._config = BangumiConfigSnapshot(
proxy=get_runtime_setting('PROXY'),
proxy_enabled=bool(
get_runtime_setting('BANGUMI_PROXY_ENABLE', False)
),
api_domain=str(
get_runtime_setting('BANGUMI_API_DOMAIN', '') or ''
),
)
self.bangumiapi = BangumiApi(
base_url=self._config.api_domain if self._config.proxy_enabled else '',
)
self.scraper = MediaScraperHelper()
def stop(self) -> None:
"""
关闭Bangumi客户端。
"""
# 请求缓存键已包含 API 地址,切换代理或域名后自然使用新键;这里不清缓存,
# 以免 Redis 缓存在每次重载和关闭时都被清空。
if self.bangumiapi:
self._require_api().close()
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性。
"""
api_url = self._require_api().base_url if self.bangumiapi else "https://api.bgm.tv/"
ret = RequestUtils(proxies=self._config.proxy).get_res(api_url)
if ret and ret.status_code == 200:
return True, ""
elif ret:
return False, f"无法连接Bangumi,错误码:{ret.status_code}"
return False, "Bangumi网络连接失败"
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
"""
Bangumi模块无需独立开关。
"""
return None
@staticmethod
def get_name() -> str:
"""
获取模块名称。
"""
return "Bangumi"
@staticmethod
def get_type() -> ModuleType:
"""
获取模块类型。
"""
return ModuleType.MediaRecognize
@staticmethod
def get_subtype() -> MediaRecognizeType:
"""
获取模块子类型。
"""
return MediaRecognizeType.Bangumi
@staticmethod
def get_priority() -> int:
"""
获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效。
"""
return 3
@staticmethod
def _recognition_plan(
meta: Optional[MetaBase],
media_source: Optional[MediaSource],
media_id: Optional[str],
requested_type: Optional[MediaType],
) -> Optional[_BangumiRecognitionPlan]:
"""
统一校验来源、媒体类型和显式 Bangumi ID。
"""
if requested_type == MediaType.MUSIC or getattr(meta, "type", None) == MediaType.MUSIC:
return None
if media_source and media_source != MediaSource.Bangumi:
return None
if media_id is not None:
if media_source != MediaSource.Bangumi or not str(media_id).isdigit():
return None
normalized_id = int(media_id)
if normalized_id <= 0:
return None
return _BangumiRecognitionPlan(
media_id=normalized_id, meta=meta, label=str(normalized_id)
)
selected_source = media_source or get_runtime_setting('RECOGNIZE_SOURCE')
if not meta or selected_source != MediaSource.Bangumi:
return None
return _BangumiRecognitionPlan(media_id=None, meta=meta, label=meta.name)
@staticmethod
def _search_plan(
meta: Optional[MetaBase],
media_source: Optional[MediaSourceSelection],
) -> _BangumiSearchPlan:
"""
统一决定 Bangumi 搜索是否响应以及是否具备查询标题。
"""
enabled = is_media_source_enabled(media_source, MediaSource.Bangumi)
return _BangumiSearchPlan(
enabled=enabled,
meta=meta if enabled and meta and meta.name else None,
)
@staticmethod
def _candidate_ids(
infos: Optional[list[dict[str, Any]]],
) -> tuple[int, ...]:
"""
提取前十个可用于详情查询的 Bangumi 数字 ID。
"""
return tuple(
int(item["id"])
for item in (infos or [])[:10]
if str(item.get("id") or "").isdigit()
)
@staticmethod
def _build_recognized_media(
info: dict[str, Any],
actors: list[dict[str, Any]],
meta: Optional[MetaBase],
) -> MediaInfo:
"""
把 Bangumi 详情和演职员投影为统一媒体信息。
"""
enriched = {**info, "actors": actors}
mediainfo = MediaInfo(bangumi_info=enriched)
if meta and meta.begin_season is not None:
mediainfo.season = meta.begin_season
return mediainfo
@staticmethod
def _project_media_list(
infos: Optional[list[dict[str, Any]]],
) -> list[MediaInfo]:
"""
把 Bangumi 媒体列表投影为统一媒体信息列表。
"""
return [MediaInfo(bangumi_info=info) for info in infos or []]
@classmethod
def _project_search_results(
cls, meta: MetaBase, infos: Optional[list[dict[str, Any]]]
) -> list[MediaInfo]:
"""
按标题包含关系过滤 Bangumi 搜索结果并统一投影。
"""
query = meta.name.lower()
return cls._project_media_list(
[
info
for info in infos or []
if query in str(info.get("name")).lower()
or query in str(info.get("name_cn")).lower()
]
)
@staticmethod
def _project_people(
persons: Optional[list[dict[str, Any]]],
) -> list[_SchemaMediaPerson]:
"""
把 Bangumi 演职员列表投影为统一人物列表。
"""
return [_SchemaMediaPerson(source='bangumi', **person) for person in persons or []]
def recognize_media(
self,
meta: Optional[MetaBase] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
**kwargs: Any,
) -> Optional[MediaInfo]:
"""
按明确条目标识或标题候选识别 Bangumi 媒体。
:param meta: 标题、年份、媒体类型和可选季号线索
:param media_source: 明确来源;其他来源不处理,标题搜索缺省时使用识别来源配置
:param media_id: Bangumi 正整数标识,仅在明确选择 Bangumi 时接受
:param kwargs: mtype 指定请求媒体类型,音乐请求不处理,其他参数不使用
:return: 带配音人物的统一媒体信息;请求不适用、查询失败或无匹配时为 None
"""
plan = self._recognition_plan(
meta, media_source, media_id, kwargs.get("mtype")
)
if not plan:
return None
info = (
self.bangumi_info(bangumiid=plan.media_id)
if plan.media_id is not None
else self._match_by_meta(plan.require_meta())
)
if info:
bangumi_id = info.get("id")
if not isinstance(bangumi_id, int):
return None
actors = self._require_api().credits(bangumi_id)
mediainfo = self._build_recognized_media(info, actors, plan.meta)
logger.info(f"{plan.label} Bangumi识别结果:{mediainfo.type.value if mediainfo.type else ''} "
f"{mediainfo.title_year}")
return mediainfo
logger.info(f"{plan.label} 未匹配到Bangumi媒体信息")
return None
async def async_recognize_media(
self,
meta: Optional[MetaBase] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
**kwargs: Any,
) -> Optional[MediaInfo]:
"""
异步按明确条目标识或标题候选识别 Bangumi 媒体。
:param meta: 标题、年份、媒体类型和可选季号线索
:param media_source: 明确来源;其他来源不处理,标题搜索缺省时使用识别来源配置
:param media_id: Bangumi 正整数标识,仅在明确选择 Bangumi 时接受
:param kwargs: mtype 指定请求媒体类型,音乐请求不处理,其他参数不使用
:return: 带配音人物的统一媒体信息;请求不适用、查询失败或无匹配时为 None
"""
plan = self._recognition_plan(
meta, media_source, media_id, kwargs.get("mtype")
)
if not plan:
return None
info = (
await self.async_bangumi_info(bangumiid=plan.media_id)
if plan.media_id is not None
else await self._async_match_by_meta(plan.require_meta())
)
if info:
bangumi_id = info.get("id")
if not isinstance(bangumi_id, int):
return None
actors = await self._require_api().async_credits(bangumi_id)
mediainfo = self._build_recognized_media(info, actors, plan.meta)
logger.info(f"{plan.label} Bangumi识别结果:{mediainfo.type.value if mediainfo.type else ''} "
f"{mediainfo.title_year}")
return mediainfo
logger.info(f"{plan.label} 未匹配到Bangumi媒体信息")
return None
@staticmethod
def _matches_meta(meta: MetaBase, info: dict[str, Any]) -> bool:
"""
判断Bangumi候选项是否符合标题解析出的类型与年份。
:param meta: 标题解析元数据
:param info: Bangumi候选项详情
:return: 是否符合筛选条件
"""
if (
meta.type in {MediaType.MOVIE, MediaType.TV}
and resolve_bangumi_media_type(info) != meta.type
):
return False
release_date = info.get("date") or info.get("air_date") or ""
return not meta.year or not release_date or release_date[:4] == str(meta.year)
def _match_by_meta(self, meta: MetaBase) -> Optional[dict[str, Any]]:
"""
搜索并获取最符合标题解析结果的Bangumi详情。
:param meta: 标题解析元数据
:return: Bangumi媒体详情
"""
for media_id in self._candidate_ids(self._require_api().search(meta.name)):
info = self._require_api().detail(media_id)
if info and self._matches_meta(meta, info):
return info
return None
async def _async_match_by_meta(self, meta: MetaBase) -> Optional[dict[str, Any]]:
"""
异步搜索并获取最符合标题解析结果的Bangumi详情。
:param meta: 标题解析元数据
:return: Bangumi媒体详情
"""
infos = await self._require_api().async_search(meta.name)
for media_id in self._candidate_ids(infos):
info = await self._require_api().async_detail(media_id)
if info and self._matches_meta(meta, info):
return info
return None
def search_medias(
self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
搜索标题候选,并保留原名或中文名包含查询词的条目。
:param meta: 包含搜索名称的媒体线索
:param media_source: 请求的来源选择条件
:return: 统一媒体列表;来源未启用为 None,标题缺失或无匹配时为空列表
"""
plan = self._search_plan(meta, media_source)
if not plan.enabled:
return None
if not plan.meta:
return []
return self._project_search_results(
plan.meta, self._require_api().search(plan.meta.name)
)
async def async_search_medias(
self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
异步搜索标题候选,并保留原名或中文名包含查询词的条目。
:param meta: 包含搜索名称的媒体线索
:param media_source: 请求的来源选择条件
:return: 统一媒体列表;来源未启用为 None,标题缺失或无匹配时为空列表
"""
plan = self._search_plan(meta, media_source)
if not plan.enabled:
return None
if not plan.meta:
return []
infos = await self._require_api().async_search(plan.meta.name)
return self._project_search_results(plan.meta, infos)
def bangumi_info(self, bangumiid: int) -> Optional[dict[str, Any]]:
"""
获取原始 Bangumi 条目详情。
:param bangumiid: Bangumi 条目数字标识
:return: 原始详情;标识为空或查询失败时为 None
"""
if not bangumiid:
return None
logger.info(f"开始获取Bangumi信息:{bangumiid} ...")
return self._require_api().detail(bangumiid)
async def async_bangumi_info(self, bangumiid: int) -> Optional[dict[str, Any]]:
"""
异步获取原始 Bangumi 条目详情。
:param bangumiid: Bangumi 条目数字标识
:return: 原始详情;标识为空或查询失败时为 None
"""
if not bangumiid:
return None
logger.info(f"开始获取Bangumi信息:{bangumiid} ...")
return await self._require_api().async_detail(bangumiid)
def metadata_nfo(
self,
mediainfo: MediaInfo,
season: Optional[int] = None,
episode: Optional[int] = None,
**kwargs: Any,
) -> Optional[str]:
"""
为选择 Bangumi 刮削来源的媒体生成 NFO 内容。
:param mediainfo: 统一媒体信息,scrape_source 优先于全局刮削配置
:param season: 目标季号,省略时生成媒体级元数据
:param episode: 目标集号,省略时由季号决定元数据范围
:param kwargs: 统一刮削接口的额外参数,当前不使用
:return: 包含 UTF-8 编码声明的 XML 文本;刮削来源不是 Bangumi 或无法生成时为 None
"""
scrape_source = mediainfo.scrape_source or get_runtime_setting('SCRAP_SOURCE')
if scrape_source != "bangumi":
return None
return self._require_scraper().get_metadata_nfo(mediainfo, season=season, episode=episode)
def metadata_img(
self,
mediainfo: MediaInfo,
season: Optional[int] = None,
episode: Optional[int] = None,
) -> Optional[dict[str, Any]]:
"""
为选择 Bangumi 刮削来源的媒体生成图片清单。
:param mediainfo: 统一媒体信息,scrape_source 优先于全局刮削配置
:param season: 可选目标季号
:param episode: 可选目标集号
:return: 图片文件名与地址映射;刮削来源不匹配时为 None
"""
scrape_source = mediainfo.scrape_source or get_runtime_setting('SCRAP_SOURCE')
if scrape_source != "bangumi":
return None
return self._require_scraper().get_metadata_img(mediainfo, season=season, episode=episode)
def bangumi_calendar(self) -> Optional[List[MediaInfo]]:
"""
读取每日放送的统一媒体列表。
:return: 媒体列表;没有放送内容或请求失败时为空列表
"""
return self._project_media_list(self._require_api().calendar())
async def async_bangumi_calendar(self) -> Optional[List[MediaInfo]]:
"""
异步读取每日放送的统一媒体列表。
:return: 媒体列表;没有放送内容或请求失败时为空列表
"""
infos = await self._require_api().async_calendar()
return self._project_media_list(infos)
def bangumi_credits(self, bangumiid: int) -> List[_SchemaMediaPerson]:
"""
按 Bangumi 条目标识查询角色配音人物。
:param bangumiid: Bangumi 条目标识,不接受其他来源标识
:return: 统一人物列表;无可用结果时为空列表
"""
return self._project_people(self._require_api().credits(bangumiid))
async def async_bangumi_credits(self, bangumiid: int) -> List[_SchemaMediaPerson]:
"""
异步按 Bangumi 条目标识查询角色配音人物。
:param bangumiid: Bangumi 条目标识,不接受其他来源标识
:return: 统一人物列表;无可用结果时为空列表
"""
persons = await self._require_api().async_credits(bangumiid)
return self._project_people(persons)
def bangumi_recommend(self, bangumiid: int) -> List[MediaInfo]:
"""
查询 Bangumi 条目的关联作品。
:param bangumiid: Bangumi 条目数字标识
:return: 统一媒体列表;无关联作品或请求失败时为空列表
"""
return self._project_media_list(self._require_api().subjects(bangumiid))
async def async_bangumi_recommend(self, bangumiid: int) -> List[MediaInfo]:
"""
异步查询 Bangumi 条目的关联作品。
:param bangumiid: Bangumi 条目数字标识
:return: 统一媒体列表;无关联作品或请求失败时为空列表
"""
subjects = await self._require_api().async_subjects(bangumiid)
return self._project_media_list(subjects)
def bangumi_person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]:
"""
查询 Bangumi 人物详情并转换为统一模型。
:param person_id: Bangumi 人物数字标识
:return: 统一人物对象;查询失败或无结果时为 None
"""
personinfo = self._require_api().person_detail(person_id)
if personinfo:
return self._build_person_detail(personinfo)
return None
async def async_bangumi_person_detail(self, person_id: int) -> Optional[_SchemaMediaPerson]:
"""
异步查询 Bangumi 人物详情并转换为统一模型。
:param person_id: Bangumi 人物数字标识
:return: 统一人物对象;查询失败或无结果时为 None
"""
personinfo = await self._require_api().async_person_detail(person_id)
if personinfo:
return self._build_person_detail(personinfo)
return None
@classmethod
def _build_person_detail(cls, personinfo: dict[str, Any]) -> _SchemaMediaPerson:
"""
构造Bangumi人物详情信息。
:param personinfo: Bangumi人物详情接口返回数据
:return: 媒体人物信息
"""
images = personinfo.get("images")
image_set = MediaImageSet(**images) if isinstance(images, dict) else None
person_id = personinfo.get("id")
person_name = personinfo.get("name")
gender = personinfo.get("gender")
return _SchemaMediaPerson(
source="bangumi",
id=person_id if isinstance(person_id, (int, str)) else None,
name=str(person_name) if person_name is not None else None,
images=image_set,
biography=cls._normalize_optional_string(personinfo.get("summary")),
birthday=cls._normalize_optional_string(personinfo.get("birth_day")),
gender=gender if isinstance(gender, (int, str)) else None,
)
@staticmethod
def _normalize_optional_string(value: object) -> Optional[str]:
"""
规范化Bangumi接口中可能返回非字符串的可选文本字段。
:param value: 原始字段值
:return: 字符串字段值或None
"""
if value is None:
return None
return str(value)
def bangumi_person_credits(self, person_id: int) -> List[MediaInfo]:
"""
查询 Bangumi 人物参与的作品。
:param person_id: Bangumi 人物数字标识
:return: 统一媒体列表;无作品或请求失败时为空列表
"""
return self._project_media_list(
self._require_api().person_credits(person_id=person_id)
)
async def async_bangumi_person_credits(self, person_id: int) -> List[MediaInfo]:
"""
异步查询 Bangumi 人物参与的作品。
:param person_id: Bangumi 人物数字标识
:return: 统一媒体列表;无作品或请求失败时为空列表
"""
credits_info = await self._require_api().async_person_credits(person_id=person_id)
return self._project_media_list(credits_info)
def bangumi_discover(self, **kwargs: Any) -> Optional[List[MediaInfo]]:
"""
按筛选条件发现 Bangumi 番剧。
:param kwargs: 原样传入 API 的筛选和分页参数
:return: 统一媒体列表;无结果或请求失败时为空列表
"""
return self._project_media_list(self._require_api().discover(**kwargs))
async def async_bangumi_discover(self, **kwargs: Any) -> Optional[List[MediaInfo]]:
"""
异步按筛选条件发现 Bangumi 番剧。
:param kwargs: 原样传入 API 的筛选和分页参数
:return: 统一媒体列表;无结果或请求失败时为空列表
"""
infos = await self._require_api().async_discover(**kwargs)
return self._project_media_list(infos)
def clear_cache(self) -> None:
"""
清除缓存。
"""
logger.info(f"开始清除{self.get_name()}缓存 ...")
self._require_api().clear_cache()
logger.info(f"{self.get_name()}缓存清除完成")