diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index 5dd07d7b..076c0641 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -1,4 +1,4 @@ -from typing import Any, List, Annotated, Optional +from typing import Any, List, Annotated, Literal, Optional from fastapi import APIRouter, Depends, Body @@ -17,6 +17,7 @@ from app.schemas.types import SystemConfigKey from app.utils.security import SecurityUtils router = APIRouter() +MediaSource = Literal["themoviedb", "douban", "bangumi", "anilist"] def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]: @@ -97,6 +98,8 @@ def add( torrent_in: schemas.TorrentInfo, tmdbid: Annotated[int | None, Body()] = None, doubanid: Annotated[str | None, Body()] = None, + media_source: Annotated[MediaSource | None, Body()] = None, + media_id: Annotated[str | None, Body()] = None, downloader: Annotated[str | None, Body()] = None, # 保存路径, 支持:, 如rclone:/MP, smb:/server/share/Movies等 save_path: Annotated[str | None, Body()] = None, @@ -108,15 +111,18 @@ def add( # 元数据 metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description) # 媒体信息 - if tmdbid or doubanid: + if tmdbid or doubanid or media_id: mediainfo = MediaChain().recognize_media( meta=metainfo, + source=media_source, + mediaid=media_id, tmdbid=tmdbid, doubanid=doubanid, ) else: mediainfo = MediaChain().recognize_by_meta( metainfo, + source=media_source, obtain_images=False, ) if not mediainfo: @@ -146,6 +152,8 @@ def download_subtitle( subtitle_in: schemas.SubtitleInfo, tmdbid: Annotated[int | None, Body()] = None, doubanid: Annotated[str | None, Body()] = None, + media_source: Annotated[MediaSource | None, Body()] = None, + media_id: Annotated[str | None, Body()] = None, save_path: Annotated[str | None, Body()] = None, current_user: User = Depends(get_current_active_user), ) -> Any: @@ -160,6 +168,8 @@ def download_subtitle( success, message, saved_files = DownloadChain().download_subtitle( subtitle=subtitle_info, + media_source=media_source, + media_id=media_id, tmdbid=tmdbid, doubanid=doubanid, save_path=save_path, diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 843bea68..c816cb25 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import List, Any, Union, Annotated, Optional +from typing import Annotated, Any, List, Literal, Optional, Union from fastapi import APIRouter, Depends @@ -18,6 +18,7 @@ from app.schemas.category import CategoryConfig from app.schemas.types import ChainEventType router = APIRouter() +MediaSource = Literal["themoviedb", "douban", "bangumi", "anilist"] @router.get( @@ -27,17 +28,22 @@ async def recognize( title: str, subtitle: Optional[str] = None, custom_words: Optional[str] = None, + source: Optional[MediaSource] = None, _: schemas.TokenPayload = Depends(verify_token), ) -> Any: """ 根据标题、副标题识别媒体信息 :param custom_words: 临时识别词(每行一条规则),传入时仅在本次识别中生效,不会保存到系统配置 + :param source: 请求级识别数据源 """ # 识别媒体信息,传入临时识别词时优先于系统配置的识别词生效 metainfo = MetaInfo( title, subtitle, custom_words=custom_words.split("\n") if custom_words else None ) - mediainfo = await MediaChain().async_recognize_by_meta(metainfo) + mediainfo = await MediaChain().async_recognize_by_meta( + metainfo, + source=source, + ) if mediainfo: return Context(meta_info=metainfo, media_info=mediainfo).to_dict() return schemas.Context() @@ -53,25 +59,28 @@ async def recognize2( title: str, subtitle: Optional[str] = None, custom_words: Optional[str] = None, + source: Optional[MediaSource] = None, ) -> Any: """ 根据标题、副标题识别媒体信息 API_TOKEN认证(?token=xxx) """ # 识别媒体信息 - return await recognize(title, subtitle, custom_words) + return await recognize(title, subtitle, custom_words, source) @router.get( "/recognize_file", summary="识别媒体信息(文件)", response_model=schemas.Context ) async def recognize_file( - path: str, _: schemas.TokenPayload = Depends(verify_token) + path: str, + source: Optional[MediaSource] = None, + _: schemas.TokenPayload = Depends(verify_token), ) -> Any: """ 根据文件路径识别媒体信息 """ # 识别媒体信息 - context = await MediaChain().async_recognize_by_path(path) + context = await MediaChain().async_recognize_by_path(path, source=source) if context: return context.to_dict() return schemas.Context() @@ -83,13 +92,15 @@ async def recognize_file( response_model=schemas.Context, ) async def recognize_file2( - path: str, _: Annotated[str, Depends(verify_apitoken)] + path: str, + _: Annotated[str, Depends(verify_apitoken)], + source: Optional[MediaSource] = None, ) -> Any: """ 根据文件路径识别媒体信息 API_TOKEN认证(?token=xxx) """ # 识别媒体信息 - return await recognize_file(path) + return await recognize_file(path, source) @router.get("/search", summary="搜索媒体/人物信息", response_model=List[dict]) @@ -98,6 +109,7 @@ async def search( type: Optional[str] = "media", page: int = 1, count: int = 8, + source: Optional[MediaSource] = None, _: schemas.TokenPayload = Depends(verify_token), ) -> Any: """ @@ -114,7 +126,7 @@ async def search( media_chain = MediaChain() if type == "media": - _, medias = await media_chain.async_search(title=title) + _, medias = await media_chain.async_search(title=title, source=source) result = [media.to_dict() for media in medias] if medias else [] elif type == "collection": collections = await media_chain.async_search_collections(name=title) @@ -294,7 +306,7 @@ async def detail( _: schemas.TokenPayload = Depends(verify_token), ) -> Any: """ - 根据媒体ID查询themoviedb或豆瓣媒体信息,type_name: 电影/电视剧 + 根据带来源前缀的媒体ID查询媒体信息,type_name: 电影/电视剧 """ mtype = MediaType(type_name) mediainfo = None @@ -311,6 +323,10 @@ async def detail( mediainfo = await mediachain.async_recognize_media( bangumiid=int(mediaid[8:]), mtype=mtype ) + elif mediaid.startswith("anilist:"): + mediainfo = await mediachain.async_recognize_media( + anilistid=int(mediaid[8:]), mtype=mtype + ) else: # 广播事件解析媒体信息 event_data = MediaRecognizeConvertEventData( diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index 8f38db99..df9995a1 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -292,6 +292,14 @@ def manual_transfer( transer_item.doubanid = ( str(history.doubanid) if history.doubanid else transer_item.doubanid ) + transer_item.media_source = ( + getattr(history, "media_source", None) + or transer_item.media_source + ) + transer_item.media_id = ( + getattr(history, "media_id", None) + or transer_item.media_id + ) transer_item.season = ( int(str(history.seasons).replace("S", "")) if history.seasons @@ -409,6 +417,8 @@ def manual_transfer( target_path=target_path, tmdbid=transer_item.tmdbid, doubanid=transer_item.doubanid, + media_source=transer_item.media_source, + media_id=transer_item.media_id, mtype=mtype, season=transer_item.season, episode_group=transer_item.episode_group, @@ -491,6 +501,8 @@ def manual_transfer( target_path=target_path, tmdbid=transer_item.tmdbid, doubanid=transer_item.doubanid, + media_source=transer_item.media_source, + media_id=transer_item.media_id, mtype=mtype, season=transer_item.season, episode_group=transer_item.episode_group, diff --git a/app/chain/__init__.py b/app/chain/__init__.py index 72e16bb9..34fced50 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -464,15 +464,25 @@ class ChainBase(metaclass=ABCMeta): ) return result - def run_module(self, method: str, *args, **kwargs) -> Any: + def run_module( + self, + method: str, + *args, + system_only: bool = False, + **kwargs, + ) -> Any: """ 运行包含该方法的所有模块,然后返回结果 当kwargs包含命名参数raise_exception时,如模块方法抛出异常且raise_exception为True,则同步抛出异常 + + :param method: 模块方法名称 + :param system_only: 是否仅执行系统模块 """ result = None # 执行插件模块 - result = self.__execute_plugin_modules(method, result, *args, **kwargs) + if not system_only: + result = self.__execute_plugin_modules(method, result, *args, **kwargs) if not self.__is_valid_empty(result) and not isinstance(result, list): # 插件模块返回结果不为空且不是列表,直接返回 @@ -481,18 +491,28 @@ class ChainBase(metaclass=ABCMeta): # 执行系统模块 return self.__execute_system_modules(method, result, *args, **kwargs) - async def async_run_module(self, method: str, *args, **kwargs) -> Any: + async def async_run_module( + self, + method: str, + *args, + system_only: bool = False, + **kwargs, + ) -> Any: """ 异步运行包含该方法的所有模块,然后返回结果 当kwargs包含命名参数raise_exception时,如模块方法抛出异常且raise_exception为True,则同步抛出异常 支持异步和同步方法的混合调用 + + :param method: 模块方法名称 + :param system_only: 是否仅执行系统模块 """ result = None # 执行插件模块 - result = await self.__async_execute_plugin_modules( - method, result, *args, **kwargs - ) + if not system_only: + result = await self.__async_execute_plugin_modules( + method, result, *args, **kwargs + ) if not self.__is_valid_empty(result) and not isinstance(result, list): # 插件模块返回结果不为空且不是列表,直接返回 @@ -560,13 +580,75 @@ class ChainBase(metaclass=ABCMeta): mediainfo=mediainfo, ) + @staticmethod + def _resolve_media_source_params( + source: Optional[str] = None, + mediaid: Optional[str] = None, + tmdbid: Optional[int] = None, + doubanid: Optional[str] = None, + bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, + ) -> Tuple[Optional[str], Optional[int], Optional[str], Optional[int], Optional[int]]: + """ + 统一请求级数据源ID与兼容字段,并保证同一次识别只携带一个来源ID。 + + :param source: 数据源名称 + :param mediaid: 数据源原生ID + :param tmdbid: TMDB兼容ID + :param doubanid: 豆瓣兼容ID + :param bangumiid: Bangumi兼容ID + :param anilistid: AniList兼容ID + :return: 数据源及四种兼容ID + """ + source_aliases = { + "tmdb": "themoviedb", + "themoviedb": "themoviedb", + "douban": "douban", + "bangumi": "bangumi", + "anilist": "anilist", + } + source = source_aliases.get(str(source).casefold()) if source else None + + def to_int(value) -> Optional[int]: + """将数字ID安全转换为整数。""" + return int(value) if value is not None and str(value).isdigit() else None + + if source: + source_ids = { + "themoviedb": to_int(mediaid) if mediaid else to_int(tmdbid), + "douban": str(mediaid) if mediaid else str(doubanid) if doubanid else None, + "bangumi": to_int(mediaid) if mediaid else to_int(bangumiid), + "anilist": to_int(mediaid) if mediaid else to_int(anilistid), + } + selected_id = source_ids.get(source) + return ( + source, + selected_id if source == "themoviedb" else None, + selected_id if source == "douban" else None, + selected_id if source == "bangumi" else None, + selected_id if source == "anilist" else None, + ) + + if tmdbid: + return "themoviedb", int(tmdbid), None, None, None + if doubanid: + return "douban", None, str(doubanid), None, None + if bangumiid: + return "bangumi", None, None, int(bangumiid), None + if anilistid: + return "anilist", None, None, None, int(anilistid) + return source, None, None, None, None + def recognize_media( self, meta: MetaBase = None, mtype: Optional[MediaType] = None, + source: Optional[str] = None, + mediaid: Optional[str] = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, episode_group: Optional[str] = None, cache: bool = True, share_meta: MetaBase = None, @@ -576,37 +658,66 @@ class ChainBase(metaclass=ABCMeta): :param meta: 识别的元数据 :param share_meta: 共享识别查询/上报使用的原始元数据 :param mtype: 识别的媒体类型,与tmdbid配套 + :param source: 请求级识别数据源 + :param mediaid: 与source配套的数据源原生ID :param tmdbid: tmdbid :param doubanid: 豆瓣ID :param bangumiid: BangumiID + :param anilistid: AniList ID :param episode_group: 剧集组 :param cache: 是否使用缓存 :return: 识别的媒体信息,包括剧集信息 """ # 识别用名中含指定信息情形 + requested_source = source if not tmdbid and hasattr(meta, "tmdbid"): tmdbid = meta.tmdbid if not doubanid and hasattr(meta, "doubanid"): doubanid = meta.doubanid + if not source and hasattr(meta, "media_source"): + source = meta.media_source + if not mediaid and hasattr(meta, "media_id"): + mediaid = meta.media_id if not episode_group and hasattr(meta, "episode_group"): episode_group = meta.episode_group - # 有tmdbid时,不使用meta推断的类型(由消歧逻辑决定),也不使用其它ID + source, tmdbid, doubanid, bangumiid, anilistid = self._resolve_media_source_params( + source=source, + mediaid=mediaid, + tmdbid=tmdbid, + doubanid=doubanid, + bangumiid=bangumiid, + anilistid=anilistid, + ) + # 有tmdbid时,不使用meta推断的类型(由消歧逻辑决定) if tmdbid: - doubanid = None - bangumiid = None + source = "themoviedb" elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]: mtype = meta.type share_query_meta = share_meta or meta + system_only = bool( + requested_source + or mediaid + or anilistid + or source in {"bangumi", "anilist"} + ) + module_kwargs = { + "meta": meta, + "mtype": mtype, + "tmdbid": tmdbid, + "doubanid": doubanid, + "bangumiid": bangumiid, + "episode_group": episode_group, + "cache": cache, + } + if system_only: + module_kwargs["source"] = source + if anilistid: + module_kwargs["anilistid"] = anilistid with fresh(not cache): mediainfo = self.run_module( "recognize_media", - meta=meta, - mtype=mtype, - tmdbid=tmdbid, - doubanid=doubanid, - bangumiid=bangumiid, - episode_group=episode_group, - cache=cache, + system_only=system_only, + **module_kwargs, ) if mediainfo: if not mediainfo.recognize_cache_hit: @@ -617,7 +728,7 @@ class ChainBase(metaclass=ABCMeta): ) return mediainfo - if self._can_use_media_recognize_share( + if not source and self._can_use_media_recognize_share( share_query_meta, tmdbid, doubanid, bangumiid ): shared_cache_meta = self._snapshot_recognize_cache_meta(meta) @@ -648,9 +759,12 @@ class ChainBase(metaclass=ABCMeta): self, meta: MetaBase = None, mtype: Optional[MediaType] = None, + source: Optional[str] = None, + mediaid: Optional[str] = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, episode_group: Optional[str] = None, cache: bool = True, share_meta: MetaBase = None, @@ -660,37 +774,66 @@ class ChainBase(metaclass=ABCMeta): :param meta: 识别的元数据 :param share_meta: 共享识别查询/上报使用的原始元数据 :param mtype: 识别的媒体类型,与tmdbid配套 + :param source: 请求级识别数据源 + :param mediaid: 与source配套的数据源原生ID :param tmdbid: tmdbid :param doubanid: 豆瓣ID :param bangumiid: BangumiID + :param anilistid: AniList ID :param episode_group: 剧集组 :param cache: 是否使用缓存 :return: 识别的媒体信息,包括剧集信息 """ # 识别用名中含指定信息情形 + requested_source = source if not tmdbid and hasattr(meta, "tmdbid"): tmdbid = meta.tmdbid if not doubanid and hasattr(meta, "doubanid"): doubanid = meta.doubanid + if not source and hasattr(meta, "media_source"): + source = meta.media_source + if not mediaid and hasattr(meta, "media_id"): + mediaid = meta.media_id if not episode_group and hasattr(meta, "episode_group"): episode_group = meta.episode_group - # 有tmdbid时,不使用meta推断的类型(由消歧逻辑决定),也不使用其它ID + source, tmdbid, doubanid, bangumiid, anilistid = self._resolve_media_source_params( + source=source, + mediaid=mediaid, + tmdbid=tmdbid, + doubanid=doubanid, + bangumiid=bangumiid, + anilistid=anilistid, + ) + # 有tmdbid时,不使用meta推断的类型(由消歧逻辑决定) if tmdbid: - doubanid = None - bangumiid = None + source = "themoviedb" elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]: mtype = meta.type share_query_meta = share_meta or meta + system_only = bool( + requested_source + or mediaid + or anilistid + or source in {"bangumi", "anilist"} + ) + module_kwargs = { + "meta": meta, + "mtype": mtype, + "tmdbid": tmdbid, + "doubanid": doubanid, + "bangumiid": bangumiid, + "episode_group": episode_group, + "cache": cache, + } + if system_only: + module_kwargs["source"] = source + if anilistid: + module_kwargs["anilistid"] = anilistid async with async_fresh(not cache): mediainfo = await self.async_run_module( "async_recognize_media", - meta=meta, - mtype=mtype, - tmdbid=tmdbid, - doubanid=doubanid, - bangumiid=bangumiid, - episode_group=episode_group, - cache=cache, + system_only=system_only, + **module_kwargs, ) if mediainfo: if not mediainfo.recognize_cache_hit: @@ -701,7 +844,7 @@ class ChainBase(metaclass=ABCMeta): ) return mediainfo - if self._can_use_media_recognize_share( + if not source and self._can_use_media_recognize_share( share_query_meta, tmdbid, doubanid, bangumiid ): shared_cache_meta = self._snapshot_recognize_cache_meta(meta) @@ -984,20 +1127,40 @@ class ChainBase(metaclass=ABCMeta): """ return self.run_module("webhook_parser", body=body, form=form, args=args) - 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 source: + return self.run_module( + "search_medias", + meta=meta, + source=source, + system_only=True, + ) return self.run_module("search_medias", meta=meta) - 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 source: + return await self.async_run_module( + "async_search_medias", + meta=meta, + source=source, + system_only=True, + ) return await self.async_run_module("async_search_medias", meta=meta) def search_persons(self, name: str) -> Optional[List[MediaPerson]]: diff --git a/app/chain/download.py b/app/chain/download.py index 16824361..6aafeba6 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -324,6 +324,8 @@ class DownloadChain(ChainBase): def download_subtitle( self, subtitle: SubtitleInfo, + media_source: Optional[str] = None, + media_id: Optional[str] = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, save_path: Optional[str] = None, @@ -333,6 +335,8 @@ class DownloadChain(ChainBase): 下载字幕文件并保存到媒体对应的下载目录。 :param subtitle: 字幕搜索结果 + :param media_source: 媒体数据源 + :param media_id: 数据源原生ID :param tmdbid: TMDB ID :param doubanid: 豆瓣 ID :param save_path: 保存路径 @@ -345,6 +349,8 @@ class DownloadChain(ChainBase): metainfo = MetaInfo(title=subtitle.title, subtitle=subtitle.description) mediainfo = self.recognize_media( meta=metainfo, + source=media_source, + mediaid=media_id, tmdbid=tmdbid, doubanid=doubanid, ) diff --git a/app/chain/media.py b/app/chain/media.py index 9ff86ff3..b2acc243 100644 --- a/app/chain/media.py +++ b/app/chain/media.py @@ -592,14 +592,21 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def recognize_by_meta( self, metainfo: MetaBase, + source: Optional[str] = None, episode_group: Optional[str] = None, obtain_images: bool = False, ) -> Optional[MediaInfo]: """ 根据主副标题识别媒体信息 + + :param metainfo: 标题解析元数据 + :param source: 请求级识别数据源 + :param episode_group: 剧集组 + :param obtain_images: 是否补充图片 """ mediainfo = self._recognize_with_fallback_by_meta( metainfo=metainfo, + source=source, episode_group=episode_group, obtain_images=obtain_images, ) @@ -610,11 +617,18 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def _recognize_with_fallback_by_meta( self, metainfo: MetaBase, + source: Optional[str] = None, episode_group: Optional[str] = None, obtain_images: bool = False, ) -> Optional[MediaInfo]: """ 根据标题识别媒体信息,必要时回退到辅助识别。 + + :param metainfo: 标题解析元数据 + :param source: 请求级识别数据源 + :param episode_group: 剧集组 + :param obtain_images: 是否补充图片 + :return: 统一媒体信息 """ if not metainfo: return None @@ -622,17 +636,21 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): share_meta = deepcopy(metainfo) def native_recognize() -> Optional[MediaInfo]: + """使用请求级数据源执行原生识别。""" return self.recognize_media( meta=metainfo, + source=source, share_meta=share_meta, episode_group=episode_group, ) def plugin_recognize() -> Optional[MediaInfo]: + """执行辅助识别并保持请求级数据源约束。""" return self.recognize_help( title=title, org_meta=metainfo, share_meta=share_meta, + source=source, episode_group=episode_group, ) @@ -668,6 +686,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): title: str, org_meta: MetaBase, share_meta: MetaBase = None, + source: Optional[str] = None, episode_group: Optional[str] = None, ) -> Optional[MediaInfo]: """ @@ -676,6 +695,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param title: 标题 :param org_meta: 原始元数据 :param share_meta: 共享识别查询/上报使用的原始元数据 + :param source: 请求级识别数据源 :param episode_group: 剧集组 """ # 发送请求事件,等待结果 @@ -718,6 +738,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): # 重新识别 return self.recognize_media( meta=org_meta, + source=source, share_meta=share_meta, episode_group=episode_group, ) @@ -725,11 +746,18 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): def recognize_by_path( self, path: str, + source: Optional[str] = None, episode_group: Optional[str] = None, obtain_images: bool = False, ) -> Optional[Context]: """ 根据文件路径识别媒体信息 + + :param path: 文件路径 + :param source: 请求级识别数据源 + :param episode_group: 剧集组 + :param obtain_images: 是否补充图片 + :return: 识别上下文 """ logger.info(f"开始识别媒体信息,文件:{path} ...") file_path = Path(path) @@ -737,6 +765,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): file_meta = MetaInfoPath(file_path) mediainfo = self._recognize_with_fallback_by_meta( metainfo=file_meta, + source=source, episode_group=episode_group, obtain_images=obtain_images, ) @@ -746,11 +775,14 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): # 返回上下文 return Context(meta_info=file_meta, media_info=mediainfo) - def search(self, title: str) -> Tuple[Optional[MetaBase], List[MediaInfo]]: + def search( + self, title: str, source: Optional[str] = None + ) -> Tuple[Optional[MetaBase], List[MediaInfo]]: """ 搜索媒体/人物信息 :param title: 搜索内容 + :param source: 请求级搜索数据源 :return: 识别元数据,媒体信息列表 """ # 提取要素 @@ -772,7 +804,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): meta.year = year # 开始搜索 logger.info(f"开始搜索媒体信息:{meta.name}") - medias: Optional[List[MediaInfo]] = self.search_medias(meta=meta) + medias: Optional[List[MediaInfo]] = self.search_medias(meta=meta, source=source) if not medias: logger.warn(f"{meta.name} 没有找到对应的媒体信息!") return meta, [] @@ -1553,14 +1585,22 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): async def async_recognize_by_meta( self, metainfo: MetaBase, + source: Optional[str] = None, episode_group: Optional[str] = None, obtain_images: bool = False, ) -> Optional[MediaInfo]: """ 根据主副标题识别媒体信息(异步版本) + + :param metainfo: 标题解析元数据 + :param source: 请求级识别数据源 + :param episode_group: 剧集组 + :param obtain_images: 是否补充图片 + :return: 统一媒体信息 """ mediainfo = await self._async_recognize_with_fallback_by_meta( metainfo=metainfo, + source=source, episode_group=episode_group, obtain_images=obtain_images, ) @@ -1571,29 +1611,40 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): async def _async_recognize_with_fallback_by_meta( self, metainfo: MetaBase, + source: Optional[str] = None, episode_group: Optional[str] = None, obtain_images: bool = False, ) -> Optional[MediaInfo]: """ 异步根据标题识别媒体信息,必要时回退到辅助识别。 + + :param metainfo: 标题解析元数据 + :param source: 请求级识别数据源 + :param episode_group: 剧集组 + :param obtain_images: 是否补充图片 + :return: 统一媒体信息 """ if not metainfo: return None title = metainfo.title share_meta = deepcopy(metainfo) - async def native_recognize(): + async def native_recognize() -> Optional[MediaInfo]: + """异步使用请求级数据源执行原生识别。""" return await self.async_recognize_media( meta=metainfo, + source=source, share_meta=share_meta, episode_group=episode_group, ) - async def plugin_recognize(): + async def plugin_recognize() -> Optional[MediaInfo]: + """异步执行辅助识别并保持请求级数据源约束。""" return await self.async_recognize_help( title=title, org_meta=metainfo, share_meta=share_meta, + source=source, episode_group=episode_group, ) @@ -1618,6 +1669,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): title: str, org_meta: MetaBase, share_meta: MetaBase = None, + source: Optional[str] = None, episode_group: Optional[str] = None, ) -> Optional[MediaInfo]: """ @@ -1626,6 +1678,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param title: 标题 :param org_meta: 原始元数据 :param share_meta: 共享识别查询/上报使用的原始元数据 + :param source: 请求级识别数据源 :param episode_group: 剧集组 """ # 发送请求事件,等待结果 @@ -1668,6 +1721,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): # 重新识别 return await self.async_recognize_media( meta=org_meta, + source=source, share_meta=share_meta, episode_group=episode_group, ) @@ -1675,11 +1729,18 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): async def async_recognize_by_path( self, path: str, + source: Optional[str] = None, episode_group: Optional[str] = None, obtain_images: bool = False, ) -> Optional[Context]: """ 根据文件路径识别媒体信息(异步版本) + + :param path: 文件路径 + :param source: 请求级识别数据源 + :param episode_group: 剧集组 + :param obtain_images: 是否补充图片 + :return: 识别上下文 """ logger.info(f"开始识别媒体信息,文件:{path} ...") file_path = Path(path) @@ -1687,6 +1748,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): file_meta = MetaInfoPath(file_path) mediainfo = await self._async_recognize_with_fallback_by_meta( metainfo=file_meta, + source=source, episode_group=episode_group, obtain_images=obtain_images, ) @@ -1697,12 +1759,13 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return Context(meta_info=file_meta, media_info=mediainfo) async def async_search( - self, title: str + self, title: str, source: Optional[str] = None ) -> Tuple[Optional[MetaBase], List[MediaInfo]]: """ 搜索媒体/人物信息(异步版本) :param title: 搜索内容 + :param source: 请求级搜索数据源 :return: 识别元数据,媒体信息列表 """ # 提取要素 @@ -1724,7 +1787,9 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): meta.year = year # 开始搜索 logger.info(f"开始搜索媒体信息:{meta.name}") - medias: Optional[List[MediaInfo]] = await self.async_search_medias(meta=meta) + medias: Optional[List[MediaInfo]] = await self.async_search_medias( + meta=meta, source=source + ) if not medias: logger.warn(f"{meta.name} 没有找到对应的媒体信息!") return meta, [] diff --git a/app/chain/mediaserver.py b/app/chain/mediaserver.py index 8f3dcf7d..4585be16 100644 --- a/app/chain/mediaserver.py +++ b/app/chain/mediaserver.py @@ -238,11 +238,16 @@ class MediaServerChain(ChainBase): "mediaserver_image_cookies", server=server, image_url=image_url ) - def sync(self, progress_callback: Optional[Callable[..., None]] = None) -> None: + def sync( + self, + progress_callback: Optional[Callable[..., None]] = None, + server: Optional[str] = None, + ) -> None: """ - 同步媒体库所有数据到本地数据库 + 同步全部或指定媒体服务器的媒体库数据到本地数据库 :param progress_callback: 定时服务进度更新回调 + :param server: 指定媒体服务器名称,为空时同步全部已启用服务器 """ # 设置的媒体服务器 mediaservers = ServiceConfigHelper.get_mediaserver_configs() @@ -257,7 +262,14 @@ class MediaServerChain(ChainBase): enabled_servers = [mediaserver.name for mediaserver in mediaservers if mediaserver and mediaserver.enabled and mediaserver.name] dboper.delete_excluded_servers(enabled_servers) + if server: + mediaservers = [ + mediaserver for mediaserver in mediaservers + if mediaserver and mediaserver.enabled and mediaserver.name == server + ] total_servers = len(enabled_servers) + if server: + total_servers = len(mediaservers) if progress_callback: progress_callback( value=0, @@ -266,7 +278,13 @@ class MediaServerChain(ChainBase): ) if not total_servers: if progress_callback: - progress_callback(value=100, text="没有已启用的媒体服务器") + progress_callback( + value=100, + text=( + f"媒体服务器 {server} 未启用或不存在" + if server else "没有已启用的媒体服务器" + ), + ) return server_sync_contexts = {} diff --git a/app/chain/transfer.py b/app/chain/transfer.py index dbc4c7d0..4974c888 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -141,7 +141,19 @@ class JobManager: """ if not media: return None, season - return media.tmdb_id or media.douban_id, season + media_ids = { + "themoviedb": getattr(media, "tmdb_id", None), + "douban": getattr(media, "douban_id", None), + "bangumi": getattr(media, "bangumi_id", None), + "anilist": getattr(media, "anilist_id", None), + } + source = getattr(media, "source", None) + if not source or media_ids.get(source) is None: + source = next( + (name for name, media_id in media_ids.items() if media_id is not None), + source, + ) + return (source, media_ids.get(source)), season @staticmethod def __get_file_key(fileitem: FileItem) -> Optional[Tuple[str, str]]: @@ -1600,23 +1612,30 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): f"{task.fileitem.name} 文件年份 {task.meta.year} 与下载记录年份 " f"{download_history.year} 不一致,按文件名重新识别" ) + recognize_kwargs = {"obtain_images": True} + if task.media_source: + recognize_kwargs["source"] = task.media_source mediainfo = MediaChain().recognize_by_meta( - task.meta, - obtain_images=True, + task.meta, **recognize_kwargs ) if mediainfo and download_history.media_category: mediainfo.category = download_history.media_category else: # 识别媒体信息 + recognize_kwargs = {"obtain_images": True} + if task.media_source: + recognize_kwargs["source"] = task.media_source mediainfo = MediaChain().recognize_by_meta( - task.meta, - obtain_images=True, + task.meta, **recognize_kwargs ) # 按名称识别时已在识别链路补图,这里只补齐显式ID识别的场景。 if mediainfo and need_obtain_images: self.obtain_images(mediainfo=mediainfo) + if mediainfo and task.media_source: + mediainfo.scrape_source = task.media_source + if not mediainfo: if task.preview: return False, "未识别到媒体信息" @@ -2572,6 +2591,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): fileitem: FileItem, meta: MetaBase = None, mediainfo: MediaInfo = None, + media_source: Optional[str] = None, target_directory: TransferDirectoryConf = None, target_storage: Optional[str] = None, target_path: Path = None, @@ -2597,6 +2617,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param fileitem: 文件项 :param meta: 元数据 :param mediainfo: 媒体信息 + :param media_source: 请求级识别与刮削数据源 :param target_directory: 目标目录配置 :param target_storage: 目标存储器 :param target_path: 目标路径 @@ -3115,6 +3136,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): fileitem=file_item, meta=file_meta, mediainfo=task_mediainfo, + media_source=media_source, target_directory=target_directory, target_storage=target_storage, target_path=target_path, @@ -3474,6 +3496,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): target_path: Path = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, + media_source: Optional[str] = None, + media_id: Optional[str] = None, mtype: MediaType = None, season: Optional[int] = None, episode_group: Optional[str] = None, @@ -3498,6 +3522,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param target_path: 目标路径 :param tmdbid: TMDB ID :param doubanid: 豆瓣ID + :param media_source: 媒体数据源 + :param media_id: 数据源原生ID :param mtype: 媒体类型 :param season: 季度 :param episode_group: 剧集组 @@ -3516,21 +3542,27 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件 """ logger.info(f"手动整理:{fileitem.path} ...") - if tmdbid or doubanid: - # 有输入TMDBID时单个识别 + if tmdbid or doubanid or media_id: + # 有输入媒体ID时单个识别 # 识别媒体信息 mediainfo: MediaInfo = MediaChain().recognize_media( tmdbid=tmdbid, doubanid=doubanid, + source=media_source, + mediaid=media_id, mtype=mtype, episode_group=episode_group, ) if not mediainfo: return ( False, - f"媒体信息识别失败,tmdbid:{tmdbid},doubanid:{doubanid},type: {mtype.value if mtype else None}", + f"媒体信息识别失败,source:{media_source},media_id:{media_id}," + f"tmdbid:{tmdbid},doubanid:{doubanid}," + f"type: {mtype.value if mtype else None}", ) else: + if media_source: + mediainfo.scrape_source = media_source # 更新媒体图片 self.obtain_images(mediainfo=mediainfo) @@ -3540,6 +3572,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): target_storage=target_storage, target_path=target_path, mediainfo=mediainfo, + media_source=media_source, transfer_type=transfer_type, season=season, epformat=epformat, @@ -3567,6 +3600,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): fileitem=fileitem, target_storage=target_storage, target_path=target_path, + media_source=media_source, transfer_type=transfer_type, season=season, epformat=epformat, diff --git a/app/core/config.py b/app/core/config.py index 06f52260..7f289c1d 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -38,6 +38,8 @@ class SystemConfModel(BaseModel): douban: int = 0 # Bangumi请求缓存数量 bangumi: int = 0 + # AniList请求缓存数量 + anilist: int = 0 # Fanart请求缓存数量 fanart: int = 0 # 元数据缓存过期时间(秒) @@ -197,11 +199,11 @@ class ConfigModel(BaseModel): DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112" # ==================== 媒体元数据配置 ==================== - # 媒体搜索来源 themoviedb/douban/bangumi,多个用,分隔 + # 媒体搜索来源 themoviedb/douban/bangumi/anilist,多个用,分隔 SEARCH_SOURCE: str = "themoviedb" - # 媒体识别来源 themoviedb/douban + # 媒体识别来源 themoviedb/douban/bangumi/anilist RECOGNIZE_SOURCE: str = "themoviedb" - # 刮削来源 themoviedb/douban + # 刮削来源 themoviedb/douban/bangumi/anilist SCRAP_SOURCE: str = "themoviedb" # 电视剧动漫的分类genre_ids ANIME_GENREIDS: List[int] = Field(default=[16]) diff --git a/app/core/context.py b/app/core/context.py index 82ae3c94..e19d4271 100644 --- a/app/core/context.py +++ b/app/core/context.py @@ -10,6 +10,7 @@ from app.schemas.types import MediaType from app.utils.string import StringUtils BANGUMI_MOVIE_PLATFORMS = frozenset({"movie", "电影", "剧场版"}) +ANILIST_MOVIE_FORMATS = frozenset({"MOVIE"}) @dataclass @@ -251,8 +252,10 @@ class MediaInfo: # 内部标记:是否命中本地识别缓存,不参与序列化 recognize_cache_hit = False - # 来源:themoviedb、douban、bangumi + # 来源:themoviedb、douban、bangumi、anilist source: str = None + # 请求级刮削来源;为空时使用系统设置 + scrape_source: str = None # 类型 电影、电视剧 type: MediaType = None # 媒体标题 @@ -279,6 +282,10 @@ class MediaInfo: douban_id: str = None # Bangumi ID bangumi_id: int = None + # AniList ID + anilist_id: int = None + # AniDB ID(AniList外部映射) + anidb_id: int = None # 合集ID collection_id: int = None # 媒体原语种 @@ -315,6 +322,8 @@ class MediaInfo: douban_info: dict = field(default_factory=dict) # Bangumi INFO bangumi_info: dict = field(default_factory=dict) + # AniList INFO + anilist_info: dict = field(default_factory=dict) # 导演 directors: List[dict] = field(default_factory=list) # 演员 @@ -380,6 +389,8 @@ class MediaInfo: self.set_douban_info(self.douban_info) if self.bangumi_info: self.set_bangumi_info(self.bangumi_info) + if self.anilist_info: + self.set_anilist_info(self.anilist_info) def __setattr__(self, name: str, value: Any): self.__dict__[name] = value @@ -750,7 +761,7 @@ class MediaInfo: self.source = "bangumi" # 本体 self.bangumi_info = info - # 豆瓣ID + # Bangumi ID self.bangumi_id = info.get("id") # 类型 if not self.type: @@ -804,13 +815,166 @@ class MediaInfo: if self.type == MediaType.TV and not self.seasons: meta = MetaInfo(self.title) season = meta.begin_season if meta.begin_season is not None else 1 - episodes_count = info.get("total_episodes") + episodes_count = info.get("total_episodes") or info.get("eps") if episodes_count: self.seasons[season] = list(range(1, episodes_count + 1)) + self.number_of_episodes = episodes_count + self.number_of_seasons = 1 + # 风格 + if not self.genres: + self.genres = [ + {"id": tag.get("name"), "name": tag.get("name")} + for tag in info.get("tags") or [] + if tag.get("name") + ] + # 制作公司与导演 + if info.get("infobox"): + companies = [] + directors = [] + for item in info.get("infobox"): + values = item.get("value") + if not isinstance(values, list): + values = [values] + normalized_values = [ + value.get("v") if isinstance(value, dict) else value + for value in values + if value + ] + if item.get("key") in {"动画制作", "制作"}: + companies.extend({"name": value} for value in normalized_values) + elif item.get("key") == "导演": + directors.extend({"name": value} for value in normalized_values) + if companies and not self.production_companies: + self.production_companies = companies + if directors and not self.directors: + self.directors = directors # 演员 if not self.actors: self.actors = info.get("actors") or [] + @staticmethod + def get_anilist_media_type(info: dict) -> MediaType: + """ + 根据 AniList 发布格式获取标准媒体类型。 + + :param info: AniList 媒体信息 + :return: 标准媒体类型 + """ + return ( + MediaType.MOVIE + if str(info.get("format") or "").upper() in ANILIST_MOVIE_FORMATS + else MediaType.TV + ) + + @staticmethod + def _anilist_date(date_info: dict) -> Optional[str]: + """ + 将 AniList 模糊日期转换为标准日期文本。 + + :param date_info: AniList FuzzyDate 字段 + :return: YYYY、YYYY-MM 或 YYYY-MM-DD 日期文本 + """ + if not date_info or not date_info.get("year"): + return None + values = [str(date_info.get("year"))] + if date_info.get("month"): + values.append(str(date_info.get("month")).zfill(2)) + if date_info.get("day"): + values.append(str(date_info.get("day")).zfill(2)) + return "-".join(values) + + def set_anilist_info(self, info: dict) -> None: + """ + 初始化 AniList 媒体信息。 + + :param info: AniList 媒体详情 + """ + if not info: + return + self.source = "anilist" + self.anilist_info = info + self.anilist_id = info.get("id") + self.type = self.type or self.get_anilist_media_type(info) + + titles = info.get("title") or {} + self.title = self.title or titles.get("english") or titles.get("romaji") or titles.get("native") + self.en_title = self.en_title or titles.get("english") + self.original_title = self.original_title or titles.get("native") or titles.get("romaji") + self.names = list( + dict.fromkeys( + value + for value in [ + titles.get("english"), + titles.get("romaji"), + titles.get("native"), + *(info.get("synonyms") or []), + ] + if value and value != self.title + ) + ) + + self.release_date = self.release_date or self._anilist_date(info.get("startDate") or {}) + self.first_air_date = self.first_air_date or self.release_date + self.last_air_date = self.last_air_date or self._anilist_date(info.get("endDate") or {}) + self.year = self.year or ( + str(info.get("startDate", {}).get("year")) + if info.get("startDate", {}).get("year") + else str(info.get("seasonYear")) if info.get("seasonYear") else None + ) + + cover = info.get("coverImage") or {} + self.poster_path = self.poster_path or cover.get("extraLarge") or cover.get("large") + self.backdrop_path = self.backdrop_path or info.get("bannerImage") + self.overview = self.overview or re.sub( + r"<[^>]+>", + "", + str(info.get("description") or "").replace("
", "\n").replace("
", "\n"), + ).strip() + self.vote_average = self.vote_average or ( + round(float(info.get("averageScore")) / 10, 1) + if info.get("averageScore") is not None + else 0 + ) + self.popularity = self.popularity or info.get("popularity") + self.runtime = self.runtime or info.get("duration") + self.adult = self.adult or bool(info.get("isAdult")) + self.status = self.status or info.get("status") + self.original_language = self.original_language or ( + "ja" if info.get("countryOfOrigin") == "JP" else None + ) + self.origin_country = self.origin_country or ( + [info.get("countryOfOrigin")] if info.get("countryOfOrigin") else [] + ) + self.production_companies = self.production_companies or [ + {"name": studio.get("name")} + for studio in info.get("studios", {}).get("nodes") or [] + if studio.get("name") + ] + self.genres = self.genres or [ + {"id": genre, "name": genre} for genre in info.get("genres") or [] + ] + self.actors = self.actors or info.get("actors") or [] + self.directors = self.directors or info.get("directors") or [] + + if self.season is None: + self.season = MetaInfo(self.title).begin_season if self.title else None + episodes_count = info.get("episodes") + if self.type == MediaType.TV and episodes_count: + season = self.season if self.season is not None else 1 + self.seasons[season] = list(range(1, episodes_count + 1)) + self.number_of_episodes = episodes_count + self.number_of_seasons = 1 + if self.year: + self.season_years[season] = self.year + + for external_link in info.get("externalLinks") or []: + if str(external_link.get("site") or "").casefold() != "anidb": + continue + match = re.search(r"\d+", external_link.get("url") or "") + if match: + self.anidb_id = int(match.group()) + break + @property def title_year(self): if self.title: @@ -831,6 +995,8 @@ class MediaInfo: return "https://movie.douban.com/subject/%s" % self.douban_id elif self.bangumi_id: return "http://bgm.tv/subject/%s" % self.bangumi_id + elif self.anilist_id: + return "https://anilist.co/anime/%s" % self.anilist_id return "" @property @@ -895,6 +1061,16 @@ class MediaInfo: dicts["tmdb_info"] = None dicts["douban_info"] = None dicts["bangumi_info"] = None + dicts["anilist_info"] = None + dicts["mediaid_prefix"] = self.source + source_ids = { + "themoviedb": self.tmdb_id, + "douban": self.douban_id, + "bangumi": self.bangumi_id, + "anilist": self.anilist_id, + } + media_id = source_ids.get(self.source) + dicts["media_id"] = str(media_id) if media_id is not None else None return dicts def clear(self): @@ -904,6 +1080,7 @@ class MediaInfo: self.tmdb_info = {} self.douban_info = {} self.bangumi_info = {} + self.anilist_info = {} self.seasons = {} self.genres = [] self.season_info = [] diff --git a/app/core/meta/infopath.py b/app/core/meta/infopath.py index 39fda6cf..cf363e90 100644 --- a/app/core/meta/infopath.py +++ b/app/core/meta/infopath.py @@ -23,7 +23,7 @@ def should_use_parent_title_for_file_stem( """ if not file_meta.isfile or not stem or not parent_dir_name: return False - if file_meta.tmdbid or file_meta.doubanid: + if file_meta.tmdbid or file_meta.doubanid or file_meta.media_id: return False if not PARENT_LATIN_TITLE_RE.search(parent_dir_name): return False diff --git a/app/core/meta/metabase.py b/app/core/meta/metabase.py index 58011494..3343c7cd 100644 --- a/app/core/meta/metabase.py +++ b/app/core/meta/metabase.py @@ -97,6 +97,8 @@ class MetaBase(object): # 附加信息 tmdbid: int = None doubanid: str = None + media_source: Optional[str] = None + media_id: Optional[str] = None episode_group: Optional[str] = None # 帧率信息(纯数值) fps: Optional[int] = None @@ -683,6 +685,11 @@ class MetaBase(object): # doubanid if not self.doubanid and meta.doubanid: self.doubanid = meta.doubanid + # 通用媒体来源与ID + if not self.media_source and meta.media_source: + self.media_source = meta.media_source + if not self.media_id and meta.media_id: + self.media_id = meta.media_id # 剧集组 if not self.episode_group and meta.episode_group: self.episode_group = meta.episode_group diff --git a/app/core/metainfo.py b/app/core/metainfo.py index 8bd1c8e6..046ede82 100644 --- a/app/core/metainfo.py +++ b/app/core/metainfo.py @@ -29,6 +29,8 @@ _ANIME_SQUARE_BRACKET_RE = re.compile(r'\[[+0-9XVPI-]+]\s*\[', re.IGNORECASE) _BRACED_METAINFO_RE = re.compile(r'(?<={\[)[\W\w]+(?=]})') _BRACED_TMDBID_RE = re.compile(r'(?<=tmdbid=)\d+') _BRACED_DOUBANID_RE = re.compile(r'(?<=doubanid=)\d+') +_BRACED_BANGUMIID_RE = re.compile(r'(?<=bangumiid=)\d+') +_BRACED_ANILISTID_RE = re.compile(r'(?<=anilistid=)\d+') _BRACED_TYPE_RE = re.compile(r'(?<=type=)\w+') _BRACED_EPISODE_GROUP_RE = re.compile(r'(?:^|;)g=([0-9a-fA-F]+)(?=;|$)') _BRACED_BEGIN_SEASON_RE = re.compile(r'(?<=s=)\d+') @@ -41,6 +43,24 @@ _EMBY_TMDB_RE_LIST = ( re.compile(r'\{tmdbid[=\-](\d+)\}'), re.compile(r'\{tmdb[=\-](\d+)\}'), ) +_EXTENDED_MEDIA_ID_RE_LIST = { + "bangumi": ( + re.compile(r'\[bangumiid[=\-](\d+)\]'), + re.compile(r'\[bangumi[=\-](\d+)\]'), + re.compile(r'\{bangumiid[=\-](\d+)\}'), + re.compile(r'\{bangumi[=\-](\d+)\}'), + ), + "anilist": ( + re.compile(r'\[anilistid[=\-](\d+)\]'), + re.compile(r'\[anilist[=\-](\d+)\]'), + re.compile(r'\{anilistid[=\-](\d+)\}'), + re.compile(r'\{anilist[=\-](\d+)\}'), + ), +} +_EXTENDED_MEDIA_ID_TAG_RE = re.compile( + r'(?:bangumi(?:id)?|anilist(?:id)?)[=\-]\d+', + re.IGNORECASE, +) _RUST_PARSE_OPTIONS_CACHE_KEY = "_cache_key" @@ -51,6 +71,10 @@ def _empty_metainfo() -> dict: return { 'tmdbid': None, 'doubanid': None, + 'bangumiid': None, + 'anilistid': None, + 'media_source': None, + 'media_id': None, 'type': None, 'episode_group': None, 'begin_season': None, @@ -115,6 +139,14 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]: doubanid = _BRACED_DOUBANID_RE.search(result) if doubanid and doubanid.group(0).isdigit(): metainfo['doubanid'] = doubanid.group(0) + # 查找Bangumi ID信息 + bangumiid = _BRACED_BANGUMIID_RE.search(result) + if bangumiid and bangumiid.group(0).isdigit(): + metainfo['bangumiid'] = bangumiid.group(0) + # 查找AniList ID信息 + anilistid = _BRACED_ANILISTID_RE.search(result) + if anilistid and anilistid.group(0).isdigit(): + metainfo['anilistid'] = anilistid.group(0) # 查找媒体类型 mtype = _BRACED_TYPE_RE.search(result) if mtype: @@ -142,7 +174,18 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]: if end_episode and end_episode.group(0).isdigit(): metainfo['end_episode'] = int(end_episode.group(0)) # 去除title中该部分 - if tmdbid or mtype or episode_group or begin_season or end_season or begin_episode or end_episode: + if ( + tmdbid + or doubanid + or bangumiid + or anilistid + or mtype + or episode_group + or begin_season + or end_season + or begin_episode + or end_episode + ): title = title.replace(f"{{[{result}]}}", '') # 支持Emby格式的ID标签;第一个 [tmdbid] 历史上始终优先处理,用于覆盖前面 {[...]} 中的旧标签。 @@ -159,6 +202,31 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]: title = tmdb_re.sub('', title).strip() break + for source, patterns in _EXTENDED_MEDIA_ID_RE_LIST.items(): + key = f"{source}id" + if metainfo.get(key): + continue + for media_id_re in patterns: + media_id_match = media_id_re.search(title) + if not media_id_match: + continue + metainfo[key] = media_id_match.group(1) + title = media_id_re.sub('', title).strip() + break + + if metainfo.get('tmdbid'): + metainfo['media_source'] = 'themoviedb' + metainfo['media_id'] = metainfo['tmdbid'] + elif metainfo.get('doubanid'): + metainfo['media_source'] = 'douban' + metainfo['media_id'] = metainfo['doubanid'] + elif metainfo.get('bangumiid'): + metainfo['media_source'] = 'bangumi' + metainfo['media_id'] = metainfo['bangumiid'] + elif metainfo.get('anilistid'): + metainfo['media_source'] = 'anilist' + metainfo['media_id'] = metainfo['anilistid'] + # 计算季集总数 _apply_range_total(metainfo, 'begin_season', 'end_season', 'total_season') _apply_range_total(metainfo, 'begin_episode', 'end_episode', 'total_episode') @@ -202,6 +270,10 @@ def _build_meta_info( logger.warn("tmdbid 必须是数字") if metainfo.get('doubanid'): meta.doubanid = metainfo['doubanid'] + if metainfo.get('media_source'): + meta.media_source = metainfo['media_source'] + if metainfo.get('media_id'): + meta.media_id = str(metainfo['media_id']) if metainfo.get('type'): meta.type = MediaType(metainfo['type']) if isinstance(metainfo['type'], str) else metainfo['type'] if metainfo.get('episode_group'): @@ -319,6 +391,8 @@ def _meta_from_rust(parsed: dict) -> Optional[MetaBase]: "apply_words": parsed.get("apply_words") or [], "tmdbid": parsed.get("tmdbid"), "doubanid": parsed.get("doubanid"), + "media_source": parsed.get("media_source"), + "media_id": parsed.get("media_id"), "episode_group": parsed.get("episode_group"), "fps": parsed.get("fps"), } @@ -327,6 +401,24 @@ def _meta_from_rust(parsed: dict) -> Optional[MetaBase]: return meta +def _requires_python_metainfo( + title: str, + custom_words: Optional[List[str]] = None, +) -> bool: + """ + 判断标题或临时识别词是否包含当前Rust扩展尚未支持的数据源ID标签。 + + :param title: 原始标题 + :param custom_words: 临时识别词 + :return: 是否必须使用Python解析器 + """ + candidates = [title or "", *(custom_words or [])] + contains_extended_id = any( + _EXTENDED_MEDIA_ID_TAG_RE.search(candidate) for candidate in candidates + ) + return contains_extended_id and not rust_accel.supports_extended_media_ids() + + def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str] = None) -> MetaBase: """ 根据标题和副标题识别元数据 @@ -335,9 +427,11 @@ def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str] :param custom_words: 自定义识别词列表 :return: MetaAnime、MetaVideo """ - rust_meta = _meta_from_rust( - rust_accel.parse_metainfo(title, subtitle, _rust_parse_options(custom_words)) - ) + rust_meta = None + if not _requires_python_metainfo(title, custom_words): + rust_meta = _meta_from_rust( + rust_accel.parse_metainfo(title, subtitle, _rust_parse_options(custom_words)) + ) if rust_meta: return rust_meta meta = _build_meta_info(title=title, subtitle=subtitle, custom_words=custom_words) @@ -355,9 +449,14 @@ def MetaInfoPath(path: Path, custom_words: List[str] = None) -> MetaBase: :param path: 路径 :param custom_words: 自定义识别词列表 """ - rust_meta = _meta_from_rust( - rust_accel.parse_metainfo_path(str(path), _rust_parse_options(custom_words)) + path_context = " ".join( + [path.name, path.parent.name, path.parent.parent.name] ) + rust_meta = None + if not _requires_python_metainfo(path_context, custom_words): + rust_meta = _meta_from_rust( + rust_accel.parse_metainfo_path(str(path), _rust_parse_options(custom_words)) + ) if rust_meta: return rust_meta # 文件元数据,不包含后缀 @@ -400,7 +499,9 @@ def find_metainfo(title: str) -> Tuple[str, dict]: """ 从标题中提取媒体信息 """ - rust_result = rust_accel.find_metainfo(title) + rust_result = None + if not _requires_python_metainfo(title): + rust_result = rust_accel.find_metainfo(title) if rust_result: return rust_result["title"], rust_result["metainfo"] return _find_metainfo_python(title) diff --git a/app/db/models/transferhistory.py b/app/db/models/transferhistory.py index df83f48a..68afc543 100644 --- a/app/db/models/transferhistory.py +++ b/app/db/models/transferhistory.py @@ -48,6 +48,9 @@ class TransferHistory(Base): imdbid = Column(String) tvdbid = Column(Integer) doubanid = Column(String) + # 统一媒体数据源与原生ID + media_source = Column(String, index=True) + media_id = Column(String, index=True) # Sxx seasons = Column(String) # Exx diff --git a/app/db/transferhistory_oper.py b/app/db/transferhistory_oper.py index 287c7289..663aff96 100644 --- a/app/db/transferhistory_oper.py +++ b/app/db/transferhistory_oper.py @@ -198,6 +198,8 @@ class TransferHistoryOper(DbOper): imdbid=mediainfo.imdb_id, tvdbid=mediainfo.tvdb_id, doubanid=mediainfo.douban_id, + media_source=mediainfo.source, + media_id=mediainfo.to_dict().get("media_id"), seasons=meta.season, episodes=meta.episode, image=mediainfo.get_poster_image(), @@ -229,6 +231,8 @@ class TransferHistoryOper(DbOper): imdbid=mediainfo.imdb_id, tvdbid=mediainfo.tvdb_id, doubanid=mediainfo.douban_id, + media_source=mediainfo.source, + media_id=mediainfo.to_dict().get("media_id"), seasons=meta.season, episodes=meta.episode, image=mediainfo.get_poster_image(), @@ -243,6 +247,10 @@ class TransferHistoryOper(DbOper): his = self.add_force( title=meta.name, year=meta.year, + tmdbid=meta.tmdbid, + doubanid=meta.doubanid, + media_source=meta.media_source, + media_id=meta.media_id, src=fileitem.path, src_storage=fileitem.storage, src_fileitem=fileitem.model_dump(), diff --git a/app/helper/scraper.py b/app/helper/scraper.py new file mode 100644 index 00000000..55424f5d --- /dev/null +++ b/app/helper/scraper.py @@ -0,0 +1,179 @@ +from pathlib import Path +from typing import Optional +from urllib.parse import urlparse +from xml.dom import minidom + +from app.core.context import MediaInfo +from app.schemas.types import MediaType +from app.utils.dom import DomUtils + + +class MediaScraperHelper: + """ + 基于统一媒体信息生成通用 NFO 与图片清单,供缺少专用刮削格式的数据源复用 + """ + + @staticmethod + def _media_identity(mediainfo: MediaInfo) -> tuple[Optional[str], Optional[str]]: + """ + 获取媒体信息中的来源与来源原生 ID。 + + :param mediainfo: 统一媒体信息 + :return: 数据源名称与原生 ID + """ + source_ids = { + "themoviedb": mediainfo.tmdb_id, + "douban": mediainfo.douban_id, + "bangumi": mediainfo.bangumi_id, + "anilist": mediainfo.anilist_id, + } + media_id = source_ids.get(mediainfo.source) + return mediainfo.source, str(media_id) if media_id is not None else None + + @staticmethod + def _image_extension(url: str) -> str: + """ + 从图片 URL 中提取可用于本地文件名的扩展名。 + + :param url: 图片地址 + :return: 图片扩展名,无法确定时返回 .jpg + """ + extension = Path(urlparse(url).path).suffix.lower() + return extension if extension in {".jpg", ".jpeg", ".png", ".webp"} else ".jpg" + + @classmethod + def _append_common_nodes( + cls, + mediainfo: MediaInfo, + doc: minidom.Document, + root: minidom.Node, + ) -> None: + """ + 向 NFO 根节点写入各媒体类型共享的标准字段。 + + :param mediainfo: 统一媒体信息 + :param doc: XML 文档 + :param root: NFO 根节点 + """ + DomUtils.add_node(doc, root, "title", mediainfo.title or "") + DomUtils.add_node(doc, root, "originaltitle", mediainfo.original_title or "") + DomUtils.add_node(doc, root, "year", mediainfo.year or "") + DomUtils.add_node(doc, root, "premiered", mediainfo.release_date or "") + DomUtils.add_node(doc, root, "rating", mediainfo.vote_average or "0") + + plot = DomUtils.add_node(doc, root, "plot") + plot.appendChild(doc.createCDATASection(mediainfo.overview or "")) + outline = DomUtils.add_node(doc, root, "outline") + outline.appendChild(doc.createCDATASection(mediainfo.overview or "")) + + source, media_id = cls._media_identity(mediainfo) + if source and media_id: + unique_id = DomUtils.add_node(doc, root, "uniqueid", media_id) + unique_id.setAttribute("type", source) + unique_id.setAttribute("default", "true") + + for genre in mediainfo.genres or []: + genre_name = genre.get("name") if isinstance(genre, dict) else str(genre) + if genre_name: + DomUtils.add_node(doc, root, "genre", genre_name) + + for company in mediainfo.production_companies or []: + company_name = company.get("name") if isinstance(company, dict) else str(company) + if company_name: + DomUtils.add_node(doc, root, "studio", company_name) + + for director in mediainfo.directors or []: + director_name = director.get("name") if isinstance(director, dict) else str(director) + if director_name: + DomUtils.add_node(doc, root, "director", director_name) + + for actor in mediainfo.actors or []: + if not isinstance(actor, dict): + continue + actor_node = DomUtils.add_node(doc, root, "actor") + DomUtils.add_node(doc, actor_node, "name", actor.get("name") or "") + DomUtils.add_node( + doc, + actor_node, + "role", + actor.get("character") or actor.get("role") or "", + ) + avatar = actor.get("avatar") or actor.get("images") or {} + if isinstance(avatar, dict): + DomUtils.add_node( + doc, + actor_node, + "thumb", + avatar.get("large") or avatar.get("medium") or avatar.get("normal") or "", + ) + + @classmethod + def get_metadata_nfo( + cls, + mediainfo: MediaInfo, + season: Optional[int] = None, + episode: Optional[int] = None, + ) -> Optional[str]: + """ + 根据统一媒体信息生成电影、剧集、季或单集 NFO。 + + :param mediainfo: 统一媒体信息 + :param season: 季号 + :param episode: 集号 + :return: NFO XML 文本 + """ + if not mediainfo: + return None + + doc = minidom.Document() + if mediainfo.type == MediaType.MOVIE: + root = DomUtils.add_node(doc, doc, "movie") + cls._append_common_nodes(mediainfo, doc, root) + elif season is not None and episode is not None: + root = DomUtils.add_node(doc, doc, "episodedetails") + cls._append_common_nodes(mediainfo, doc, root) + DomUtils.add_node(doc, root, "season", str(season)) + DomUtils.add_node(doc, root, "episode", str(episode)) + DomUtils.add_node( + doc, + root, + "showtitle", + mediainfo.title or "", + ) + elif season is not None: + root = DomUtils.add_node(doc, doc, "season") + cls._append_common_nodes(mediainfo, doc, root) + DomUtils.add_node(doc, root, "seasonnumber", str(season)) + else: + root = DomUtils.add_node(doc, doc, "tvshow") + cls._append_common_nodes(mediainfo, doc, root) + DomUtils.add_node(doc, root, "season", "-1") + DomUtils.add_node(doc, root, "episode", "-1") + + return doc.toprettyxml(indent=" ", encoding="utf-8") + + @classmethod + def get_metadata_img( + cls, + mediainfo: MediaInfo, + season: Optional[int] = None, + episode: Optional[int] = None, + ) -> dict: + """ + 根据统一媒体信息生成主海报和背景图下载清单。 + + :param mediainfo: 统一媒体信息 + :param season: 季号 + :param episode: 集号 + :return: 图片文件名与下载地址映射 + """ + if not mediainfo or season is not None or episode is not None: + return {} + images = {} + if mediainfo.poster_path: + extension = cls._image_extension(mediainfo.poster_path) + images[f"poster{extension}"] = mediainfo.poster_path + if mediainfo.backdrop_path: + extension = cls._image_extension(mediainfo.backdrop_path) + images[f"backdrop{extension}"] = mediainfo.backdrop_path + return images diff --git a/app/locales/en-US.json b/app/locales/en-US.json index 6ff3b194..2ab31b1e 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -1102,6 +1102,10 @@ "source": "数据表 {name} 清理处理完成", "target": "Data table {name} cleanup completed" }, + { + "source": "同步媒体服务器 - {name} 开始执行 ...", + "target": "Starting media server sync - {name} ..." + }, { "source": "{name} 开始执行 ...", "target": "Starting {name_i18n} ..." @@ -1122,6 +1126,10 @@ "source": "正在同步媒体服务器({index}/{total}){name} ...", "target": "Syncing media server ({index}/{total}) {name} ..." }, + { + "source": "媒体服务器 {name} 未启用或不存在", + "target": "Media server {name} is disabled or does not exist" + }, { "source": "媒体服务器 {name} 无可同步媒体库", "target": "Media server {name} has no libraries to sync" @@ -1326,6 +1334,18 @@ "source": "工作流 {name} 执行完成", "target": "Workflow {name} completed" }, + { + "source": "同步媒体服务器 - {name} 执行完成", + "target": "Media server sync - {name} completed" + }, + { + "source": "同步媒体服务器 - {name} 执行失败", + "target": "Media server sync - {name} failed" + }, + { + "source": "同步媒体服务器 - {name}", + "target": "Sync Media Server - {name}" + }, { "source": "{name} 执行完成", "target": "{name_i18n} completed" diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json index e65d8aee..5d053902 100644 --- a/app/locales/zh-TW.json +++ b/app/locales/zh-TW.json @@ -1102,6 +1102,10 @@ "source": "数据表 {name} 清理处理完成", "target": "資料表 {name} 清理處理完成" }, + { + "source": "同步媒体服务器 - {name} 开始执行 ...", + "target": "開始同步媒體伺服器 - {name} ..." + }, { "source": "{name} 开始执行 ...", "target": "{name_i18n} 開始執行 ..." @@ -1122,6 +1126,10 @@ "source": "正在同步媒体服务器({index}/{total}){name} ...", "target": "正在同步媒體伺服器({index}/{total}){name} ..." }, + { + "source": "媒体服务器 {name} 未启用或不存在", + "target": "媒體伺服器 {name} 未啟用或不存在" + }, { "source": "媒体服务器 {name} 无可同步媒体库", "target": "媒體伺服器 {name} 無可同步媒體庫" @@ -1326,6 +1334,18 @@ "source": "工作流 {name} 执行完成", "target": "工作流 {name} 執行完成" }, + { + "source": "同步媒体服务器 - {name} 执行完成", + "target": "同步媒體伺服器 - {name} 執行完成" + }, + { + "source": "同步媒体服务器 - {name} 执行失败", + "target": "同步媒體伺服器 - {name} 執行失敗" + }, + { + "source": "同步媒体服务器 - {name}", + "target": "同步媒體伺服器 - {name}" + }, { "source": "{name} 执行完成", "target": "{name_i18n} 執行完成" diff --git a/app/modules/anilist/__init__.py b/app/modules/anilist/__init__.py new file mode 100644 index 00000000..67b33ed2 --- /dev/null +++ b/app/modules/anilist/__init__.py @@ -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() diff --git a/app/modules/anilist/anilist.py b/app/modules/anilist/anilist.py new file mode 100644 index 00000000..c2013581 --- /dev/null +++ b/app/modules/anilist/anilist.py @@ -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() diff --git a/app/modules/bangumi/__init__.py b/app/modules/bangumi/__init__.py index 7983e333..4cc3d494 100644 --- a/app/modules/bangumi/__init__.py +++ b/app/modules/bangumi/__init__.py @@ -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: """ 清除缓存 """ diff --git a/app/modules/douban/__init__.py b/app/modules/douban/__init__.py index 391c3ae0..47c8ee5f 100644 --- a/app/modules/douban/__init__.py +++ b/app/modules/douban/__init__.py @@ -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 diff --git a/app/modules/themoviedb/__init__.py b/app/modules/themoviedb/__init__.py index 9c58ec9a..6dae3aba 100644 --- a/app/modules/themoviedb/__init__.py +++ b/app/modules/themoviedb/__init__.py @@ -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 [] diff --git a/app/scheduler.py b/app/scheduler.py index d67d5bac..03780a97 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -1,5 +1,6 @@ import asyncio import gc +import hashlib import inspect import json import multiprocessing @@ -38,6 +39,7 @@ from app.helper.image import WallpaperHelper from app.helper.message import MessageHelper from app.helper.progress import ProgressHelper from app.helper.server import MoviePilotServerHelper +from app.helper.service import ServiceConfigHelper from app.helper.sites import SitesHelper # noqa from app.log import logger from app.schemas import Notification, NotificationType, Workflow @@ -280,6 +282,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): "DEV", "COOKIECLOUD_INTERVAL", "MEDIASERVER_SYNC_INTERVAL", + SystemConfigKey.MediaServers.value, "SUBSCRIBE_SEARCH", "SUBSCRIBE_SEARCH_INTERVAL", "SUBSCRIBE_MODE", @@ -323,6 +326,58 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): """ return "定时服务" + @staticmethod + def _get_mediaserver_sync_interval( + mediaserver: schemas.MediaServerConf, + default_interval: Optional[int], + ) -> Optional[int]: + """ + 获取媒体服务器的有效同步间隔,未设置时回退旧全局配置。 + """ + interval = mediaserver.sync_interval + if interval is None: + interval = default_interval + try: + interval = int(interval) + except (TypeError, ValueError): + return None + return interval if interval > 0 else None + + @classmethod + def _build_mediaserver_sync_schedules( + cls, + mediaservers: List[schemas.MediaServerConf], + default_interval: Optional[int], + ) -> List[dict]: + """ + 构建已启用媒体服务器的独立自动同步任务描述。 + """ + schedules = [] + job_ids = set() + for mediaserver in mediaservers: + if not mediaserver or not mediaserver.enabled or not mediaserver.name: + continue + interval = cls._get_mediaserver_sync_interval( + mediaserver=mediaserver, + default_interval=default_interval, + ) + if not interval: + continue + digest = hashlib.sha256(mediaserver.name.encode("utf-8")).hexdigest()[:12] + job_id = f"mediaserver_sync_{digest}" + if job_id in job_ids: + continue + job_ids.add(job_id) + schedules.append( + { + "id": job_id, + "name": f"同步媒体服务器 - {mediaserver.name}", + "server": mediaserver.name, + "interval": interval, + } + ) + return schedules + @staticmethod def _get_progress_key(job_id: str) -> str: """ @@ -351,6 +406,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): with lock: # 各服务的运行状态 + mediaserver_chain = MediaServerChain() self._jobs = { "cookiecloud": { "name": "同步CookieCloud站点", @@ -359,7 +415,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): }, "mediaserver_sync": { "name": "同步媒体服务器", - "func": MediaServerChain().sync, + "func": mediaserver_chain.sync, "running": False, }, "subscribe_tmdb": { @@ -478,19 +534,27 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): kwargs={"job_id": "cookiecloud"}, ) - # 媒体服务器同步 - if ( - settings.MEDIASERVER_SYNC_INTERVAL - and str(settings.MEDIASERVER_SYNC_INTERVAL).isdigit() - ): + # 按媒体服务器分别注册自动同步任务 + mediaserver_schedules = self._build_mediaserver_sync_schedules( + mediaservers=ServiceConfigHelper.get_mediaserver_configs(), + default_interval=settings.MEDIASERVER_SYNC_INTERVAL, + ) + for mediaserver_schedule in mediaserver_schedules: + job_id = mediaserver_schedule["id"] + self._jobs[job_id] = { + "name": mediaserver_schedule["name"], + "func": mediaserver_chain.sync, + "running": False, + "kwargs": {"server": mediaserver_schedule["server"]}, + } self._scheduler.add_job( self.start, "interval", - id="mediaserver_sync", - name="同步媒体服务器", - hours=int(settings.MEDIASERVER_SYNC_INTERVAL), + id=job_id, + name=mediaserver_schedule["name"], + hours=mediaserver_schedule["interval"], next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=10), - kwargs={"job_id": "mediaserver_sync"}, + kwargs={"job_id": job_id}, ) # 新增订阅时搜索(5分钟检查一次) diff --git a/app/schemas/context.py b/app/schemas/context.py index ccc5dc99..0fd0eab8 100644 --- a/app/schemas/context.py +++ b/app/schemas/context.py @@ -63,14 +63,20 @@ class MetaInfo(BaseModel): apply_words: Optional[List[str]] = None # 剧集组 episode_group: Optional[str] = None + # 显式媒体数据源 + media_source: Optional[str] = None + # 显式媒体数据源原生ID + media_id: Optional[str] = None class MediaInfo(BaseModel): """ 识别媒体信息 """ - # 来源:themoviedb、douban、bangumi + # 来源:themoviedb、douban、bangumi、anilist source: Optional[str] = None + # 请求级刮削来源 + scrape_source: Optional[str] = None # 类型 电影、电视剧、合集 type: Optional[str] = None # 媒体标题 @@ -93,6 +99,10 @@ class MediaInfo(BaseModel): douban_id: Optional[str] = None # Bangumi ID bangumi_id: Optional[int] = None + # AniList ID + anilist_id: Optional[int] = None + # AniDB ID + anidb_id: Optional[int] = None # 合集ID collection_id: Optional[int] = None # 其它媒体ID前缀 diff --git a/app/schemas/history.py b/app/schemas/history.py index 243bb77d..071c136d 100644 --- a/app/schemas/history.py +++ b/app/schemas/history.py @@ -55,6 +55,10 @@ class DownloadHistory(BaseModel): class TransferHistory(BaseModel): + """ + 文件整理历史记录 + """ + # ID id: int # 源目录 @@ -79,6 +83,10 @@ class TransferHistory(BaseModel): tvdbid: Optional[int] = None # 豆瓣ID doubanid: Optional[str] = None + # 媒体数据源 + media_source: Optional[str] = None + # 数据源原生ID + media_id: Optional[str] = None # 季Sxx seasons: Optional[str] = None # 集Exx diff --git a/app/schemas/system.py b/app/schemas/system.py index 9a15f5e7..056cac97 100644 --- a/app/schemas/system.py +++ b/app/schemas/system.py @@ -37,6 +37,8 @@ class MediaServerConf(BaseModel): enabled: Optional[bool] = False # 同步媒体体库列表 sync_libraries: Optional[list] = Field(default_factory=list) + # 自动同步间隔(小时),未设置时使用旧全局配置 + sync_interval: Optional[int] = None class DownloaderConf(BaseModel): diff --git a/app/schemas/transfer.py b/app/schemas/transfer.py index 46cfeb65..7729fd73 100644 --- a/app/schemas/transfer.py +++ b/app/schemas/transfer.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Optional, List, Any, Callable +from typing import Any, Callable, List, Literal, Optional from pydantic import BaseModel, Field @@ -60,6 +60,9 @@ class TransferTask(BaseModel): fileitem: FileItem meta: Optional[Any] = None mediainfo: Optional[Any] = None + media_source: Optional[ + Literal["themoviedb", "douban", "bangumi", "anilist"] + ] = None target_directory: Optional[TransferDirectoryConf] = None target_storage: Optional[str] = None target_path: Optional[Path] = None @@ -192,6 +195,10 @@ class EpisodeFormatRecommendItem(BaseModel): class ManualTransferItem(BaseModel): + """ + 手动整理请求,兼容历史数据源ID字段并支持统一来源与原生ID + """ + # 文件项 fileitem: FileItem = None # 文件项列表(前端多选时传入) @@ -208,6 +215,12 @@ class ManualTransferItem(BaseModel): tmdbid: Optional[int] = None # 豆瓣ID doubanid: Optional[str] = None + # 媒体数据源 + media_source: Optional[ + Literal["themoviedb", "douban", "bangumi", "anilist"] + ] = None + # 数据源原生ID + media_id: Optional[str] = None # 类型 type_name: Optional[str] = None # 季号 diff --git a/app/schemas/types.py b/app/schemas/types.py index 6c50043e..931db073 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -391,6 +391,8 @@ class MediaRecognizeType(Enum): TVDB = "TheTvDb" # bangumi Bangumi = "Bangumi" + # AniList + AniList = "AniList" # 用户配置Key字典 diff --git a/app/utils/rust_accel.py b/app/utils/rust_accel.py index 8d76ad5f..f269cd53 100644 --- a/app/utils/rust_accel.py +++ b/app/utils/rust_accel.py @@ -1,4 +1,5 @@ import logging +from functools import lru_cache from typing import List, Optional, Tuple from app.core.config import settings @@ -214,6 +215,29 @@ def find_metainfo(title: str) -> Optional[dict]: return None +@lru_cache(maxsize=1) +def supports_extended_media_ids() -> bool: + """ + 判断当前 Rust 扩展是否支持 Bangumi 与 AniList 显式媒体标签。 + + :return: 是否支持扩展数据源ID字段 + """ + if not is_enabled(): + return False + try: + result = _moviepilot_rust.find_metainfo_fast("test [anilist=1]") + except BaseException as err: + _raise_non_rust_panic(err) + logger.debug(f"检测 Rust 扩展数据源ID能力失败:{err}") + return False + metainfo = result.get("metainfo") if isinstance(result, dict) else None + return bool( + metainfo + and metainfo.get("media_source") == "anilist" + and metainfo.get("media_id") == "1" + ) + + def _raise_non_rust_panic(err: BaseException) -> None: """ 只吞掉 Rust 扩展 panic/异常,保留用户中断和进程退出语义。 diff --git a/database/versions/e6a1c4b8d2f0_2_2_13.py b/database/versions/e6a1c4b8d2f0_2_2_13.py new file mode 100644 index 00000000..9e13649d --- /dev/null +++ b/database/versions/e6a1c4b8d2f0_2_2_13.py @@ -0,0 +1,107 @@ +"""2.2.13 +为整理历史增加统一媒体数据源与原生ID + +Revision ID: e6a1c4b8d2f0 +Revises: c4e8f7a1b2d3 +Create Date: 2026-07-21 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "e6a1c4b8d2f0" +down_revision = "c4e8f7a1b2d3" +branch_labels = None +depends_on = None + + +def _has_column( + inspector: sa.Inspector, + table_name: str, + column_name: str, +) -> bool: + """ + 检查数据表是否已存在指定字段。 + + :param inspector: SQLAlchemy结构检查器 + :param table_name: 数据表名称 + :param column_name: 字段名称 + :return: 字段是否存在 + """ + if table_name not in inspector.get_table_names(): + return False + return any( + column["name"] == column_name + for column in inspector.get_columns(table_name) + ) + + +def upgrade() -> None: + """升级整理历史数据源字段。""" + inspector = sa.inspect(op.get_bind()) + if not _has_column(inspector, "transferhistory", "media_source"): + op.add_column( + "transferhistory", + sa.Column("media_source", sa.String(), nullable=True), + ) + op.create_index( + "ix_transferhistory_media_source", + "transferhistory", + ["media_source"], + ) + + inspector = sa.inspect(op.get_bind()) + if not _has_column(inspector, "transferhistory", "media_id"): + op.add_column( + "transferhistory", + sa.Column("media_id", sa.String(), nullable=True), + ) + op.create_index( + "ix_transferhistory_media_id", + "transferhistory", + ["media_id"], + ) + + transfer_history = sa.table( + "transferhistory", + sa.column("tmdbid", sa.Integer()), + sa.column("doubanid", sa.String()), + sa.column("media_source", sa.String()), + sa.column("media_id", sa.String()), + ) + connection = op.get_bind() + connection.execute( + transfer_history.update() + .where(transfer_history.c.tmdbid.is_not(None)) + .where(transfer_history.c.media_id.is_(None)) + .values( + media_source="themoviedb", + media_id=sa.cast(transfer_history.c.tmdbid, sa.String()), + ) + ) + connection.execute( + transfer_history.update() + .where(transfer_history.c.tmdbid.is_(None)) + .where(transfer_history.c.doubanid.is_not(None)) + .where(transfer_history.c.media_id.is_(None)) + .values( + media_source="douban", + media_id=transfer_history.c.doubanid, + ) + ) + + +def downgrade() -> None: + """回滚整理历史数据源字段。""" + inspector = sa.inspect(op.get_bind()) + if _has_column(inspector, "transferhistory", "media_id"): + op.drop_index("ix_transferhistory_media_id", table_name="transferhistory") + op.drop_column("transferhistory", "media_id") + + inspector = sa.inspect(op.get_bind()) + if _has_column(inspector, "transferhistory", "media_source"): + op.drop_index( + "ix_transferhistory_media_source", + table_name="transferhistory", + ) + op.drop_column("transferhistory", "media_source") diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 0b6770ab..e3384a9f 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -110,6 +110,19 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所 FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返回 `detail_i18n`;新版前端优先展示 `detail_i18n`,缺失时回退 `detail`。 +#### 媒体识别 / 整理 + +媒体识别、搜索和手动整理支持 `themoviedb`、`douban`、`bangumi`、`anilist` 四种数据源。请求未指定 `source` 时继续使用后台配置;显式指定时仅在该数据源中识别或搜索。 + +| 方法 | 路径 | 说明 | +| :--- | :--- | :--- | +| GET | `/api/v1/media/search` | 按标题搜索媒体,参数:`title`、`type`、`page`、`count`,可选 `source` | +| GET | `/api/v1/media/recognize` | 识别标题,参数:`title`、`subtitle`、`custom_words`,可选 `source` | +| GET | `/api/v1/media/recognize_file` | 识别文件路径,参数:`path`,可选 `source` | +| GET | `/api/v1/media/{mediaid}` | 查询媒体详情,`mediaid` 支持 `tmdb:`、`douban:`、`bangumi:`、`anilist:` 前缀 | +| POST | `/api/v1/transfer/manual/target-path` | 匹配手动整理目标路径;请求体可用 `media_source` + `media_id` 指定数据源原生ID | +| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源,同时兼容 `tmdbid`、`doubanid` | + #### 搜索 / 种子 / 字幕 | 方法 | 路径 | 说明 | @@ -132,8 +145,8 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返 | :--- | :--- | :--- | | GET | `/api/v1/download/` | 查询正在下载的任务,参数:`name` | | POST | `/api/v1/download/` | 添加含媒体信息的下载任务,请求体包含媒体信息和种子信息 | -| POST | `/api/v1/download/add` | 添加不含媒体信息的下载任务,请求体包含 `torrent_in`,可选 `tmdbid`、`doubanid`、`downloader`、`save_path` | -| POST | `/api/v1/download/subtitle` | 下载字幕到识别出的媒体下载目录,请求体包含 `subtitle_in`,可选 `tmdbid`、`doubanid`、`save_path` | +| POST | `/api/v1/download/add` | 添加不含媒体信息的下载任务,请求体包含 `torrent_in`,可选 `media_source` + `media_id`;继续兼容 `tmdbid`、`doubanid`,并支持 `downloader`、`save_path` | +| POST | `/api/v1/download/subtitle` | 下载字幕到识别出的媒体下载目录,请求体包含 `subtitle_in`,可选 `media_source` + `media_id`;继续兼容 `tmdbid`、`doubanid`,并支持 `save_path` | | GET | `/api/v1/download/start/{hashString}` | 恢复下载任务,参数:`name` | | GET | `/api/v1/download/stop/{hashString}` | 暂停下载任务,参数:`name` | | GET | `/api/v1/download/clients` | 查询可用下载器 | diff --git a/requirements.in b/requirements.in index 3f598b72..82b7d2c9 100644 --- a/requirements.in +++ b/requirements.in @@ -1,4 +1,4 @@ -moviepilot-rust~=0.2.3 +moviepilot-rust~=0.2.4 pydantic>=2.13.4,<3.0.0 pydantic-settings>=2.14.1,<3.0.0 SQLAlchemy~=2.0.50 diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index d7106d4c..3ac4ea8c 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -91,11 +91,11 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as ` | Method | Path | Description | |--------|------|-------------| -| GET | `/api/v1/media/search` | Search media/person by title. Params: `title` (required), `type`, `page`, `count` | -| GET | `/api/v1/media/recognize` | Recognize media from torrent title. Params: `title` (required), `subtitle` | -| GET | `/api/v1/media/recognize2` | Recognize media (API_TOKEN auth, use `--token-param`). Params: `title`, `subtitle` | -| GET | `/api/v1/media/recognize_file` | Recognize media from file path. Params: `path` (required) | -| GET | `/api/v1/media/recognize_file2` | Recognize file (API_TOKEN auth). Params: `path` | +| GET | `/api/v1/media/search` | Search media/person by title. Params: `title` (required), `type`, `page`, `count`, optional `source` (`themoviedb`, `douban`, `bangumi`, `anilist`) | +| GET | `/api/v1/media/recognize` | Recognize media from torrent title. Params: `title` (required), `subtitle`, `custom_words`, optional `source` | +| GET | `/api/v1/media/recognize2` | Recognize media (API_TOKEN auth, use `--token-param`). Params: `title`, `subtitle`, `custom_words`, optional `source` | +| GET | `/api/v1/media/recognize_file` | Recognize media from file path. Params: `path` (required), optional `source` | +| GET | `/api/v1/media/recognize_file2` | Recognize file (API_TOKEN auth). Params: `path`, optional `source` | | POST | `/api/v1/media/scrape/{storage}` | Scrape media metadata. Body: FileItem JSON | | GET | `/api/v1/media/category/config` | Get category strategy config | | POST | `/api/v1/media/category/config` | Save category strategy config. Body: CategoryConfig | @@ -103,7 +103,7 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as ` | GET | `/api/v1/media/group/seasons/{episode_group}` | Get episode group seasons | | GET | `/api/v1/media/groups/{tmdbid}` | Get media episode groups | | GET | `/api/v1/media/seasons` | Get media season info. Params: `mediaid`, `title`, `year`, `season` | -| GET | `/api/v1/media/{mediaid}` | Get media detail. Params: `type_name` (required: movie/tv), `title`, `year` | +| GET | `/api/v1/media/{mediaid}` | Get media detail. `mediaid` supports `tmdb:`, `douban:`, `bangumi:`, and `anilist:`. Params: `type_name` (required: movie/tv), `title`, `year` | ### TMDB (8 endpoints) @@ -160,8 +160,8 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as ` |--------|------|-------------| | GET | `/api/v1/download/` | List active downloads. Params: `name` (downloader name) | | POST | `/api/v1/download/` | Add download (with media info). Body: JSON | -| POST | `/api/v1/download/add` | Add download (without media info). Body: JSON with `torrent_url` | -| POST | `/api/v1/download/subtitle` | Download subtitle file to the recognized media download directory. Body: `subtitle_in`, optional `tmdbid`, `doubanid`, `save_path` | +| POST | `/api/v1/download/add` | Add download without media info. Body: `torrent_in`, optional `media_source` + `media_id` (legacy `tmdbid`/`doubanid` remain supported), `downloader`, `save_path` | +| POST | `/api/v1/download/subtitle` | Download subtitle file to the recognized media download directory. Body: `subtitle_in`, optional `media_source` + `media_id` (legacy `tmdbid`/`doubanid` remain supported), `save_path` | | GET | `/api/v1/download/start/{hashString}` | Resume download task | | GET | `/api/v1/download/stop/{hashString}` | Pause download task | | GET | `/api/v1/download/clients` | List available download clients | @@ -278,8 +278,8 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as ` | GET | `/api/v1/transfer/name` | Preview transfer name. Params: `path` (required), `filetype` (required) | | GET | `/api/v1/transfer/queue` | Transfer queue | | DELETE | `/api/v1/transfer/queue` | Remove from transfer queue. Body: FileItem JSON | -| POST | `/api/v1/transfer/manual/target-path` | Match manual transfer target path. Body: ManualTransferItem JSON | -| POST | `/api/v1/transfer/manual` | Manual transfer. Params: `background`. Body: ManualTransferItem JSON | +| POST | `/api/v1/transfer/manual/target-path` | Match manual transfer target path. Body: ManualTransferItem JSON; optional `media_source` + `media_id` select the recognition source | +| POST | `/api/v1/transfer/manual` | Manual transfer. Params: `background`. Body: ManualTransferItem JSON; optional `media_source` + `media_id` select recognition and scraping source | | GET | `/api/v1/transfer/now` | Run immediate transfer | ### Dashboard (19 endpoints) diff --git a/tests/test_anilist_media_source.py b/tests/test_anilist_media_source.py new file mode 100644 index 00000000..505b71cd --- /dev/null +++ b/tests/test_anilist_media_source.py @@ -0,0 +1,162 @@ +import asyncio +from unittest.mock import AsyncMock, Mock +from xml.dom import minidom + +import pytest + +from app.core.context import MediaInfo +from app.core.meta import MetaBase +from app.helper.scraper import MediaScraperHelper +from app.modules.anilist import AniListModule +from app.modules.anilist.anilist import AniListApi +from app.schemas.types import MediaType + + +@pytest.fixture +def anilist_info() -> dict: + """构造不依赖网络的AniList媒体详情。""" + return { + "id": 154587, + "title": { + "romaji": "Sousou no Frieren", + "english": "Frieren: Beyond Journey's End", + "native": "葬送のフリーレン", + }, + "format": "TV", + "status": "FINISHED", + "description": "A journey after the adventure.", + "startDate": {"year": 2023, "month": 9, "day": 29}, + "endDate": {"year": 2024, "month": 3, "day": 22}, + "episodes": 28, + "duration": 24, + "countryOfOrigin": "JP", + "coverImage": {"extraLarge": "https://img.example/poster.jpg"}, + "bannerImage": "https://img.example/backdrop.png", + "genres": ["Adventure", "Fantasy"], + "synonyms": ["Frieren"], + "averageScore": 91, + "popularity": 300000, + "isAdult": False, + "studios": {"nodes": [{"name": "Madhouse"}]}, + "staff": { + "edges": [ + { + "role": "Director", + "node": { + "name": {"full": "Keiichiro Saito"}, + "image": {"large": "https://img.example/director.jpg"}, + "siteUrl": "https://anilist.co/staff/1", + }, + } + ] + }, + "characters": { + "edges": [ + { + "node": {"name": {"full": "Frieren"}}, + "voiceActors": [ + { + "name": {"full": "Atsumi Tanezaki"}, + "image": {"large": "https://img.example/actor.jpg"}, + "siteUrl": "https://anilist.co/staff/2", + } + ], + } + ] + }, + "externalLinks": [ + {"site": "AniDB", "url": "https://anidb.net/anime/17617"} + ], + } + + +def test_anilist_id_recognition_normalizes_media_info(anilist_info: dict) -> None: + """AniList ID识别应生成可供整理和刮削复用的统一媒体信息。""" + module = AniListModule() + module.anilist_api = Mock() + module.anilist_api.detail.return_value = anilist_info + + media = module.recognize_media(anilistid=154587) + + assert media is not None + assert media.source == "anilist" + assert media.anilist_id == 154587 + assert media.anidb_id == 17617 + assert media.type == MediaType.TV + assert media.year == "2023" + assert media.number_of_episodes == 28 + assert media.seasons[1] == list(range(1, 29)) + assert media.genres == [ + {"id": "Adventure", "name": "Adventure"}, + {"id": "Fantasy", "name": "Fantasy"}, + ] + assert media.production_companies == [{"name": "Madhouse"}] + assert media.directors[0]["name"] == "Keiichiro Saito" + assert media.actors[0]["character"] == "Frieren" + module.anilist_api.detail.assert_called_once_with(154587) + + +def test_anilist_title_recognition_respects_request_source(anilist_info: dict) -> None: + """标题识别仅在本次请求明确选择AniList时使用AniList候选项。""" + module = AniListModule() + module.anilist_api = Mock() + module.anilist_api.search.return_value = [anilist_info] + meta = MetaBase("Frieren") + meta.cn_name = "Frieren" + meta.type = MediaType.TV + meta.year = "2023" + + media = module.recognize_media(meta=meta, source="anilist") + skipped = module.recognize_media(meta=meta, source="douban") + + assert media is not None + assert media.anilist_id == 154587 + assert skipped is None + module.anilist_api.search.assert_called_once_with("Frieren") + + +def test_async_anilist_title_recognition(anilist_info: dict) -> None: + """异步AniList标题识别应与同步结果保持一致。""" + module = AniListModule() + module.anilist_api = Mock() + module.anilist_api.async_search = AsyncMock(return_value=[anilist_info]) + meta = MetaBase("Frieren") + meta.cn_name = "Frieren" + meta.type = MediaType.TV + + media = asyncio.run( + module.async_recognize_media(meta=meta, source="anilist") + ) + + assert media is not None + assert media.anilist_id == 154587 + module.anilist_api.async_search.assert_awaited_once_with("Frieren") + + +def test_anilist_scraper_generates_nfo_and_images(anilist_info: dict) -> None: + """AniList媒体信息应生成带来源ID的NFO以及主海报和背景图。""" + module = AniListModule() + module.scraper = MediaScraperHelper() + media = MediaInfo(anilist_info=anilist_info) + media.scrape_source = "anilist" + + nfo = module.metadata_nfo(media) + images = module.metadata_img(media) + document = minidom.parseString(nfo) + unique_id = document.getElementsByTagName("uniqueid")[0] + + assert document.documentElement.tagName == "tvshow" + assert unique_id.firstChild.data == "154587" + assert unique_id.getAttribute("type") == "anilist" + assert images == { + "poster.jpg": "https://img.example/poster.jpg", + "backdrop.png": "https://img.example/backdrop.png", + } + + +def test_anilist_api_extracts_graphql_errors_without_network() -> None: + """AniList客户端应把GraphQL错误响应统一视为无结果。""" + response = Mock(status_code=200) + response.json.return_value = {"errors": [{"message": "invalid"}]} + + assert AniListApi._extract_response(response) is None diff --git a/tests/test_anime_source_metainfo.py b/tests/test_anime_source_metainfo.py new file mode 100644 index 00000000..0cf5b0b5 --- /dev/null +++ b/tests/test_anime_source_metainfo.py @@ -0,0 +1,71 @@ +from pathlib import Path +from unittest.mock import patch + +import pytest + +from app.core.metainfo import MetaInfo, MetaInfoPath, find_metainfo +from app.schemas.types import MediaType + + +@pytest.mark.parametrize( + ("title", "source", "media_id"), + [ + ("葬送的芙莉莲 {[bangumiid=400602;type=tv;s=1]}", "bangumi", "400602"), + ("Frieren {[anilistid=154587;type=tv;s=1]}", "anilist", "154587"), + ("Frieren [anilist=154587] S01E01", "anilist", "154587"), + ], +) +def test_find_metainfo_supports_anime_source_ids( + title: str, + source: str, + media_id: str, +) -> None: + """显式动画来源标签应提取统一来源ID并从标题中移除。""" + parsed_title, metainfo = find_metainfo(title) + + assert metainfo["media_source"] == source + assert metainfo["media_id"] == media_id + assert f"{source}id=" not in parsed_title + assert f"{source}=" not in parsed_title + + +def test_metainfo_custom_words_support_anilist_id() -> None: + """自定义识别词替换结果中的AniList ID应进入统一元数据字段。""" + meta = MetaInfo( + "Sousou no Frieren 01", + custom_words=[ + "Sousou no Frieren => Frieren {[anilistid=154587;type=tv;s=1]}" + ], + ) + + assert meta.media_source == "anilist" + assert meta.media_id == "154587" + assert meta.type == MediaType.TV + assert meta.begin_season == 1 + + +def test_metainfo_path_inherits_bangumi_id_from_parent() -> None: + """文件路径识别应从父目录继承Bangumi来源ID。""" + meta = MetaInfoPath( + Path("/anime/葬送的芙莉莲 [bangumi=400602]/Frieren.S01E01.mkv") + ) + + assert meta.media_source == "bangumi" + assert meta.media_id == "400602" + assert meta.begin_season == 1 + assert meta.begin_episode == 1 + + +def test_extended_ids_fall_back_when_installed_rust_is_old() -> None: + """当前Rust扩展缺少新字段时应直接使用Python解析器。""" + with patch( + "app.core.metainfo.rust_accel.supports_extended_media_ids", + return_value=False, + ), patch( + "app.core.metainfo.rust_accel.find_metainfo", + side_effect=AssertionError("旧Rust扩展不应处理扩展来源ID"), + ): + _, metainfo = find_metainfo("Frieren [anilist=154587]") + + assert metainfo["media_source"] == "anilist" + assert metainfo["media_id"] == "154587" diff --git a/tests/test_bangumi_recognize_scrape.py b/tests/test_bangumi_recognize_scrape.py new file mode 100644 index 00000000..2d7dc8e2 --- /dev/null +++ b/tests/test_bangumi_recognize_scrape.py @@ -0,0 +1,78 @@ +from unittest.mock import Mock +from xml.dom import minidom + +from app.core.meta import MetaBase +from app.helper.scraper import MediaScraperHelper +from app.modules.bangumi import BangumiModule +from app.schemas.types import MediaType + + +def _bangumi_info() -> dict: + """构造Bangumi识别与刮削测试详情。""" + return { + "id": 400602, + "name": "Sousou no Frieren", + "name_cn": "葬送的芙莉莲", + "platform": "TV", + "date": "2023-09-29", + "eps": 28, + "summary": "勇者一行击败魔王后的故事。", + "images": {"large": "https://lain.example/poster.jpg"}, + "rating": {"score": 8.9}, + "tags": [{"name": "奇幻"}, {"name": "冒险"}], + "infobox": [ + {"key": "动画制作", "value": "MADHOUSE"}, + {"key": "导演", "value": [{"v": "斋藤圭一郎"}]}, + ], + } + + +def test_bangumi_title_recognition_loads_detail_and_people() -> None: + """Bangumi标题识别应搜索候选、读取详情并补齐演职员。""" + module = BangumiModule() + module.bangumiapi = Mock() + module.bangumiapi.search.return_value = [ + {"id": 400602, "name": "Sousou no Frieren", "name_cn": "葬送的芙莉莲"} + ] + module.bangumiapi.detail.return_value = _bangumi_info() + module.bangumiapi.credits.return_value = [ + {"name": "种崎敦美", "career": ["芙莉莲"]} + ] + meta = MetaBase("葬送的芙莉莲") + meta.cn_name = "葬送的芙莉莲" + meta.type = MediaType.TV + meta.year = "2023" + + media = module.recognize_media(meta=meta, source="bangumi") + + assert media is not None + assert media.source == "bangumi" + assert media.bangumi_id == 400602 + assert media.number_of_episodes == 28 + assert media.genres == [ + {"id": "奇幻", "name": "奇幻"}, + {"id": "冒险", "name": "冒险"}, + ] + assert media.production_companies == [{"name": "MADHOUSE"}] + assert media.directors == [{"name": "斋藤圭一郎"}] + assert media.actors[0]["name"] == "种崎敦美" + + +def test_bangumi_scraper_generates_source_nfo() -> None: + """Bangumi来源应可生成带Bangumi唯一ID的NFO与图片清单。""" + module = BangumiModule() + module.bangumiapi = Mock() + module.scraper = MediaScraperHelper() + module.bangumiapi.detail.return_value = _bangumi_info() + module.bangumiapi.credits.return_value = [] + media = module.recognize_media(bangumiid=400602) + media.scrape_source = "bangumi" + + nfo = module.metadata_nfo(media) + images = module.metadata_img(media) + document = minidom.parseString(nfo) + unique_id = document.getElementsByTagName("uniqueid")[0] + + assert unique_id.firstChild.data == "400602" + assert unique_id.getAttribute("type") == "bangumi" + assert images == {"poster.jpg": "https://lain.example/poster.jpg"} diff --git a/tests/test_download_media_source.py b/tests/test_download_media_source.py new file mode 100644 index 00000000..7b84b3c0 --- /dev/null +++ b/tests/test_download_media_source.py @@ -0,0 +1,118 @@ +from types import SimpleNamespace + +from app import schemas +from app.api.endpoints import download as download_endpoint +from app.core.context import MediaInfo +from app.schemas.types import MediaType + + +def test_download_add_passes_generic_media_source(monkeypatch) -> None: + """不含媒体信息的下载应按统一来源ID执行精确识别。""" + captured = {} + media = MediaInfo( + anilist_info={ + "id": 154587, + "title": {"english": "Frieren"}, + "format": "TV", + } + ) + + class FakeMediaChain: + """记录下载接口传入的媒体识别参数。""" + + def recognize_media(self, **kwargs): + """返回固定媒体信息并保存识别参数。""" + captured["recognize"] = kwargs + return media + + class FakeDownloadChain: + """模拟下载任务提交。""" + + def download_single(self, **kwargs): + """保存下载上下文并返回任务ID。""" + captured["download"] = kwargs + return "download-1" + + monkeypatch.setattr(download_endpoint, "MediaChain", FakeMediaChain) + monkeypatch.setattr(download_endpoint, "DownloadChain", FakeDownloadChain) + + response = download_endpoint.add( + torrent_in=schemas.TorrentInfo(title="Frieren S01E01"), + media_source="anilist", + media_id="154587", + current_user=SimpleNamespace(name="tester"), + ) + + assert response.success is True + assert captured["recognize"]["source"] == "anilist" + assert captured["recognize"]["mediaid"] == "154587" + assert captured["download"]["context"].media_info is media + + +def test_download_add_uses_selected_source_for_title_recognition(monkeypatch) -> None: + """只选择来源而未填写ID时应在该来源内按标题识别。""" + captured = {} + media = MediaInfo(title="测试动画", type=MediaType.TV, bangumi_id=1) + + class FakeMediaChain: + """记录按标题识别的请求级来源。""" + + def recognize_by_meta(self, metainfo, **kwargs): + """返回固定媒体信息并保存来源。""" + captured["metainfo"] = metainfo + captured["kwargs"] = kwargs + return media + + class FakeDownloadChain: + """模拟下载任务提交。""" + + @staticmethod + def download_single(**kwargs): + """返回固定下载任务ID。""" + return "download-2" + + monkeypatch.setattr(download_endpoint, "MediaChain", FakeMediaChain) + monkeypatch.setattr(download_endpoint, "DownloadChain", FakeDownloadChain) + + response = download_endpoint.add( + torrent_in=schemas.TorrentInfo(title="测试动画 S01E01"), + media_source="bangumi", + current_user=SimpleNamespace(name="tester"), + ) + + assert response.success is True + assert captured["kwargs"]["source"] == "bangumi" + + +def test_subtitle_download_passes_generic_media_source(monkeypatch) -> None: + """字幕下载接口应把统一来源ID传递到下载链。""" + captured = {} + + class FakeDownloadChain: + """记录字幕下载参数。""" + + def download_subtitle(self, **kwargs): + """保存参数并返回固定成功结果。""" + captured.update(kwargs) + return True, "字幕下载成功", ["/tmp/subtitle.ass"] + + monkeypatch.setattr( + download_endpoint, + "_prepare_subtitle_download", + lambda _subtitle: (True, ""), + ) + monkeypatch.setattr(download_endpoint, "DownloadChain", FakeDownloadChain) + + response = download_endpoint.download_subtitle( + subtitle_in=schemas.SubtitleInfo( + title="Frieren S01E01", + enclosure="https://example.com/subtitle.ass", + ), + media_source="anilist", + media_id="154587", + current_user=SimpleNamespace(name="tester"), + ) + + assert response.success is True + assert captured["media_source"] == "anilist" + assert captured["media_id"] == "154587" diff --git a/tests/test_locale_helper.py b/tests/test_locale_helper.py index 06f79a80..4b1eb0db 100644 --- a/tests/test_locale_helper.py +++ b/tests/test_locale_helper.py @@ -317,6 +317,18 @@ def test_schedule_info_auto_fills_i18n_display_fields(): assert schedule.progress_detail.text_i18n == "Media server Emby has no libraries to sync" assert schedule.progress_detail.error_i18n == "Background service does not exist" + token = LocaleHelper.set_current_locale("en-US") + try: + dynamic_schedule = ScheduleInfo( + id="mediaserver_sync_example", + name="同步媒体服务器 - Plex", + progress_text="同步媒体服务器 - Plex 开始执行 ...", + ) + finally: + LocaleHelper.reset_current_locale(token) + assert dynamic_schedule.name_i18n == "Sync Media Server - Plex" + assert dynamic_schedule.progress_text_i18n == "Starting media server sync - Plex ..." + def test_scheduler_progress_patterns_translate_dynamic_texts(): """定时任务进度动态模板应翻译固定词并保留业务变量。""" diff --git a/tests/test_media_source_routing.py b/tests/test_media_source_routing.py new file mode 100644 index 00000000..6cf4b5f2 --- /dev/null +++ b/tests/test_media_source_routing.py @@ -0,0 +1,106 @@ +from unittest.mock import Mock, patch + +from app.chain import ChainBase +from app.core.context import MediaInfo +from app.core.meta import MetaBase +from app.schemas.types import MediaType + + +def _chain_without_init() -> ChainBase: + """构造不加载真实模块和外部服务的识别链实例。""" + return object.__new__(ChainBase) + + +def test_generic_source_id_wins_over_legacy_ids() -> None: + """显式source与media_id应优先于同一请求残留的兼容ID字段。""" + resolved = ChainBase._resolve_media_source_params( + source="anilist", + mediaid="154587", + tmdbid=999, + doubanid="888", + ) + + assert resolved == ("anilist", None, None, None, 154587) + + +def test_explicit_source_recognition_runs_system_modules_only() -> None: + """显式选择数据源时应跳过插件并只向系统模块传递该来源ID。""" + chain = _chain_without_init() + media = MediaInfo( + anilist_info={ + "id": 154587, + "title": {"english": "Frieren"}, + "format": "TV", + } + ) + chain.run_module = Mock(return_value=media) + + with patch( + "app.chain.MoviePilotServerHelper.report_recognize_share", + return_value=False, + ): + result = chain.recognize_media( + source="anilist", + mediaid="154587", + tmdbid=999, + mtype=MediaType.TV, + ) + + assert result is media + call = chain.run_module.call_args + assert call.kwargs["system_only"] is True + assert call.kwargs["source"] == "anilist" + assert call.kwargs["anilistid"] == 154587 + assert call.kwargs["tmdbid"] is None + + +def test_default_recognition_preserves_plugin_method_contract() -> None: + """未显式选择来源时不应向既有插件额外传递source参数。""" + chain = _chain_without_init() + media = MediaInfo(title="测试电影", type=MediaType.MOVIE, tmdb_id=1) + chain.run_module = Mock(return_value=media) + meta = MetaBase("测试电影") + meta.cn_name = "测试电影" + meta.type = MediaType.MOVIE + + with patch( + "app.chain.MoviePilotServerHelper.report_recognize_share", + return_value=False, + ): + result = chain.recognize_media(meta=meta) + + assert result is media + call = chain.run_module.call_args + assert call.kwargs["system_only"] is False + assert "source" not in call.kwargs + assert "anilistid" not in call.kwargs + + +def test_system_only_module_dispatch_skips_plugins() -> None: + """模块调度的system_only模式不得执行插件模块。""" + chain = _chain_without_init() + chain._ChainBase__execute_plugin_modules = Mock(return_value="plugin") + chain._ChainBase__execute_system_modules = Mock(return_value="system") + + result = chain.run_module("search_medias", system_only=True, meta=MetaBase("test")) + + assert result == "system" + chain._ChainBase__execute_plugin_modules.assert_not_called() + chain._ChainBase__execute_system_modules.assert_called_once() + + +def test_explicit_search_source_uses_system_only_dispatch() -> None: + """请求级搜索来源应进入仅系统模块调度。""" + chain = _chain_without_init() + chain.run_module = Mock(return_value=[]) + meta = MetaBase("Frieren") + + result = chain.search_medias(meta, source="anilist") + + assert result == [] + chain.run_module.assert_called_once_with( + "search_medias", + meta=meta, + source="anilist", + system_only=True, + ) diff --git a/tests/test_mediaserver_sync_incremental.py b/tests/test_mediaserver_sync_incremental.py index 012e6b0c..7f51985e 100644 --- a/tests/test_mediaserver_sync_incremental.py +++ b/tests/test_mediaserver_sync_incremental.py @@ -248,3 +248,33 @@ def test_sync_queries_counts_before_items_and_reports_media_progress(database): assert media_progress[2]["data"]["media_finished"] == 3 progress_values = [snapshot["value"] for snapshot in progress_snapshots] assert progress_values == sorted(progress_values) + + +def test_sync_targets_one_server_without_excluding_other_enabled_servers(monkeypatch): + """定向同步只访问目标服务器,缓存清理仍保留其他已启用服务器。""" + chain = object.__new__(MediaServerChain) + library_calls = [] + excluded_server_calls = [] + + class FakeMediaServerOper: + """记录媒体服务器缓存清理参数的测试替身。""" + + def delete_excluded_servers(self, servers): + """记录应保留的全部已启用服务器名称。""" + excluded_server_calls.append(servers) + + chain.librarys = lambda server: library_calls.append(server) or [] + monkeypatch.setattr(MEDIA_SERVER_CHAIN_MODULE, "MediaServerOper", FakeMediaServerOper) + monkeypatch.setattr( + MEDIA_SERVER_CHAIN_MODULE.ServiceConfigHelper, + "get_mediaserver_configs", + lambda: [ + SimpleNamespace(name="plex-a", enabled=True, sync_libraries=["all"]), + SimpleNamespace(name="plex-b", enabled=True, sync_libraries=["all"]), + ], + ) + + chain.sync(server="plex-a") + + assert library_calls == ["plex-a"] + assert excluded_server_calls == [["plex-a", "plex-b"]] diff --git a/tests/test_mediaserver_sync_scheduler.py b/tests/test_mediaserver_sync_scheduler.py new file mode 100644 index 00000000..2de6f4b6 --- /dev/null +++ b/tests/test_mediaserver_sync_scheduler.py @@ -0,0 +1,33 @@ +from app.schemas.system import MediaServerConf +from app.scheduler import Scheduler + + +def test_build_mediaserver_sync_schedules_uses_server_interval_and_legacy_fallback(): + """媒体服务器自动任务应支持独立周期,并在缺省时回退旧全局值。""" + schedules = Scheduler._build_mediaserver_sync_schedules( + mediaservers=[ + MediaServerConf(name="default", enabled=True), + MediaServerConf(name="custom", enabled=True, sync_interval=12), + MediaServerConf(name="disabled-sync", enabled=True, sync_interval=0), + MediaServerConf(name="disabled-server", enabled=False, sync_interval=3), + ], + default_interval=6, + ) + + assert [(item["server"], item["interval"]) for item in schedules] == [ + ("default", 6), + ("custom", 12), + ] + assert len({item["id"] for item in schedules}) == 2 + assert all(item["id"].startswith("mediaserver_sync_") for item in schedules) + + +def test_build_mediaserver_sync_schedules_keeps_ids_stable(): + """同名媒体服务器重载配置后应生成稳定的自动任务标识。""" + mediaservers = [MediaServerConf(name="My Plex", enabled=True, sync_interval=8)] + + first = Scheduler._build_mediaserver_sync_schedules(mediaservers, 6) + second = Scheduler._build_mediaserver_sync_schedules(mediaservers, 24) + + assert first[0]["id"] == second[0]["id"] + assert first[0]["interval"] == second[0]["interval"] == 8 diff --git a/tests/test_transferhistory_media_source_migration.py b/tests/test_transferhistory_media_source_migration.py new file mode 100644 index 00000000..389368ab --- /dev/null +++ b/tests/test_transferhistory_media_source_migration.py @@ -0,0 +1,82 @@ +import importlib +from types import SimpleNamespace +from unittest.mock import Mock + +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + +from app.core.meta import MetaBase +from app.db.transferhistory_oper import TransferHistoryOper +from app.schemas import FileItem + + +def test_transferhistory_migration_backfills_existing_source_ids(monkeypatch) -> None: + """迁移应把存量TMDB和豆瓣字段回填到统一来源字段。""" + migration = importlib.import_module( + "database.versions.e6a1c4b8d2f0_2_2_13" + ) + engine = sa.create_engine("sqlite://") + metadata = sa.MetaData() + transfer_history = sa.Table( + "transferhistory", + metadata, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("tmdbid", sa.Integer()), + sa.Column("doubanid", sa.String()), + ) + + with engine.begin() as connection: + metadata.create_all(connection) + connection.execute( + transfer_history.insert(), + [ + {"id": 1, "tmdbid": 123, "doubanid": "ignored"}, + {"id": 2, "tmdbid": None, "doubanid": "456"}, + {"id": 3, "tmdbid": None, "doubanid": None}, + ], + ) + context = MigrationContext.configure(connection) + monkeypatch.setattr(migration, "op", Operations(context)) + + migration.upgrade() + + migrated = sa.Table( + "transferhistory", + sa.MetaData(), + autoload_with=connection, + ) + rows = connection.execute( + sa.select(migrated).order_by(migrated.c.id) + ).mappings().all() + + assert rows[0]["media_source"] == "themoviedb" + assert rows[0]["media_id"] == "123" + assert rows[1]["media_source"] == "douban" + assert rows[1]["media_id"] == "456" + assert rows[2]["media_source"] is None + assert rows[2]["media_id"] is None + + +def test_failed_transfer_history_preserves_explicit_media_source() -> None: + """识别失败记录也应保存文件名中显式指定的数据源ID。""" + oper = object.__new__(TransferHistoryOper) + oper.add_force = Mock(return_value=SimpleNamespace(id=1)) + meta = MetaBase("Frieren") + meta.cn_name = "Frieren" + meta.media_source = "anilist" + meta.media_id = "154587" + + oper.add_fail( + fileitem=FileItem( + storage="local", + path="/downloads/Frieren.mkv", + type="file", + ), + mode="copy", + meta=meta, + ) + + call = oper.add_force.call_args + assert call.kwargs["media_source"] == "anilist" + assert call.kwargs["media_id"] == "154587"