mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-16 11:33:59 +08:00
feat: expand metadata sources and media server sync (#6129)
This commit is contained in:
311
app/modules/anilist/__init__.py
Normal file
311
app/modules/anilist/__init__.py
Normal file
@@ -0,0 +1,311 @@
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.helper.scraper import MediaScraperHelper
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.anilist.anilist import AniListApi
|
||||
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
|
||||
|
||||
|
||||
class AniListModule(_ModuleBase):
|
||||
"""
|
||||
AniList 动画媒体识别与刮削模块
|
||||
"""
|
||||
|
||||
CONFIG_WATCH = {"PROXY_HOST"}
|
||||
|
||||
anilist_api: AniListApi = None
|
||||
scraper: MediaScraperHelper = None
|
||||
|
||||
def init_module(self) -> None:
|
||||
"""初始化 AniList 客户端与通用刮削器"""
|
||||
self.anilist_api = AniListApi()
|
||||
self.scraper = MediaScraperHelper()
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
"""AniList 模块无需独立开关"""
|
||||
return None
|
||||
|
||||
def stop(self) -> None:
|
||||
"""关闭 AniList 模块"""
|
||||
return None
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""测试 AniList GraphQL API 连通性"""
|
||||
result = self.anilist_api.search("Cowboy Bebop", count=1)
|
||||
return (True, "") if result else (False, "AniList网络连接失败")
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
"""获取模块名称"""
|
||||
return "AniList"
|
||||
|
||||
@staticmethod
|
||||
def get_type() -> ModuleType:
|
||||
"""获取模块类型"""
|
||||
return ModuleType.MediaRecognize
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> MediaRecognizeType:
|
||||
"""获取模块子类型"""
|
||||
return MediaRecognizeType.AniList
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""获取模块优先级"""
|
||||
return 4
|
||||
|
||||
@staticmethod
|
||||
def _source_enabled(source: Optional[str]) -> bool:
|
||||
"""
|
||||
判断本次识别是否指定 AniList。
|
||||
|
||||
:param source: 请求级识别数据源
|
||||
:return: 是否启用 AniList 识别
|
||||
"""
|
||||
return (source or settings.RECOGNIZE_SOURCE) == "anilist"
|
||||
|
||||
@staticmethod
|
||||
def _media_type(info: dict) -> MediaType:
|
||||
"""
|
||||
将 AniList 发布格式转换为系统媒体类型。
|
||||
|
||||
:param info: AniList 媒体信息
|
||||
:return: 系统媒体类型
|
||||
"""
|
||||
return MediaType.MOVIE if info.get("format") == "MOVIE" else MediaType.TV
|
||||
|
||||
@classmethod
|
||||
def _matches_meta(cls, meta: MetaBase, info: dict) -> bool:
|
||||
"""
|
||||
判断 AniList 候选项是否符合标题解析出的类型与年份。
|
||||
|
||||
:param meta: 标题解析元数据
|
||||
:param info: AniList 候选项
|
||||
:return: 是否符合筛选条件
|
||||
"""
|
||||
if meta.type in {MediaType.MOVIE, MediaType.TV} and cls._media_type(info) != meta.type:
|
||||
return False
|
||||
year = info.get("startDate", {}).get("year") or info.get("seasonYear")
|
||||
return not meta.year or not year or str(year) == str(meta.year)
|
||||
|
||||
@staticmethod
|
||||
def _enrich_people(info: dict) -> dict:
|
||||
"""
|
||||
将 AniList 人物连接转换为统一媒体信息所需的演职员结构。
|
||||
|
||||
:param info: AniList 媒体详情
|
||||
:return: 补充演员和导演后的媒体详情
|
||||
"""
|
||||
enriched = dict(info)
|
||||
actors = []
|
||||
for edge in info.get("characters", {}).get("edges") or []:
|
||||
character = edge.get("node") or {}
|
||||
voice_actors = edge.get("voiceActors") or []
|
||||
actor = voice_actors[0] if voice_actors else {}
|
||||
actor_name = actor.get("name", {}).get("full")
|
||||
if not actor_name:
|
||||
continue
|
||||
actors.append(
|
||||
{
|
||||
"name": actor_name,
|
||||
"character": character.get("name", {}).get("full")
|
||||
or character.get("name", {}).get("native"),
|
||||
"avatar": {"large": actor.get("image", {}).get("large")},
|
||||
"url": actor.get("siteUrl"),
|
||||
}
|
||||
)
|
||||
enriched["actors"] = actors
|
||||
|
||||
directors = []
|
||||
for edge in info.get("staff", {}).get("edges") or []:
|
||||
role = edge.get("role") or ""
|
||||
if "Director" not in role:
|
||||
continue
|
||||
staff = edge.get("node") or {}
|
||||
directors.append(
|
||||
{
|
||||
"name": staff.get("name", {}).get("full"),
|
||||
"job": role,
|
||||
"avatar": {"large": staff.get("image", {}).get("large")},
|
||||
"url": staff.get("siteUrl"),
|
||||
}
|
||||
)
|
||||
enriched["directors"] = directors
|
||||
return enriched
|
||||
|
||||
def recognize_media(
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
anilistid: Optional[int] = None,
|
||||
source: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""
|
||||
按 AniList ID 或标题识别动画媒体信息。
|
||||
|
||||
:param meta: 标题解析元数据
|
||||
:param anilistid: AniList 媒体 ID
|
||||
:param source: 请求级识别数据源
|
||||
:return: 统一媒体信息
|
||||
"""
|
||||
if not anilistid and (not meta or not self._source_enabled(source)):
|
||||
return None
|
||||
info = self.anilist_api.detail(anilistid) if anilistid else self._match_by_meta(meta)
|
||||
if not info:
|
||||
return None
|
||||
mediainfo = MediaInfo(anilist_info=self._enrich_people(info))
|
||||
if meta and meta.begin_season is not None:
|
||||
mediainfo.season = meta.begin_season
|
||||
logger.info(
|
||||
f"{anilistid or meta.name} AniList识别结果:{mediainfo.type.value} "
|
||||
f"{mediainfo.title_year}"
|
||||
)
|
||||
return mediainfo
|
||||
|
||||
async def async_recognize_media(
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
anilistid: Optional[int] = None,
|
||||
source: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""
|
||||
异步按 AniList ID 或标题识别动画媒体信息。
|
||||
|
||||
:param meta: 标题解析元数据
|
||||
:param anilistid: AniList 媒体 ID
|
||||
:param source: 请求级识别数据源
|
||||
:return: 统一媒体信息
|
||||
"""
|
||||
if not anilistid and (not meta or not self._source_enabled(source)):
|
||||
return None
|
||||
info = (
|
||||
await self.anilist_api.async_detail(anilistid)
|
||||
if anilistid
|
||||
else await self._async_match_by_meta(meta)
|
||||
)
|
||||
if not info:
|
||||
return None
|
||||
mediainfo = MediaInfo(anilist_info=self._enrich_people(info))
|
||||
if meta and meta.begin_season is not None:
|
||||
mediainfo.season = meta.begin_season
|
||||
logger.info(
|
||||
f"{anilistid or meta.name} AniList识别结果:{mediainfo.type.value} "
|
||||
f"{mediainfo.title_year}"
|
||||
)
|
||||
return mediainfo
|
||||
|
||||
def _match_by_meta(self, meta: MetaBase) -> Optional[dict]:
|
||||
"""
|
||||
同步搜索并筛选最符合标题解析结果的 AniList 条目。
|
||||
|
||||
:param meta: 标题解析元数据
|
||||
:return: AniList 媒体详情
|
||||
"""
|
||||
for info in self.anilist_api.search(meta.name):
|
||||
if self._matches_meta(meta, info):
|
||||
return info
|
||||
return None
|
||||
|
||||
async def _async_match_by_meta(self, meta: MetaBase) -> Optional[dict]:
|
||||
"""
|
||||
异步搜索并筛选最符合标题解析结果的 AniList 条目。
|
||||
|
||||
:param meta: 标题解析元数据
|
||||
:return: AniList 媒体详情
|
||||
"""
|
||||
for info in await self.anilist_api.async_search(meta.name):
|
||||
if self._matches_meta(meta, info):
|
||||
return info
|
||||
return None
|
||||
|
||||
def search_medias(
|
||||
self, meta: MetaBase, source: Optional[str] = None
|
||||
) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索 AniList 动画媒体信息。
|
||||
|
||||
:param meta: 标题解析元数据
|
||||
:param source: 请求级搜索数据源
|
||||
:return: 统一媒体信息列表
|
||||
"""
|
||||
if source and source != "anilist":
|
||||
return None
|
||||
if not source and settings.SEARCH_SOURCE and "anilist" not in settings.SEARCH_SOURCE:
|
||||
return None
|
||||
if not meta or not meta.name:
|
||||
return []
|
||||
return [
|
||||
MediaInfo(anilist_info=self._enrich_people(info))
|
||||
for info in self.anilist_api.search(meta.name)
|
||||
if self._matches_meta(meta, info)
|
||||
]
|
||||
|
||||
async def async_search_medias(
|
||||
self, meta: MetaBase, source: Optional[str] = None
|
||||
) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
异步搜索 AniList 动画媒体信息。
|
||||
|
||||
:param meta: 标题解析元数据
|
||||
:param source: 请求级搜索数据源
|
||||
:return: 统一媒体信息列表
|
||||
"""
|
||||
if source and source != "anilist":
|
||||
return None
|
||||
if not source and settings.SEARCH_SOURCE and "anilist" not in settings.SEARCH_SOURCE:
|
||||
return None
|
||||
if not meta or not meta.name:
|
||||
return []
|
||||
return [
|
||||
MediaInfo(anilist_info=self._enrich_people(info))
|
||||
for info in await self.anilist_api.async_search(meta.name)
|
||||
if self._matches_meta(meta, info)
|
||||
]
|
||||
|
||||
def metadata_nfo(
|
||||
self,
|
||||
mediainfo: MediaInfo,
|
||||
season: Optional[int] = None,
|
||||
episode: Optional[int] = None,
|
||||
**kwargs,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
生成 AniList 来源的 NFO 内容。
|
||||
|
||||
:param mediainfo: 统一媒体信息
|
||||
:param season: 季号
|
||||
:param episode: 集号
|
||||
:return: NFO XML 文本
|
||||
"""
|
||||
scrape_source = mediainfo.scrape_source or settings.SCRAP_SOURCE
|
||||
if scrape_source != "anilist":
|
||||
return None
|
||||
return self.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]:
|
||||
"""
|
||||
获取 AniList 来源的刮削图片清单。
|
||||
|
||||
:param mediainfo: 统一媒体信息
|
||||
:param season: 季号
|
||||
:param episode: 集号
|
||||
:return: 图片文件名与下载地址映射
|
||||
"""
|
||||
scrape_source = mediainfo.scrape_source or settings.SCRAP_SOURCE
|
||||
if scrape_source != "anilist":
|
||||
return None
|
||||
return self.scraper.get_metadata_img(mediainfo, season=season, episode=episode)
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""清理 AniList 接口缓存"""
|
||||
self.anilist_api.clear_cache()
|
||||
185
app/modules/anilist/anilist.py
Normal file
185
app/modules/anilist/anilist.py
Normal file
@@ -0,0 +1,185 @@
|
||||
from typing import Optional
|
||||
|
||||
from app.core.cache import cached
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.utils.http import AsyncRequestUtils, RequestUtils
|
||||
|
||||
|
||||
class AniListApi:
|
||||
"""
|
||||
AniList GraphQL API 客户端
|
||||
"""
|
||||
|
||||
_base_url = "https://graphql.anilist.co"
|
||||
_media_fields = """
|
||||
id
|
||||
idMal
|
||||
title { romaji english native }
|
||||
format
|
||||
status
|
||||
description(asHtml: false)
|
||||
startDate { year month day }
|
||||
endDate { year month day }
|
||||
seasonYear
|
||||
episodes
|
||||
duration
|
||||
countryOfOrigin
|
||||
coverImage { extraLarge large }
|
||||
bannerImage
|
||||
genres
|
||||
synonyms
|
||||
averageScore
|
||||
popularity
|
||||
isAdult
|
||||
siteUrl
|
||||
studios(isMain: true) { nodes { name } }
|
||||
staff(perPage: 25, sort: [RELEVANCE]) {
|
||||
edges { role node { name { full } image { large } siteUrl } }
|
||||
}
|
||||
characters(perPage: 25, sort: [ROLE]) {
|
||||
edges {
|
||||
role
|
||||
node { name { full native } image { large } siteUrl }
|
||||
voiceActors(language: JAPANESE, sort: [RELEVANCE]) {
|
||||
name { full }
|
||||
image { large }
|
||||
siteUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
externalLinks { site url type }
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""初始化同步与异步请求客户端"""
|
||||
headers = {
|
||||
"User-Agent": settings.NORMAL_USER_AGENT,
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
self._request = RequestUtils(
|
||||
proxies=settings.PROXY,
|
||||
headers=headers,
|
||||
)
|
||||
self._async_request = AsyncRequestUtils(
|
||||
proxies=settings.PROXY,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_response(response) -> Optional[dict]:
|
||||
"""
|
||||
提取 GraphQL 响应数据并统一处理上游错误。
|
||||
|
||||
:param response: HTTP 响应对象
|
||||
:return: GraphQL data 字段
|
||||
"""
|
||||
if response is None or response.status_code != 200:
|
||||
return None
|
||||
try:
|
||||
result = response.json()
|
||||
except Exception as err:
|
||||
logger.error(f"解析 AniList 响应失败:{str(err)}")
|
||||
return None
|
||||
if result.get("errors"):
|
||||
logger.warning(f"AniList 接口返回错误:{result.get('errors')}")
|
||||
return None
|
||||
return result.get("data")
|
||||
|
||||
def _invoke(self, query: str, variables: dict) -> Optional[dict]:
|
||||
"""
|
||||
执行同步 GraphQL 请求。
|
||||
|
||||
:param query: GraphQL 查询
|
||||
:param variables: 查询变量
|
||||
:return: GraphQL data 字段
|
||||
"""
|
||||
response = self._request.post_res(
|
||||
self._base_url,
|
||||
json={"query": query, "variables": variables},
|
||||
)
|
||||
return self._extract_response(response)
|
||||
|
||||
async def _async_invoke(self, query: str, variables: dict) -> Optional[dict]:
|
||||
"""
|
||||
执行异步 GraphQL 请求。
|
||||
|
||||
:param query: GraphQL 查询
|
||||
:param variables: 查询变量
|
||||
:return: GraphQL data 字段
|
||||
"""
|
||||
response = await self._async_request.post_res(
|
||||
self._base_url,
|
||||
json={"query": query, "variables": variables},
|
||||
)
|
||||
return self._extract_response(response)
|
||||
|
||||
@cached(maxsize=settings.CONF.anilist, ttl=settings.CONF.meta, shared_key="get")
|
||||
def detail(self, anilist_id: int) -> Optional[dict]:
|
||||
"""
|
||||
根据 AniList ID 获取动画详情。
|
||||
|
||||
:param anilist_id: AniList 媒体 ID
|
||||
:return: AniList 媒体详情
|
||||
"""
|
||||
query = f"query ($id: Int!) {{ Media(id: $id, type: ANIME) {{ {self._media_fields} }} }}"
|
||||
result = self._invoke(query, {"id": anilist_id})
|
||||
return result.get("Media") if result else None
|
||||
|
||||
@cached(maxsize=settings.CONF.anilist, ttl=settings.CONF.meta, shared_key="get")
|
||||
async def async_detail(self, anilist_id: int) -> Optional[dict]:
|
||||
"""
|
||||
异步根据 AniList ID 获取动画详情。
|
||||
|
||||
:param anilist_id: AniList 媒体 ID
|
||||
:return: AniList 媒体详情
|
||||
"""
|
||||
query = f"query ($id: Int!) {{ Media(id: $id, type: ANIME) {{ {self._media_fields} }} }}"
|
||||
result = await self._async_invoke(query, {"id": anilist_id})
|
||||
return result.get("Media") if result else None
|
||||
|
||||
@cached(maxsize=settings.CONF.anilist, ttl=settings.CONF.meta, shared_key="get")
|
||||
def search(self, name: str, count: int = 20) -> list[dict]:
|
||||
"""
|
||||
按标题搜索 AniList 动画。
|
||||
|
||||
:param name: 动画标题
|
||||
:param count: 返回条数
|
||||
:return: AniList 媒体列表
|
||||
"""
|
||||
query = f"""
|
||||
query ($search: String!, $count: Int!) {{
|
||||
Page(page: 1, perPage: $count) {{
|
||||
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {{ {self._media_fields} }}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
result = self._invoke(query, {"search": name, "count": count})
|
||||
return result.get("Page", {}).get("media") or [] if result else []
|
||||
|
||||
@cached(maxsize=settings.CONF.anilist, ttl=settings.CONF.meta, shared_key="get")
|
||||
async def async_search(self, name: str, count: int = 20) -> list[dict]:
|
||||
"""
|
||||
异步按标题搜索 AniList 动画。
|
||||
|
||||
:param name: 动画标题
|
||||
:param count: 返回条数
|
||||
:return: AniList 媒体列表
|
||||
"""
|
||||
query = f"""
|
||||
query ($search: String!, $count: Int!) {{
|
||||
Page(page: 1, perPage: $count) {{
|
||||
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {{ {self._media_fields} }}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
result = await self._async_invoke(query, {"search": name, "count": count})
|
||||
return result.get("Page", {}).get("media") or [] if result else []
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""清理 AniList 详情与搜索缓存"""
|
||||
self.detail.cache_clear()
|
||||
self.async_detail.cache_clear()
|
||||
self.search.cache_clear()
|
||||
self.async_search.cache_clear()
|
||||
@@ -4,10 +4,11 @@ from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.helper.scraper import MediaScraperHelper
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.bangumi.bangumi import BangumiApi
|
||||
from app.schemas.types import ModuleType, MediaRecognizeType
|
||||
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
@@ -18,12 +19,14 @@ class BangumiModule(_ModuleBase):
|
||||
CONFIG_WATCH = {"PROXY_HOST"}
|
||||
|
||||
bangumiapi: BangumiApi = None
|
||||
scraper: MediaScraperHelper = None
|
||||
|
||||
def init_module(self) -> None:
|
||||
"""
|
||||
初始化Bangumi客户端
|
||||
"""
|
||||
self.bangumiapi = BangumiApi()
|
||||
self.scraper = MediaScraperHelper()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
@@ -44,7 +47,8 @@ class BangumiModule(_ModuleBase):
|
||||
return False, "Bangumi网络连接失败"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
"""Bangumi模块无需独立开关"""
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
@@ -74,59 +78,133 @@ class BangumiModule(_ModuleBase):
|
||||
"""
|
||||
return 3
|
||||
|
||||
def recognize_media(self, bangumiid: int = None,
|
||||
**kwargs) -> Optional[MediaInfo]:
|
||||
def recognize_media(
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
bangumiid: int = None,
|
||||
source: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""
|
||||
识别媒体信息
|
||||
:param meta: 识别的元数据
|
||||
:param bangumiid: 识别的Bangumi ID
|
||||
:param source: 请求级识别数据源
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
if not bangumiid:
|
||||
if not bangumiid and (
|
||||
not meta or (source or settings.RECOGNIZE_SOURCE) != "bangumi"
|
||||
):
|
||||
return None
|
||||
|
||||
# 直接查询详情
|
||||
info = self.bangumi_info(bangumiid=bangumiid)
|
||||
info = (
|
||||
self.bangumi_info(bangumiid=bangumiid)
|
||||
if bangumiid
|
||||
else self._match_by_meta(meta)
|
||||
)
|
||||
if info:
|
||||
# 赋值TMDB信息并返回
|
||||
info["actors"] = self.bangumiapi.credits(info.get("id"))
|
||||
mediainfo = MediaInfo(bangumi_info=info)
|
||||
logger.info(f"{bangumiid} Bangumi识别结果:{mediainfo.type.value} "
|
||||
if meta and meta.begin_season is not None:
|
||||
mediainfo.season = meta.begin_season
|
||||
logger.info(f"{bangumiid or meta.name} Bangumi识别结果:{mediainfo.type.value} "
|
||||
f"{mediainfo.title_year}")
|
||||
return mediainfo
|
||||
else:
|
||||
logger.info(f"{bangumiid} 未匹配到Bangumi媒体信息")
|
||||
logger.info(f"{bangumiid or meta.name} 未匹配到Bangumi媒体信息")
|
||||
|
||||
return None
|
||||
|
||||
async def async_recognize_media(self, bangumiid: int = None,
|
||||
**kwargs) -> Optional[MediaInfo]:
|
||||
async def async_recognize_media(
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
bangumiid: int = None,
|
||||
source: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""
|
||||
识别媒体信息(异步版本)
|
||||
:param meta: 识别的元数据
|
||||
:param bangumiid: 识别的Bangumi ID
|
||||
:param source: 请求级识别数据源
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
if not bangumiid:
|
||||
if not bangumiid and (
|
||||
not meta or (source or settings.RECOGNIZE_SOURCE) != "bangumi"
|
||||
):
|
||||
return None
|
||||
|
||||
# 直接查询详情
|
||||
info = await self.async_bangumi_info(bangumiid=bangumiid)
|
||||
info = (
|
||||
await self.async_bangumi_info(bangumiid=bangumiid)
|
||||
if bangumiid
|
||||
else await self._async_match_by_meta(meta)
|
||||
)
|
||||
if info:
|
||||
# 赋值TMDB信息并返回
|
||||
info["actors"] = await self.bangumiapi.async_credits(info.get("id"))
|
||||
mediainfo = MediaInfo(bangumi_info=info)
|
||||
logger.info(f"{bangumiid} Bangumi识别结果:{mediainfo.type.value} "
|
||||
if meta and meta.begin_season is not None:
|
||||
mediainfo.season = meta.begin_season
|
||||
logger.info(f"{bangumiid or meta.name} Bangumi识别结果:{mediainfo.type.value} "
|
||||
f"{mediainfo.title_year}")
|
||||
return mediainfo
|
||||
else:
|
||||
logger.info(f"{bangumiid} 未匹配到Bangumi媒体信息")
|
||||
logger.info(f"{bangumiid or meta.name} 未匹配到Bangumi媒体信息")
|
||||
|
||||
return None
|
||||
|
||||
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
|
||||
@staticmethod
|
||||
def _matches_meta(meta: MetaBase, info: dict) -> bool:
|
||||
"""
|
||||
判断Bangumi候选项是否符合标题解析出的类型与年份。
|
||||
|
||||
:param meta: 标题解析元数据
|
||||
:param info: Bangumi候选项详情
|
||||
:return: 是否符合筛选条件
|
||||
"""
|
||||
if (
|
||||
meta.type in {MediaType.MOVIE, MediaType.TV}
|
||||
and MediaInfo.get_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]:
|
||||
"""
|
||||
搜索并获取最符合标题解析结果的Bangumi详情。
|
||||
|
||||
:param meta: 标题解析元数据
|
||||
:return: Bangumi媒体详情
|
||||
"""
|
||||
for item in (self.bangumiapi.search(meta.name) or [])[:10]:
|
||||
info = self.bangumiapi.detail(item.get("id")) if item.get("id") else None
|
||||
if info and self._matches_meta(meta, info):
|
||||
return info
|
||||
return None
|
||||
|
||||
async def _async_match_by_meta(self, meta: MetaBase) -> Optional[dict]:
|
||||
"""
|
||||
异步搜索并获取最符合标题解析结果的Bangumi详情。
|
||||
|
||||
:param meta: 标题解析元数据
|
||||
:return: Bangumi媒体详情
|
||||
"""
|
||||
for item in (await self.bangumiapi.async_search(meta.name) or [])[:10]:
|
||||
info = await self.bangumiapi.async_detail(item.get("id")) if item.get("id") else None
|
||||
if info and self._matches_meta(meta, info):
|
||||
return info
|
||||
return None
|
||||
|
||||
def search_medias(
|
||||
self, meta: MetaBase, source: Optional[str] = None
|
||||
) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息
|
||||
:param meta: 识别的元数据
|
||||
:reutrn: 媒体信息
|
||||
:param source: 请求级搜索数据源
|
||||
:return: 媒体信息
|
||||
"""
|
||||
if settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE:
|
||||
if source and source != "bangumi":
|
||||
return None
|
||||
if not source and settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE:
|
||||
return None
|
||||
if not meta.name:
|
||||
return []
|
||||
@@ -137,13 +215,18 @@ class BangumiModule(_ModuleBase):
|
||||
or meta.name.lower() in str(info.get("name_cn")).lower()]
|
||||
return []
|
||||
|
||||
async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
|
||||
async def async_search_medias(
|
||||
self, meta: MetaBase, source: Optional[str] = None
|
||||
) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息(异步版本)
|
||||
:param meta: 识别的元数据
|
||||
:reutrn: 媒体信息
|
||||
:param source: 请求级搜索数据源
|
||||
:return: 媒体信息
|
||||
"""
|
||||
if settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE:
|
||||
if source and source != "bangumi":
|
||||
return None
|
||||
if not source and settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE:
|
||||
return None
|
||||
if not meta.name:
|
||||
return []
|
||||
@@ -176,6 +259,45 @@ class BangumiModule(_ModuleBase):
|
||||
logger.info(f"开始获取Bangumi信息:{bangumiid} ...")
|
||||
return await self.bangumiapi.async_detail(bangumiid)
|
||||
|
||||
def metadata_nfo(
|
||||
self,
|
||||
mediainfo: MediaInfo,
|
||||
season: Optional[int] = None,
|
||||
episode: Optional[int] = None,
|
||||
**kwargs,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
生成Bangumi来源的NFO内容。
|
||||
|
||||
:param mediainfo: 统一媒体信息
|
||||
:param season: 季号
|
||||
:param episode: 集号
|
||||
:return: NFO XML文本
|
||||
"""
|
||||
scrape_source = mediainfo.scrape_source or settings.SCRAP_SOURCE
|
||||
if scrape_source != "bangumi":
|
||||
return None
|
||||
return self.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]:
|
||||
"""
|
||||
获取Bangumi来源的刮削图片清单。
|
||||
|
||||
:param mediainfo: 统一媒体信息
|
||||
:param season: 季号
|
||||
:param episode: 集号
|
||||
:return: 图片文件名与下载地址映射
|
||||
"""
|
||||
scrape_source = mediainfo.scrape_source or settings.SCRAP_SOURCE
|
||||
if scrape_source != "bangumi":
|
||||
return None
|
||||
return self.scraper.get_metadata_img(mediainfo, season=season, episode=episode)
|
||||
|
||||
def bangumi_calendar(self) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取Bangumi每日放送
|
||||
@@ -319,7 +441,7 @@ class BangumiModule(_ModuleBase):
|
||||
return [MediaInfo(bangumi_info=info) for info in infos]
|
||||
return []
|
||||
|
||||
def clear_cache(self):
|
||||
def clear_cache(self) -> None:
|
||||
"""
|
||||
清除缓存
|
||||
"""
|
||||
|
||||
@@ -127,8 +127,11 @@ class DoubanModule(_ModuleBase):
|
||||
if not doubanid and not meta:
|
||||
return None
|
||||
|
||||
if meta and not doubanid \
|
||||
and settings.RECOGNIZE_SOURCE != "douban":
|
||||
if (
|
||||
meta
|
||||
and not doubanid
|
||||
and (kwargs.get("source") or settings.RECOGNIZE_SOURCE) != "douban"
|
||||
):
|
||||
return None
|
||||
|
||||
if not meta:
|
||||
@@ -227,8 +230,11 @@ class DoubanModule(_ModuleBase):
|
||||
if not doubanid and not meta:
|
||||
return None
|
||||
|
||||
if meta and not doubanid \
|
||||
and settings.RECOGNIZE_SOURCE != "douban":
|
||||
if (
|
||||
meta
|
||||
and not doubanid
|
||||
and (kwargs.get("source") or settings.RECOGNIZE_SOURCE) != "douban"
|
||||
):
|
||||
return None
|
||||
|
||||
if not meta:
|
||||
@@ -927,13 +933,18 @@ class DoubanModule(_ModuleBase):
|
||||
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
|
||||
return []
|
||||
|
||||
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
|
||||
def search_medias(
|
||||
self, meta: MetaBase, source: Optional[str] = None
|
||||
) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息
|
||||
:param meta: 识别的元数据
|
||||
:reutrn: 媒体信息
|
||||
:param source: 请求级搜索数据源
|
||||
:return: 媒体信息
|
||||
"""
|
||||
if settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
|
||||
if source and source != "douban":
|
||||
return None
|
||||
if not source and settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
|
||||
return None
|
||||
if not meta.name:
|
||||
return []
|
||||
@@ -943,13 +954,18 @@ class DoubanModule(_ModuleBase):
|
||||
# 返回数据
|
||||
return self._build_search_medias_result(meta, result.get("items"))
|
||||
|
||||
async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
|
||||
async def async_search_medias(
|
||||
self, meta: MetaBase, source: Optional[str] = None
|
||||
) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息(异步版本)
|
||||
:param meta: 识别的元数据
|
||||
:reutrn: 媒体信息
|
||||
:param source: 请求级搜索数据源
|
||||
:return: 媒体信息
|
||||
"""
|
||||
if settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
|
||||
if source and source != "douban":
|
||||
return None
|
||||
if not source and settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
|
||||
return None
|
||||
if not meta.name:
|
||||
return []
|
||||
@@ -1147,7 +1163,7 @@ class DoubanModule(_ModuleBase):
|
||||
:param mediainfo: 媒体信息
|
||||
:param season: 季号
|
||||
"""
|
||||
if settings.SCRAP_SOURCE != "douban":
|
||||
if (mediainfo.scrape_source or settings.SCRAP_SOURCE) != "douban":
|
||||
return None
|
||||
return self.scraper.get_metadata_nfo(mediainfo=mediainfo, season=season)
|
||||
|
||||
@@ -1158,7 +1174,7 @@ class DoubanModule(_ModuleBase):
|
||||
:param season: 季号
|
||||
:param episode: 集号
|
||||
"""
|
||||
if settings.SCRAP_SOURCE != "douban":
|
||||
if (mediainfo.scrape_source or settings.SCRAP_SOURCE) != "douban":
|
||||
return None
|
||||
return self.scraper.get_metadata_img(mediainfo=mediainfo, season=season, episode=episode)
|
||||
|
||||
@@ -1169,7 +1185,7 @@ class DoubanModule(_ModuleBase):
|
||||
:param mediainfo: 媒体信息
|
||||
:return: None 表示不处理,MediaInfo 表示继续处理
|
||||
"""
|
||||
if settings.RECOGNIZE_SOURCE != "douban":
|
||||
if mediainfo.source != "douban" and settings.RECOGNIZE_SOURCE != "douban":
|
||||
return None
|
||||
if not mediainfo.douban_id:
|
||||
return None
|
||||
|
||||
@@ -92,14 +92,23 @@ class TheMovieDbModule(_ModuleBase):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _validate_recognize_params(meta: MetaBase, tmdbid: Optional[int]) -> bool:
|
||||
def _validate_recognize_params(
|
||||
meta: MetaBase,
|
||||
tmdbid: Optional[int],
|
||||
source: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
验证识别参数
|
||||
|
||||
:param meta: 标题解析元数据
|
||||
:param tmdbid: TMDB ID
|
||||
:param source: 请求级识别数据源
|
||||
:return: 参数是否可用于TMDB识别
|
||||
"""
|
||||
if not tmdbid and not meta:
|
||||
return False
|
||||
|
||||
if meta and not tmdbid and settings.RECOGNIZE_SOURCE != "themoviedb":
|
||||
if meta and not tmdbid and (source or settings.RECOGNIZE_SOURCE) != "themoviedb":
|
||||
return False
|
||||
|
||||
if meta and not meta.name and not tmdbid:
|
||||
@@ -467,7 +476,7 @@ class TheMovieDbModule(_ModuleBase):
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
# 验证参数
|
||||
if not self._validate_recognize_params(meta, tmdbid):
|
||||
if not self._validate_recognize_params(meta, tmdbid, kwargs.get("source")):
|
||||
return None
|
||||
|
||||
if not meta:
|
||||
@@ -553,7 +562,7 @@ class TheMovieDbModule(_ModuleBase):
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
# 验证参数
|
||||
if not self._validate_recognize_params(meta, tmdbid):
|
||||
if not self._validate_recognize_params(meta, tmdbid, kwargs.get("source")):
|
||||
return None
|
||||
|
||||
if not meta:
|
||||
@@ -726,13 +735,18 @@ class TheMovieDbModule(_ModuleBase):
|
||||
MediaType.TV.value: list(self.category.tv_categorys)
|
||||
}
|
||||
|
||||
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
|
||||
def search_medias(
|
||||
self, meta: MetaBase, source: Optional[str] = None
|
||||
) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息
|
||||
:param meta: 识别的元数据
|
||||
:reutrn: 媒体信息列表
|
||||
:param source: 请求级搜索数据源
|
||||
:return: 媒体信息列表
|
||||
"""
|
||||
if settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE:
|
||||
if source and source != "themoviedb":
|
||||
return None
|
||||
if not source and settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE:
|
||||
return None
|
||||
if not meta.name:
|
||||
return []
|
||||
@@ -822,7 +836,7 @@ class TheMovieDbModule(_ModuleBase):
|
||||
:param season: 季号
|
||||
:param episode: 集号
|
||||
"""
|
||||
if settings.SCRAP_SOURCE != "themoviedb":
|
||||
if (mediainfo.scrape_source or settings.SCRAP_SOURCE) != "themoviedb":
|
||||
return None
|
||||
return self.scraper.get_metadata_nfo(meta=meta, mediainfo=mediainfo, season=season, episode=episode)
|
||||
|
||||
@@ -834,7 +848,7 @@ class TheMovieDbModule(_ModuleBase):
|
||||
:param season: 季号
|
||||
:param episode: 集号
|
||||
"""
|
||||
if settings.SCRAP_SOURCE != "themoviedb":
|
||||
if (mediainfo.scrape_source or settings.SCRAP_SOURCE) != "themoviedb":
|
||||
return None
|
||||
return self.scraper.get_metadata_img(mediainfo=mediainfo, season=season, episode=episode)
|
||||
|
||||
@@ -955,7 +969,7 @@ class TheMovieDbModule(_ModuleBase):
|
||||
:param mediainfo: 媒体信息
|
||||
:return: None 表示不处理,MediaInfo 表示继续处理
|
||||
"""
|
||||
if settings.RECOGNIZE_SOURCE != "themoviedb":
|
||||
if mediainfo.source != "themoviedb" and settings.RECOGNIZE_SOURCE != "themoviedb":
|
||||
return None
|
||||
if not mediainfo.tmdb_id:
|
||||
return mediainfo
|
||||
@@ -1181,13 +1195,18 @@ class TheMovieDbModule(_ModuleBase):
|
||||
return []
|
||||
|
||||
# 异步方法
|
||||
async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
|
||||
async def async_search_medias(
|
||||
self, meta: MetaBase, source: Optional[str] = None
|
||||
) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息(异步版本)
|
||||
:param meta: 识别的元数据
|
||||
:reutrn: 媒体信息列表
|
||||
:param source: 请求级搜索数据源
|
||||
:return: 媒体信息列表
|
||||
"""
|
||||
if settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE:
|
||||
if source and source != "themoviedb":
|
||||
return None
|
||||
if not source and settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE:
|
||||
return None
|
||||
if not meta.name:
|
||||
return []
|
||||
|
||||
Reference in New Issue
Block a user