From 3b709b7f2e7c6b1e5fa3fdb3f5c6568171b78310 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 21 Jul 2026 20:20:09 +0800 Subject: [PATCH] feat: unify media source identity flow (#6129) --- app/agent/prompt/System Tasks.yaml | 8 +- app/agent/prompt/transfer_redo.py | 8 + app/agent/tools/impl/add_subscribe.py | 12 + app/agent/tools/impl/delete_subscribe.py | 9 +- app/agent/tools/impl/get_recommendations.py | 4 + app/agent/tools/impl/query_library_exists.py | 45 +- app/agent/tools/impl/query_media_detail.py | 57 +- .../tools/impl/query_popular_subscribes.py | 5 + .../tools/impl/query_subscribe_history.py | 3 + .../tools/impl/query_subscribe_shares.py | 3 + app/agent/tools/impl/query_subscribes.py | 16 + .../tools/impl/query_transfer_history.py | 8 + app/agent/tools/impl/recognize_media.py | 9 +- app/agent/tools/impl/search_media.py | 6 + app/agent/tools/impl/search_person_credits.py | 6 + app/agent/tools/impl/search_subscribe.py | 6 +- app/agent/tools/impl/search_torrents.py | 51 +- app/agent/tools/impl/transfer_file.py | 20 + app/api/endpoints/download.py | 10 +- app/api/endpoints/media.py | 110 ++-- app/api/endpoints/mediaserver.py | 4 +- app/api/endpoints/search.py | 537 ++++-------------- app/api/endpoints/subscribe.py | 103 ++-- app/api/endpoints/torrent.py | 18 +- app/api/endpoints/transfer.py | 12 +- app/chain/__init__.py | 105 ++-- app/chain/download.py | 55 +- app/chain/message.py | 11 +- app/chain/search.py | 251 ++++++-- app/chain/subscribe.py | 472 ++++++++++----- app/chain/torrents.py | 78 ++- app/chain/transfer.py | 64 ++- app/core/context.py | 13 +- app/core/meta/infopath.py | 5 +- app/core/meta/metabase.py | 2 + app/db/downloadhistory_oper.py | 20 +- app/db/models/downloadfailure.py | 8 + app/db/models/downloadhistory.py | 37 +- app/db/models/subscribe.py | 233 +++++--- app/db/models/subscribehistory.py | 86 ++- app/db/models/transferhistory.py | 3 + app/db/subscribe_oper.py | 148 +++-- app/db/transferhistory_oper.py | 6 + app/helper/message.py | 19 + app/helper/server.py | 15 +- app/helper/torrent.py | 13 +- app/locales/en-US.json | 2 + app/locales/zh-TW.json | 2 + app/schemas/context.py | 10 +- app/schemas/event.py | 4 + app/schemas/history.py | 12 + app/schemas/subscribe.py | 6 + app/schemas/transfer.py | 4 + app/utils/media.py | 112 ++++ app/workflow/actions/add_subscribe.py | 3 + app/workflow/actions/fetch_torrents.py | 4 + database/versions/f7b2d5c9a301_2_2_14.py | 209 +++++++ docs/mcp-api.md | 18 +- skills/moviepilot-api/SKILL.md | 16 +- tests/test_delete_transfer_history_tool.py | 8 + tests/test_download_chain.py | 2 + tests/test_history_batch_ai_redo_prompt.py | 4 + tests/test_media_identity_migration.py | 116 ++++ tests/test_media_interaction.py | 4 +- tests/test_media_source_routing.py | 43 +- ...st_media_source_signature_compatibility.py | 58 ++ tests/test_message_media_identity.py | 41 ++ tests/test_metainfo.py | 2 + tests/test_search_media_sources.py | 150 +++++ tests/test_subscribe_chain.py | 47 +- tests/test_subscribe_endpoint.py | 1 + tests/test_subscribe_files_info.py | 7 + tests/test_subscribe_search_state.py | 4 + tests/test_subscribe_source_keyword.py | 3 + tests/test_torrents_subscribe_candidates.py | 14 + tests/test_transfer_failed_retry_buttons.py | 8 + tests/test_transfer_history_retransfer.py | 4 + tests/test_transfer_job_manager.py | 8 + 78 files changed, 2519 insertions(+), 1121 deletions(-) create mode 100644 app/utils/media.py create mode 100644 database/versions/f7b2d5c9a301_2_2_14.py create mode 100644 tests/test_media_identity_migration.py create mode 100644 tests/test_media_source_signature_compatibility.py create mode 100644 tests/test_message_media_identity.py create mode 100644 tests/test_search_media_sources.py diff --git a/app/agent/prompt/System Tasks.yaml b/app/agent/prompt/System Tasks.yaml index 49c1cc7de..abd78128a 100644 --- a/app/agent/prompt/System Tasks.yaml +++ b/app/agent/prompt/System Tasks.yaml @@ -79,6 +79,10 @@ task_types: - "- Transfer mode: {transfer_mode}" - "- Current TMDB ID: {tmdbid}" - "- Current Douban ID: {doubanid}" + - "- Current Bangumi ID: {bangumiid}" + - "- Current AniList ID: {anilistid}" + - "- Current media source: {media_source}" + - "- Current source-native ID: {media_id}" - "- Error message: {error_message}" steps_title: "Required workflow" steps: @@ -90,7 +94,7 @@ task_types: - "Only continue when you have high confidence in the target media." - "Before re-organizing, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file." - "Then use `transfer_file` to organize the source path directly." - - "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, tmdbid or doubanid, and media_type." + - "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, and media_type." - "If this record is already correct and no re-organize is needed, do not perform destructive actions; simply report that no change is necessary." task_rules: - "Do NOT rely on previous chat context. Work only from the record above." @@ -116,7 +120,7 @@ task_types: - "If a source file no longer exists or cannot be safely processed, skip that record and note the reason." - "Before re-organizing a record, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file." - "Then use `transfer_file` to organize the source path directly." - - "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, tmdbid or doubanid, and media_type." + - "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, and media_type." - "If a record is already correct and no re-organize is needed, do not perform destructive actions; simply mark it as skipped." - "Report only the aggregate outcome, including how many records succeeded, skipped, and failed." task_rules: diff --git a/app/agent/prompt/transfer_redo.py b/app/agent/prompt/transfer_redo.py index eabc6e1bd..cdb5c0642 100644 --- a/app/agent/prompt/transfer_redo.py +++ b/app/agent/prompt/transfer_redo.py @@ -32,6 +32,10 @@ def build_manual_redo_template_context(history: Any) -> dict[str, int | str]: "transfer_mode": history.mode or "unknown", "tmdbid": history.tmdbid or "none", "doubanid": history.doubanid or "none", + "bangumiid": history.bangumiid or "none", + "anilistid": history.anilistid or "none", + "media_source": history.media_source or "none", + "media_id": history.media_id or "none", "error_message": history.errmsg or "none", } @@ -55,6 +59,10 @@ def format_manual_redo_record_context(history: Any) -> str: f"- Transfer mode: {context['transfer_mode']}", f"- Current TMDB ID: {context['tmdbid']}", f"- Current Douban ID: {context['doubanid']}", + f"- Current Bangumi ID: {context['bangumiid']}", + f"- Current AniList ID: {context['anilistid']}", + f"- Current media source: {context['media_source']}", + f"- Current source-native ID: {context['media_id']}", f"- Error message: {context['error_message']}", ] ) diff --git a/app/agent/tools/impl/add_subscribe.py b/app/agent/tools/impl/add_subscribe.py index 879284e1b..c031b3c54 100644 --- a/app/agent/tools/impl/add_subscribe.py +++ b/app/agent/tools/impl/add_subscribe.py @@ -39,6 +39,10 @@ class AddSubscribeInput(BaseModel): None, description="Douban ID for precise media identification (optional, alternative to tmdb_id)", ) + bangumi_id: Optional[int] = Field(None, description="Bangumi media ID") + anilist_id: Optional[int] = Field(None, description="AniList media ID") + media_source: Optional[str] = Field(None, description="Media metadata source") + media_id: Optional[str] = Field(None, description="Native ID for media_source") start_episode: Optional[int] = Field( None, description="Starting episode number for TV shows (optional, defaults to 1 if not specified)", @@ -144,6 +148,10 @@ class AddSubscribeTool(MoviePilotTool): season: Optional[int] = None, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None, + bangumi_id: Optional[int] = None, + anilist_id: Optional[int] = None, + media_source: Optional[str] = None, + media_id: Optional[str] = None, start_episode: Optional[int] = None, total_episode: Optional[int] = None, quality: Optional[str] = None, @@ -197,6 +205,10 @@ class AddSubscribeTool(MoviePilotTool): year=year, tmdbid=tmdb_id, doubanid=douban_id, + bangumiid=bangumi_id, + anilistid=anilist_id, + media_source=media_source, + media_id=media_id, season=season, username=subscribe_username, **subscribe_kwargs, diff --git a/app/agent/tools/impl/delete_subscribe.py b/app/agent/tools/impl/delete_subscribe.py index 91fb100f8..334abc6f6 100644 --- a/app/agent/tools/impl/delete_subscribe.py +++ b/app/agent/tools/impl/delete_subscribe.py @@ -54,7 +54,14 @@ class DeleteSubscribeTool(MoviePilotTool): await subscribe_oper.async_delete(subscribe_id) # 分享订阅统计刷新本身已异步化,这里只需要在删除后触发即可。 MoviePilotServerHelper.sub_done_async( - {"tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid} + { + "tmdbid": subscribe.tmdbid, + "doubanid": subscribe.doubanid, + "bangumiid": subscribe.bangumiid, + "anilistid": subscribe.anilistid, + "media_source": subscribe.media_source, + "media_id": subscribe.media_id, + } ) # 发送事件 diff --git a/app/agent/tools/impl/get_recommendations.py b/app/agent/tools/impl/get_recommendations.py index c6331002a..6df9234a0 100644 --- a/app/agent/tools/impl/get_recommendations.py +++ b/app/agent/tools/impl/get_recommendations.py @@ -210,6 +210,10 @@ class GetRecommendationsTool(MoviePilotTool): "tmdb_id": r.get("tmdb_id"), "imdb_id": r.get("imdb_id"), "douban_id": r.get("douban_id"), + "bangumi_id": r.get("bangumi_id"), + "anilist_id": r.get("anilist_id"), + "media_source": r.get("source"), + "media_id": r.get("media_id"), "vote_average": r.get("vote_average"), "poster_path": r.get("poster_path"), "detail_link": r.get("detail_link"), diff --git a/app/agent/tools/impl/query_library_exists.py b/app/agent/tools/impl/query_library_exists.py index 10aa0436e..3f08f21e0 100644 --- a/app/agent/tools/impl/query_library_exists.py +++ b/app/agent/tools/impl/query_library_exists.py @@ -77,8 +77,12 @@ def _build_tv_server_result(existing_seasons: OrderedDict, total_seasons: Ordere class QueryLibraryExistsInput(BaseModel): """查询媒体库工具的输入参数模型""" - tmdb_id: Optional[int] = Field(None, description="TMDB ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.") - douban_id: Optional[str] = Field(None, description="Douban ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.") + tmdb_id: Optional[int] = Field(None, description="TMDB media ID") + douban_id: Optional[str] = Field(None, description="Douban media ID") + bangumi_id: Optional[int] = Field(None, description="Bangumi media ID") + anilist_id: Optional[int] = Field(None, description="AniList media ID") + media_source: Optional[str] = Field(None, description="Media metadata source") + media_id: Optional[str] = Field(None, description="Native ID for media_source") media_type: Optional[str] = Field(None, description="Allowed values: movie, tv") @@ -89,21 +93,24 @@ class QueryLibraryExistsTool(MoviePilotTool): ToolTag.Library, ToolTag.Media, ] - description: str = "Check whether media already exists in Plex, Emby, or Jellyfin by media ID. Results are grouped by media server; TV results include existing episodes, total episodes, and missing episodes/seasons. Requires tmdb_id or douban_id from search_media." + description: str = "Check whether media already exists in Plex, Emby, or Jellyfin by a TMDB, Douban, Bangumi, AniList, or source-native media ID. Results are grouped by media server; TV results include existing episodes, total episodes, and missing episodes/seasons." args_schema: Type[BaseModel] = QueryLibraryExistsInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据查询参数生成友好的提示消息""" - tmdb_id = kwargs.get("tmdb_id") - douban_id = kwargs.get("douban_id") media_type = kwargs.get("media_type") - - if tmdb_id: - message = f"查询媒体库: TMDB={tmdb_id}" - elif douban_id: - message = f"查询媒体库: 豆瓣={douban_id}" - else: - message = "查询媒体库" + identities = ( + ("TMDB", kwargs.get("tmdb_id")), + ("豆瓣", kwargs.get("douban_id")), + ("Bangumi", kwargs.get("bangumi_id")), + ("AniList", kwargs.get("anilist_id")), + (kwargs.get("media_source") or "媒体源", kwargs.get("media_id")), + ) + label, identity = next( + ((label, identity) for label, identity in identities if identity is not None), + (None, None), + ) + message = f"查询媒体库: {label}={identity}" if label else "查询媒体库" if media_type: message += f" [{media_type}]" return message @@ -119,11 +126,13 @@ class QueryLibraryExistsTool(MoviePilotTool): return MediaServerChain().media_exists(mediainfo=mediainfo, server=server) async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None, + bangumi_id: Optional[int] = None, anilist_id: Optional[int] = None, + media_source: Optional[str] = None, media_id: Optional[str] = None, media_type: Optional[str] = None, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}") try: - if not tmdb_id and not douban_id: - return "参数错误:tmdb_id 和 douban_id 至少需要提供一个,请先使用 search_media 工具获取媒体 ID。" + if not any((tmdb_id, douban_id, bangumi_id, anilist_id, media_id)): + return "参数错误:至少需要提供一个媒体 ID,请先使用 search_media 工具获取媒体信息。" media_type_enum = None if media_type: @@ -135,11 +144,15 @@ class QueryLibraryExistsTool(MoviePilotTool): mediainfo = await media_chain.async_recognize_media( tmdbid=tmdb_id, doubanid=douban_id, + bangumiid=bangumi_id, + anilistid=anilist_id, + source=media_source, + mediaid=media_id, mtype=media_type_enum, ) if not mediainfo: - media_id = f"TMDB={tmdb_id}" if tmdb_id else f"豆瓣={douban_id}" - return f"未识别到媒体信息: {media_id}" + identity = media_id or tmdb_id or douban_id or bangumi_id or anilist_id + return f"未识别到媒体信息: {identity}" # 2. 遍历所有媒体服务器,分别查询存在性信息 server_results = OrderedDict() diff --git a/app/agent/tools/impl/query_media_detail.py b/app/agent/tools/impl/query_media_detail.py index 0df009090..867094316 100644 --- a/app/agent/tools/impl/query_media_detail.py +++ b/app/agent/tools/impl/query_media_detail.py @@ -20,6 +20,10 @@ class QueryMediaDetailInput(BaseModel): """查询媒体详情工具的输入参数模型""" tmdb_id: Optional[int] = Field(None, description="TMDB ID of the media (movie or TV series, can be obtained from search_media tool)") douban_id: Optional[str] = Field(None, description="Douban ID of the media (alternative to tmdb_id)") + bangumi_id: Optional[int] = Field(None, description="Bangumi media ID") + anilist_id: Optional[int] = Field(None, description="AniList media ID") + media_source: Optional[str] = Field(None, description="Media metadata source") + media_id: Optional[str] = Field(None, description="Native ID for media_source") media_type: str = Field(..., description="Allowed values: movie, tv") @@ -29,24 +33,37 @@ class QueryMediaDetailTool(MoviePilotTool): ToolTag.Read, ToolTag.Media, ] - description: str = "Query supplementary media details from TMDB by ID and media_type. Accepts tmdb_id or douban_id (at least one required). media_type accepts 'movie' or 'tv'. Returns non-duplicated detail fields such as status, genres, directors, actors, and season info for TV series." + description: str = "Query supplementary media details from a metadata source by ID and media_type. Accepts a TMDB, Douban, Bangumi, AniList, or source-native media ID. media_type accepts 'movie' or 'tv'. Returns non-duplicated detail fields such as status, genres, directors, actors, and season info for TV series." args_schema: Type[BaseModel] = QueryMediaDetailInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据查询参数生成友好的提示消息""" - tmdb_id = kwargs.get("tmdb_id") - douban_id = kwargs.get("douban_id") - if tmdb_id: - return f"查询媒体详情: TMDB ID {tmdb_id}" - return f"查询媒体详情: 豆瓣 ID {douban_id}" + identities = ( + ("TMDB", kwargs.get("tmdb_id")), + ("豆瓣", kwargs.get("douban_id")), + ("Bangumi", kwargs.get("bangumi_id")), + ("AniList", kwargs.get("anilist_id")), + ) + for label, identity in identities: + if identity is not None: + return f"查询媒体详情: {label} ID {identity}" + return ( + f"查询媒体详情: {kwargs.get('media_source') or '媒体源'} " + f"ID {kwargs.get('media_id')}" + ) - async def run(self, media_type: str, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None, **kwargs) -> str: + async def run( + self, media_type: str, tmdb_id: Optional[int] = None, + douban_id: Optional[str] = None, bangumi_id: Optional[int] = None, + anilist_id: Optional[int] = None, media_source: Optional[str] = None, + media_id: Optional[str] = None, **kwargs, + ) -> str: logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}") - if tmdb_id is None and douban_id is None: + if not any((tmdb_id, douban_id, bangumi_id, anilist_id, media_id)): return json.dumps({ "success": False, - "message": "必须提供 tmdb_id 或 douban_id 之一" + "message": "必须提供至少一个媒体 ID" }, ensure_ascii=False) try: @@ -59,10 +76,22 @@ class QueryMediaDetailTool(MoviePilotTool): "message": f"无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'" }, ensure_ascii=False) - mediainfo = await media_chain.async_recognize_media(tmdbid=tmdb_id, doubanid=douban_id, mtype=media_type_enum) + mediainfo = await media_chain.async_recognize_media( + tmdbid=tmdb_id, + doubanid=douban_id, + bangumiid=bangumi_id, + anilistid=anilist_id, + source=media_source, + mediaid=media_id, + mtype=media_type_enum, + ) if not mediainfo: - id_info = f"TMDB ID {tmdb_id}" if tmdb_id else f"豆瓣 ID {douban_id}" + id_info = ( + f"{media_source or '媒体源'} ID {media_id}" + if media_id else + f"媒体 ID {tmdb_id or douban_id or bangumi_id or anilist_id}" + ) return json.dumps({ "success": False, "message": f"未找到 {id_info} 的媒体信息" @@ -139,5 +168,9 @@ class QueryMediaDetailTool(MoviePilotTool): "success": False, "message": error_message, "tmdb_id": tmdb_id, - "douban_id": douban_id + "douban_id": douban_id, + "bangumi_id": bangumi_id, + "anilist_id": anilist_id, + "media_source": media_source, + "media_id": media_id, }, ensure_ascii=False) diff --git a/app/agent/tools/impl/query_popular_subscribes.py b/app/agent/tools/impl/query_popular_subscribes.py index 41e0555e9..882dddf75 100644 --- a/app/agent/tools/impl/query_popular_subscribes.py +++ b/app/agent/tools/impl/query_popular_subscribes.py @@ -126,6 +126,8 @@ class QueryPopularSubscribesTool(MoviePilotTool): media.year = sub.get("year") media.douban_id = sub.get("doubanid") media.bangumi_id = sub.get("bangumiid") + media.anilist_id = sub.get("anilistid") + media.source = sub.get("media_source") media.tvdb_id = sub.get("tvdbid") media.imdb_id = sub.get("imdbid") media.season = sub.get("season") @@ -149,6 +151,9 @@ class QueryPopularSubscribesTool(MoviePilotTool): "tmdb_id": media_dict.get("tmdb_id"), "douban_id": media_dict.get("douban_id"), "bangumi_id": media_dict.get("bangumi_id"), + "anilist_id": media_dict.get("anilist_id"), + "media_source": media_dict.get("source"), + "media_id": media_dict.get("media_id"), "tvdb_id": media_dict.get("tvdb_id"), "imdb_id": media_dict.get("imdb_id"), "season": media_dict.get("season"), diff --git a/app/agent/tools/impl/query_subscribe_history.py b/app/agent/tools/impl/query_subscribe_history.py index 4bd943be2..c5d10bda6 100644 --- a/app/agent/tools/impl/query_subscribe_history.py +++ b/app/agent/tools/impl/query_subscribe_history.py @@ -170,6 +170,9 @@ class QuerySubscribeHistoryTool(MoviePilotTool): "tmdbid": record.tmdbid, "doubanid": record.doubanid, "bangumiid": record.bangumiid, + "anilistid": record.anilistid, + "media_source": record.media_source, + "media_id": record.media_id, "poster": record.poster, "vote": record.vote, "total_episode": record.total_episode, diff --git a/app/agent/tools/impl/query_subscribe_shares.py b/app/agent/tools/impl/query_subscribe_shares.py index 447621cd2..0e51297e9 100644 --- a/app/agent/tools/impl/query_subscribe_shares.py +++ b/app/agent/tools/impl/query_subscribe_shares.py @@ -97,6 +97,9 @@ class QuerySubscribeSharesTool(MoviePilotTool): "tmdbid": share.get("tmdbid"), "doubanid": share.get("doubanid"), "bangumiid": share.get("bangumiid"), + "anilistid": share.get("anilistid"), + "media_source": share.get("media_source"), + "media_id": share.get("media_id"), "poster": share.get("poster"), "vote": share.get("vote"), "share_title": share.get("share_title"), diff --git a/app/agent/tools/impl/query_subscribes.py b/app/agent/tools/impl/query_subscribes.py index 458e114f0..5e2ff87b3 100644 --- a/app/agent/tools/impl/query_subscribes.py +++ b/app/agent/tools/impl/query_subscribes.py @@ -63,6 +63,10 @@ class QuerySubscribesInput(BaseModel): None, description="Filter by Douban ID to check if a specific media is already subscribed", ) + bangumi_id: Optional[int] = Field(None, description="Filter by Bangumi ID") + anilist_id: Optional[int] = Field(None, description="Filter by AniList ID") + media_source: Optional[str] = Field(None, description="Filter by media source") + media_id: Optional[str] = Field(None, description="Filter by source-native media ID") page: Optional[int] = Field( 1, description="Page number for pagination (default: 1, 100 items per page)" ) @@ -104,6 +108,10 @@ class QuerySubscribesTool(MoviePilotTool): media_type: Optional[str] = "all", tmdb_id: Optional[int] = None, douban_id: Optional[str] = None, + bangumi_id: Optional[int] = None, + anilist_id: Optional[int] = None, + media_source: Optional[str] = None, + media_id: Optional[str] = None, page: Optional[int] = 1, **kwargs, ) -> str: @@ -130,6 +138,14 @@ class QuerySubscribesTool(MoviePilotTool): continue if douban_id is not None and sub.doubanid != douban_id: continue + if bangumi_id is not None and sub.bangumiid != bangumi_id: + continue + if anilist_id is not None and sub.anilistid != anilist_id: + continue + if media_source is not None and sub.media_source != media_source: + continue + if media_id is not None and sub.media_id != media_id: + continue filtered_subscribes.append(sub) if filtered_subscribes: total_count = len(filtered_subscribes) diff --git a/app/agent/tools/impl/query_transfer_history.py b/app/agent/tools/impl/query_transfer_history.py index 8b33d2f7f..39e8bbbe0 100644 --- a/app/agent/tools/impl/query_transfer_history.py +++ b/app/agent/tools/impl/query_transfer_history.py @@ -120,6 +120,14 @@ class QueryTransferHistoryTool(MoviePilotTool): simplified["imdbid"] = record.imdbid if record.doubanid: simplified["doubanid"] = record.doubanid + if record.bangumiid: + simplified["bangumiid"] = record.bangumiid + if record.anilistid: + simplified["anilistid"] = record.anilistid + if record.media_source: + simplified["media_source"] = record.media_source + if record.media_id: + simplified["media_id"] = record.media_id simplified_records.append(simplified) result_json = json.dumps(simplified_records, ensure_ascii=False, indent=2) diff --git a/app/agent/tools/impl/recognize_media.py b/app/agent/tools/impl/recognize_media.py index ee43684a3..1f111f5ba 100644 --- a/app/agent/tools/impl/recognize_media.py +++ b/app/agent/tools/impl/recognize_media.py @@ -142,6 +142,9 @@ class RecognizeMediaTool(MoviePilotTool): "imdb_id": media_info.get("imdb_id"), "douban_id": media_info.get("douban_id"), "bangumi_id": media_info.get("bangumi_id"), + "anilist_id": media_info.get("anilist_id"), + "media_source": media_info.get("source"), + "media_id": media_info.get("media_id"), "overview": media_info.get("overview"), "vote_average": media_info.get("vote_average"), "poster_path": media_info.get("poster_path"), @@ -167,7 +170,11 @@ class RecognizeMediaTool(MoviePilotTool): "season_episode": meta_info.get("season_episode"), "episode_list": meta_info.get("episode_list"), "tmdbid": meta_info.get("tmdbid"), - "doubanid": meta_info.get("doubanid") + "doubanid": meta_info.get("doubanid"), + "bangumiid": meta_info.get("bangumiid"), + "anilistid": meta_info.get("anilistid"), + "media_source": meta_info.get("media_source"), + "media_id": meta_info.get("media_id"), } return json.dumps(result, ensure_ascii=False, indent=2) diff --git a/app/agent/tools/impl/search_media.py b/app/agent/tools/impl/search_media.py index f5af59922..0c8cc85d9 100644 --- a/app/agent/tools/impl/search_media.py +++ b/app/agent/tools/impl/search_media.py @@ -10,6 +10,7 @@ from app.agent.tools.tags import ToolTag from app.chain.media import MediaChain from app.log import logger from app.schemas.types import MediaType, media_type_to_agent +from app.utils.media import resolve_media_identity class SearchMediaInput(BaseModel): @@ -83,6 +84,7 @@ class SearchMediaTool(MoviePilotTool): # 精简字段,只保留关键信息 simplified_results = [] for r in limited_results: + media_source, media_id = resolve_media_identity(media=r) simplified = { "title": r.title, "en_title": r.en_title, @@ -92,6 +94,10 @@ class SearchMediaTool(MoviePilotTool): "tmdb_id": r.tmdb_id, "imdb_id": r.imdb_id, "douban_id": r.douban_id, + "bangumi_id": r.bangumi_id, + "anilist_id": r.anilist_id, + "media_source": media_source, + "media_id": media_id, "overview": r.overview[:200] + "..." if r.overview and len(r.overview) > 200 else r.overview, "vote_average": r.vote_average, "poster_path": r.poster_path, diff --git a/app/agent/tools/impl/search_person_credits.py b/app/agent/tools/impl/search_person_credits.py index aed312f2a..247634011 100644 --- a/app/agent/tools/impl/search_person_credits.py +++ b/app/agent/tools/impl/search_person_credits.py @@ -11,6 +11,7 @@ from app.chain.douban import DoubanChain from app.chain.tmdb import TmdbChain from app.chain.bangumi import BangumiChain from app.log import logger +from app.utils.media import resolve_media_identity class SearchPersonCreditsInput(BaseModel): @@ -59,6 +60,7 @@ class SearchPersonCreditsTool(MoviePilotTool): # 精简字段,只保留关键信息 simplified_results = [] for media in limited_medias: + media_source, media_id = resolve_media_identity(media=media) simplified = { "title": media.title, "en_title": media.en_title, @@ -68,6 +70,10 @@ class SearchPersonCreditsTool(MoviePilotTool): "tmdb_id": media.tmdb_id, "imdb_id": media.imdb_id, "douban_id": media.douban_id, + "bangumi_id": media.bangumi_id, + "anilist_id": media.anilist_id, + "media_source": media_source, + "media_id": media_id, "overview": media.overview[:200] + "..." if media.overview and len(media.overview) > 200 else media.overview, "vote_average": media.vote_average, "poster_path": media.poster_path, diff --git a/app/agent/tools/impl/search_subscribe.py b/app/agent/tools/impl/search_subscribe.py index 0487ebe05..c30e820b0 100644 --- a/app/agent/tools/impl/search_subscribe.py +++ b/app/agent/tools/impl/search_subscribe.py @@ -70,7 +70,11 @@ class SearchSubscribeTool(MoviePilotTool): "total_episode": subscribe.total_episode, "lack_episode": subscribe.lack_episode, "tmdbid": subscribe.tmdbid, - "doubanid": subscribe.doubanid + "doubanid": subscribe.doubanid, + "bangumiid": subscribe.bangumiid, + "anilistid": subscribe.anilistid, + "media_source": subscribe.media_source, + "media_id": subscribe.media_id, } # 检查订阅状态 diff --git a/app/agent/tools/impl/search_torrents.py b/app/agent/tools/impl/search_torrents.py index 720da1be5..39ab2f7c9 100644 --- a/app/agent/tools/impl/search_torrents.py +++ b/app/agent/tools/impl/search_torrents.py @@ -20,13 +20,18 @@ from ._torrent_search_utils import ( class SearchTorrentsInput(BaseModel): """搜索种子工具的输入参数模型""" - tmdb_id: Optional[int] = Field(None, description="TMDB ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.") - douban_id: Optional[str] = Field(None, description="Douban ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.") + tmdb_id: Optional[int] = Field(None, description="TMDB media ID") + douban_id: Optional[str] = Field(None, description="Douban media ID") + bangumi_id: Optional[int] = Field(None, description="Bangumi media ID") + anilist_id: Optional[int] = Field(None, description="AniList media ID") + media_source: Optional[str] = Field(None, description="Media metadata source") + media_id: Optional[str] = Field(None, description="Native ID for media_source") media_type: Optional[str] = Field(None, description="Allowed values: movie, tv") area: Optional[str] = Field(None, description="Search scope: 'title' (default) or 'imdbid'") sites: Optional[List[int]] = Field(None, description="Array of specific site IDs to search on (optional, if not provided searches all configured sites)") + class SearchTorrentsTool(MoviePilotTool): name: str = "search_torrents" tags: list[str] = [ @@ -35,23 +40,27 @@ class SearchTorrentsTool(MoviePilotTool): ToolTag.Site, ToolTag.Media, ] - description: str = ("Search for torrent files by media ID across configured indexer sites, cache the matched results, " - "and return available filter options for follow-up selection. " - "Requires tmdb_id or douban_id (can be obtained from search_media tool) for accurate matching.") + description: str = ( + "Search for torrent files by media ID across configured indexer sites, cache the matched results, " + "and return available filter options for follow-up selection. " + "Accepts a TMDB, Douban, Bangumi, AniList, or source-native media ID for accurate matching.") args_schema: Type[BaseModel] = SearchTorrentsInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据搜索参数生成友好的提示消息""" - tmdb_id = kwargs.get("tmdb_id") - douban_id = kwargs.get("douban_id") media_type = kwargs.get("media_type") - - if tmdb_id: - message = f"搜索种子: TMDB={tmdb_id}" - elif douban_id: - message = f"搜索种子: 豆瓣={douban_id}" - else: - message = "搜索种子" + identities = ( + ("TMDB", kwargs.get("tmdb_id")), + ("豆瓣", kwargs.get("douban_id")), + ("Bangumi", kwargs.get("bangumi_id")), + ("AniList", kwargs.get("anilist_id")), + (kwargs.get("media_source") or "媒体源", kwargs.get("media_id")), + ) + label, identity = next( + ((label, identity) for label, identity in identities if identity is not None), + (None, None), + ) + message = f"搜索种子: {label}={identity}" if label else "搜索种子" if media_type: message += f" [{media_type}]" return message @@ -62,13 +71,15 @@ class SearchTorrentsTool(MoviePilotTool): return SystemConfigOper().get(SystemConfigKey.IndexerSites) or [] async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None, + bangumi_id: Optional[int] = None, anilist_id: Optional[int] = None, + media_source: Optional[str] = None, media_id: Optional[str] = None, media_type: Optional[str] = None, area: Optional[str] = None, sites: Optional[List[int]] = None, **kwargs) -> str: logger.info( f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}, area={area}, sites={sites}") - if not tmdb_id and not douban_id: - return "参数错误:tmdb_id 和 douban_id 至少需要提供一个,请先使用 search_media 工具获取媒体 ID。" + if not any((tmdb_id, douban_id, bangumi_id, anilist_id, media_id)): + return "参数错误:至少需要提供一个媒体 ID,请先使用 search_media 工具获取媒体信息。" try: search_chain = SearchChain() @@ -81,6 +92,10 @@ class SearchTorrentsTool(MoviePilotTool): filtered_torrents = await search_chain.async_search_by_id( tmdbid=tmdb_id, doubanid=douban_id, + bangumiid=bangumi_id, + anilistid=anilist_id, + source=media_source, + mediaid=media_id, mtype=media_type_enum, area=area or "title", sites=sites, @@ -107,9 +122,9 @@ class SearchTorrentsTool(MoviePilotTool): }, ensure_ascii=False, indent=2) return result_json else: - media_id = f"TMDB={tmdb_id}" if tmdb_id else f"豆瓣={douban_id}" + identity = media_id or tmdb_id or douban_id or bangumi_id or anilist_id result_json = json.dumps({ - "message": f"未找到相关种子资源: {media_id}", + "message": f"未找到相关种子资源: {identity}", "all_sites": all_sites, "search_site_ids": search_site_ids, }, ensure_ascii=False, indent=2) diff --git a/app/agent/tools/impl/transfer_file.py b/app/agent/tools/impl/transfer_file.py index c60de8864..412f0dba6 100644 --- a/app/agent/tools/impl/transfer_file.py +++ b/app/agent/tools/impl/transfer_file.py @@ -38,6 +38,10 @@ class TransferFileInput(BaseModel): doubanid: Optional[str] = Field( None, description="Douban ID for media identification (optional)" ) + bangumiid: Optional[int] = Field(None, description="Bangumi media ID") + anilistid: Optional[int] = Field(None, description="AniList media ID") + media_source: Optional[str] = Field(None, description="Media metadata source") + media_id: Optional[str] = Field(None, description="Native ID for media_source") season: Optional[int] = Field( None, description="Season number for TV shows (optional)" ) @@ -109,6 +113,10 @@ class TransferFileTool(MoviePilotTool): media_type: Optional[str] = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, + bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, + media_source: Optional[str] = None, + media_id: Optional[str] = None, season: Optional[int] = None, transfer_type: Optional[str] = None, background: Optional[bool] = False, @@ -148,6 +156,10 @@ class TransferFileTool(MoviePilotTool): target_path=target_path_obj, tmdbid=tmdbid, doubanid=doubanid, + bangumiid=bangumiid, + anilistid=anilistid, + media_source=media_source, + media_id=media_id, mtype=media_type_enum, season=season, transfer_type=transfer_type, @@ -178,6 +190,10 @@ class TransferFileTool(MoviePilotTool): media_type: Optional[str] = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, + bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, + media_source: Optional[str] = None, + media_id: Optional[str] = None, season: Optional[int] = None, transfer_type: Optional[str] = None, background: Optional[bool] = False, @@ -200,6 +216,10 @@ class TransferFileTool(MoviePilotTool): media_type, tmdbid, doubanid, + bangumiid, + anilistid, + media_source, + media_id, season, transfer_type, background, diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index 076c0641b..df4d55b89 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -98,6 +98,8 @@ def add( torrent_in: schemas.TorrentInfo, tmdbid: Annotated[int | None, Body()] = None, doubanid: Annotated[str | None, Body()] = None, + bangumiid: Annotated[int | None, Body()] = None, + anilistid: Annotated[int | None, Body()] = None, media_source: Annotated[MediaSource | None, Body()] = None, media_id: Annotated[str | None, Body()] = None, downloader: Annotated[str | None, Body()] = None, @@ -111,13 +113,15 @@ def add( # 元数据 metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description) # 媒体信息 - if tmdbid or doubanid or media_id: + if tmdbid or doubanid or bangumiid or anilistid or media_id: mediainfo = MediaChain().recognize_media( meta=metainfo, source=media_source, mediaid=media_id, tmdbid=tmdbid, doubanid=doubanid, + bangumiid=bangumiid, + anilistid=anilistid, ) else: mediainfo = MediaChain().recognize_by_meta( @@ -152,6 +156,8 @@ def download_subtitle( subtitle_in: schemas.SubtitleInfo, tmdbid: Annotated[int | None, Body()] = None, doubanid: Annotated[str | None, Body()] = None, + bangumiid: Annotated[int | None, Body()] = None, + anilistid: Annotated[int | None, Body()] = None, media_source: Annotated[MediaSource | None, Body()] = None, media_id: Annotated[str | None, Body()] = None, save_path: Annotated[str | None, Body()] = None, @@ -172,6 +178,8 @@ def download_subtitle( media_id=media_id, tmdbid=tmdbid, doubanid=doubanid, + bangumiid=bangumiid, + anilistid=anilistid, save_path=save_path, username=current_user.name, ) diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 08d56b8e0..65bed9ad8 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Annotated, Any, List, Literal, Optional, Union +from typing import Annotated, Any, List, Optional, Union from fastapi import APIRouter, Depends @@ -16,9 +16,53 @@ from app.db.user_oper import get_current_active_user, get_current_active_superus from app.schemas import MediaType, MediaRecognizeConvertEventData from app.schemas.category import CategoryConfig from app.schemas.types import ChainEventType +from app.utils.media import parse_media_key router = APIRouter() -MediaSource = Literal["themoviedb", "douban", "bangumi", "anilist"] +MediaSource = str + + +def _build_media_seasons( + mediainfo: Any, season: Optional[int] = None, +) -> List[schemas.MediaSeason]: + """将任意数据源的统一媒体信息转换为季信息响应。""" + seasons_info = [] + for item in mediainfo.season_info or []: + season_number = item.get("season_number") + if season is not None and season_number != season: + continue + seasons_info.append(schemas.MediaSeason( + air_date=item.get("air_date"), + episode_count=item.get("episode_count"), + name=item.get("name"), + overview=item.get("overview"), + poster_path=item.get("poster_path"), + season_number=season_number, + vote_average=item.get("vote_average"), + )) + if seasons_info: + return seasons_info + + season_numbers = sorted((mediainfo.seasons or {}).keys()) + if season is not None: + season_numbers = [season] + elif not season_numbers: + season_numbers = [mediainfo.season or 1] + return [ + schemas.MediaSeason( + season_number=season_number, + poster_path=mediainfo.poster_path, + name=f"第 {season_number} 季", + air_date=mediainfo.release_date, + overview=mediainfo.overview, + vote_average=mediainfo.vote_average, + episode_count=( + len((mediainfo.seasons or {}).get(season_number) or []) + or mediainfo.number_of_episodes + ), + ) + for season_number in season_numbers + ] @router.get( @@ -33,8 +77,11 @@ async def recognize( ) -> Any: """ 根据标题、副标题识别媒体信息 + :param title: 标题 + :param subtitle: 副标题 :param custom_words: 临时识别词(每行一条规则),传入时仅在本次识别中生效,不会保存到系统配置 :param source: 请求级识别数据源 + :param _: """ # 识别媒体信息,传入临时识别词时优先于系统配置的识别词生效 metainfo = MetaInfo( @@ -292,13 +339,23 @@ async def seasons( 查询媒体季信息 """ if mediaid: - if mediaid.startswith("tmdb:"): - tmdbid = int(mediaid[5:]) + media_source, source_media_id = parse_media_key(mediaid) + if media_source == "themoviedb": + tmdbid = int(source_media_id) seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid) if seasons_info: if season is not None: return [sea for sea in seasons_info if sea.season_number == season] return seasons_info + elif media_source and source_media_id: + mediainfo = await MediaChain().async_recognize_media( + source=media_source, + mediaid=source_media_id, + mtype=MediaType.TV, + cache=False, + ) + if mediainfo: + return _build_media_seasons(mediainfo, season) if title: meta = MetaInfo(title) if year: @@ -309,7 +366,7 @@ async def seasons( obtain_images=False, ) if mediainfo: - if settings.RECOGNIZE_SOURCE == "themoviedb": + if mediainfo.source == "themoviedb" and mediainfo.tmdb_id: seasons_info = await TmdbChain().async_tmdb_seasons( tmdbid=mediainfo.tmdb_id ) @@ -319,19 +376,7 @@ async def seasons( sea for sea in seasons_info if sea.season_number == season ] return seasons_info - else: - sea = season if season is not None else 1 - return [ - schemas.MediaSeason( - season_number=sea, - poster_path=mediainfo.poster_path, - name=f"第 {sea} 季", - air_date=mediainfo.release_date, - overview=mediainfo.overview, - vote_average=mediainfo.vote_average, - episode_count=mediainfo.number_of_episodes, - ) - ] + return _build_media_seasons(mediainfo, season) return [] @@ -349,21 +394,12 @@ async def detail( mtype = MediaType(type_name) mediainfo = None mediachain = MediaChain() - if mediaid.startswith("tmdb:"): + media_source, source_media_id = parse_media_key(mediaid) + if media_source and source_media_id: mediainfo = await mediachain.async_recognize_media( - tmdbid=int(mediaid[5:]), mtype=mtype - ) - elif mediaid.startswith("douban:"): - mediainfo = await mediachain.async_recognize_media( - doubanid=mediaid[7:], mtype=mtype - ) - elif mediaid.startswith("bangumi:"): - 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 + source=media_source, + mediaid=source_media_id, + mtype=mtype, ) else: # 广播事件解析媒体信息 @@ -377,13 +413,11 @@ async def detail( if event and event.event_data and event.event_data.media_dict: event_data: MediaRecognizeConvertEventData = event.event_data new_id = event_data.media_dict.get("id") - if event_data.convert_type == "themoviedb": + if new_id is not None and event_data.convert_type: mediainfo = await mediachain.async_recognize_media( - tmdbid=new_id, mtype=mtype - ) - elif event_data.convert_type == "douban": - mediainfo = await mediachain.async_recognize_media( - doubanid=new_id, mtype=mtype + source=event_data.convert_type, + mediaid=str(new_id), + mtype=mtype, ) elif title: # 使用名称识别兜底 diff --git a/app/api/endpoints/mediaserver.py b/app/api/endpoints/mediaserver.py index e9dca0f00..f46a25a58 100644 --- a/app/api/endpoints/mediaserver.py +++ b/app/api/endpoints/mediaserver.py @@ -16,6 +16,7 @@ from app.db.systemconfig_oper import SystemConfigOper from app.helper.mediaserver import MediaServerHelper from app.schemas import MediaType, NotExistMediaInfo from app.schemas.types import SystemConfigKey +from app.utils.media import build_media_key, resolve_media_identity router = APIRouter() @@ -130,7 +131,8 @@ def not_exists( exist_flag, no_exists = DownloadChain().get_no_exists_info( meta=meta, mediainfo=mediainfo ) - mediakey = mediainfo.tmdb_id or mediainfo.douban_id + media_source, media_id = resolve_media_identity(media=mediainfo) + mediakey = build_media_key(media_source, media_id) if mediainfo.type == MediaType.MOVIE: # 电影已存在时返回空列表,不存在时返回空对像列表 return [] if exist_flag else [NotExistMediaInfo()] diff --git a/app/api/endpoints/search.py b/app/api/endpoints/search.py index bea61c398..bfd24d8f1 100644 --- a/app/api/endpoints/search.py +++ b/app/api/endpoints/search.py @@ -16,6 +16,7 @@ from app.helper.locale import LocaleHelper from app.log import logger from app.schemas import MediaRecognizeConvertEventData from app.schemas.types import MediaType, ChainEventType +from app.utils.media import parse_media_key, resolve_media_identity from app.utils.security import SecurityUtils router = APIRouter() @@ -44,12 +45,63 @@ def _resolve_media_season( explicit_season: Optional[int], recognized_season: Optional[int], ) -> Optional[int]: - """ - 合并显式季号与识别结果,显式值优先且季 0 属于有效业务值。 - """ + """合并显式季号与识别结果,显式值优先且季 0 属于有效业务值。""" return explicit_season if explicit_season is not None else recognized_season +async def _resolve_media_search_params( + mediaid: str, + media_type: Optional[MediaType] = None, + title: Optional[str] = None, + year: Optional[str] = None, + media_season: Optional[int] = None, +) -> tuple[Optional[dict], str]: + """将任意来源媒体键解析为 SearchChain 可直接使用的识别参数。""" + source, source_media_id = parse_media_key(mediaid) + if source and source_media_id: + if source in {"themoviedb", "bangumi", "anilist"} \ + and not source_media_id.isdigit(): + return None, "媒体ID格式错误" + return {"source": source, "mediaid": source_media_id}, "" + + event_data = MediaRecognizeConvertEventData( + mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE + ) + event = await eventmanager.async_send_event( + ChainEventType.MediaRecognizeConvert, event_data + ) + if event and event.event_data and event.event_data.media_dict: + event_data = event.event_data + search_id = event_data.media_dict.get("id") + if search_id is not None: + return { + "source": event_data.convert_type, + "mediaid": str(search_id), + }, "" + + if not title: + return None, "未知的媒体ID" + + meta = MetaInfo(title) + if year: + meta.year = year + if media_type: + meta.type = media_type + if media_season is not None: + meta.type = MediaType.TV + meta.begin_season = media_season + mediainfo = await MediaChain().async_recognize_by_meta( + meta, + obtain_images=False, + ) + if not mediainfo: + return None, "未识别到媒体信息" + source, source_media_id = resolve_media_identity(media=mediainfo) + if not source or not source_media_id: + return None, "媒体信息缺少有效ID" + return {"source": source, "mediaid": source_media_id}, "" + + def _sse_event(data: dict, locale: Optional[str] = None) -> str: """ 转换为SSE事件 @@ -264,189 +316,27 @@ async def search_by_id_stream( media_type = _parse_media_type(mtype) media_season = int(season) if season else None site_list = _parse_site_list(sites) - media_chain = MediaChain() search_chain = SearchChain() async def event_source(): - nonlocal media_season - torrents = None - if mediaid.startswith("tmdb:"): - tmdbid = int(mediaid.replace("tmdb:", "")) - if settings.RECOGNIZE_SOURCE == "douban": - doubaninfo = await media_chain.async_get_doubaninfo_by_tmdbid( - tmdbid=tmdbid, mtype=media_type - ) - if doubaninfo: - torrents = search_chain.async_search_by_id_stream( - doubanid=doubaninfo.get("id"), - mtype=media_type, - area=area, - season=media_season, - sites=site_list, - cache_local=True, - ) - else: - yield { - "type": "error", - "success": False, - "message": "未识别到豆瓣媒体信息", - } - return - else: - torrents = search_chain.async_search_by_id_stream( - tmdbid=tmdbid, - mtype=media_type, - area=area, - season=media_season, - sites=site_list, - cache_local=True, - ) - elif mediaid.startswith("douban:"): - doubanid = mediaid.replace("douban:", "") - if settings.RECOGNIZE_SOURCE == "themoviedb": - tmdbinfo = await media_chain.async_get_tmdbinfo_by_doubanid( - doubanid=doubanid, mtype=media_type - ) - if tmdbinfo: - media_season = _resolve_media_season( - explicit_season=media_season, - recognized_season=tmdbinfo.get("season"), - ) - torrents = search_chain.async_search_by_id_stream( - tmdbid=tmdbinfo.get("id"), - mtype=media_type, - area=area, - season=media_season, - sites=site_list, - cache_local=True, - ) - else: - yield { - "type": "error", - "success": False, - "message": "未识别到TMDB媒体信息", - } - return - else: - torrents = search_chain.async_search_by_id_stream( - doubanid=doubanid, - mtype=media_type, - area=area, - season=media_season, - sites=site_list, - cache_local=True, - ) - elif mediaid.startswith("bangumi:"): - bangumiid = int(mediaid.replace("bangumi:", "")) - if settings.RECOGNIZE_SOURCE == "themoviedb": - tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid( - bangumiid=bangumiid - ) - if tmdbinfo: - torrents = search_chain.async_search_by_id_stream( - tmdbid=tmdbinfo.get("id"), - mtype=media_type, - area=area, - season=media_season, - sites=site_list, - cache_local=True, - ) - else: - yield { - "type": "error", - "success": False, - "message": "未识别到TMDB媒体信息", - } - return - else: - doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid( - bangumiid=bangumiid - ) - if doubaninfo: - torrents = search_chain.async_search_by_id_stream( - doubanid=doubaninfo.get("id"), - mtype=media_type, - area=area, - season=media_season, - sites=site_list, - cache_local=True, - ) - else: - yield { - "type": "error", - "success": False, - "message": "未识别到豆瓣媒体信息", - } - return - else: - event_data = MediaRecognizeConvertEventData( - mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE - ) - event = await eventmanager.async_send_event( - ChainEventType.MediaRecognizeConvert, event_data - ) - if event and event.event_data: - event_data = event.event_data - if event_data.media_dict: - search_id = event_data.media_dict.get("id") - if event_data.convert_type == "themoviedb": - torrents = search_chain.async_search_by_id_stream( - tmdbid=search_id, - mtype=media_type, - area=area, - season=media_season, - sites=site_list, - cache_local=True, - ) - elif event_data.convert_type == "douban": - torrents = search_chain.async_search_by_id_stream( - doubanid=search_id, - mtype=media_type, - area=area, - season=media_season, - sites=site_list, - cache_local=True, - ) - else: - if not title: - yield {"type": "error", "success": False, "message": "未知的媒体ID"} - return - meta = MetaInfo(title) - if year: - meta.year = year - if media_type: - meta.type = media_type - if media_season is not None: - meta.type = MediaType.TV - meta.begin_season = media_season - mediainfo = await media_chain.async_recognize_by_meta( - meta, - obtain_images=False, - ) - if mediainfo: - if settings.RECOGNIZE_SOURCE == "themoviedb": - torrents = search_chain.async_search_by_id_stream( - tmdbid=mediainfo.tmdb_id, - mtype=media_type, - area=area, - season=media_season, - sites=site_list, - cache_local=True, - ) - else: - torrents = search_chain.async_search_by_id_stream( - doubanid=mediainfo.douban_id, - mtype=media_type, - area=area, - season=media_season, - sites=site_list, - cache_local=True, - ) - - if not torrents: - yield {"type": "error", "success": False, "message": "未搜索到任何资源"} + search_params, message = await _resolve_media_search_params( + mediaid=mediaid, + media_type=media_type, + title=title, + year=year, + media_season=media_season, + ) + if not search_params: + yield {"type": "error", "success": False, "message": message} return - + torrents = search_chain.async_search_by_id_stream( + **search_params, + mtype=media_type, + area=area, + season=media_season, + sites=site_list, + cache_local=True, + ) async for event in torrents: yield event @@ -467,182 +357,32 @@ async def search_by_id( _: schemas.TokenPayload = Depends(verify_token), ) -> Any: """ - 根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/bangumi: + 根据带来源前缀的媒体 ID 精确搜索站点资源。 """ media_type = _parse_media_type(mtype) - if season: - media_season = int(season) - else: - media_season = None - if sites: - site_list = [int(site) for site in sites.split(",") if site] - else: - site_list = None - torrents = None - media_chain = MediaChain() - search_chain = SearchChain() - # 根据前缀识别媒体ID - if mediaid.startswith("tmdb:"): - tmdbid = int(mediaid.replace("tmdb:", "")) - if settings.RECOGNIZE_SOURCE == "douban": - # 通过TMDBID识别豆瓣ID - doubaninfo = await media_chain.async_get_doubaninfo_by_tmdbid( - tmdbid=tmdbid, mtype=media_type - ) - if doubaninfo: - torrents = await search_chain.async_search_by_id( - doubanid=doubaninfo.get("id"), - mtype=media_type, - area=area, - season=media_season, - sites=site_list, - cache_local=True, - ) - else: - return schemas.Response(success=False, message="未识别到豆瓣媒体信息") - else: - torrents = await search_chain.async_search_by_id( - tmdbid=tmdbid, - mtype=media_type, - area=area, - season=media_season, - sites=site_list, - cache_local=True, - ) - elif mediaid.startswith("douban:"): - doubanid = mediaid.replace("douban:", "") - if settings.RECOGNIZE_SOURCE == "themoviedb": - # 通过豆瓣ID识别TMDBID - tmdbinfo = await media_chain.async_get_tmdbinfo_by_doubanid( - doubanid=doubanid, mtype=media_type - ) - if tmdbinfo: - media_season = _resolve_media_season( - explicit_season=media_season, - recognized_season=tmdbinfo.get("season"), - ) - torrents = await search_chain.async_search_by_id( - tmdbid=tmdbinfo.get("id"), - mtype=media_type, - area=area, - season=media_season, - sites=site_list, - cache_local=True, - ) - else: - return schemas.Response(success=False, message="未识别到TMDB媒体信息") - else: - torrents = await search_chain.async_search_by_id( - doubanid=doubanid, - mtype=media_type, - area=area, - season=media_season, - sites=site_list, - cache_local=True, - ) - elif mediaid.startswith("bangumi:"): - bangumiid = int(mediaid.replace("bangumi:", "")) - if settings.RECOGNIZE_SOURCE == "themoviedb": - # 通过BangumiID识别TMDBID - tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid( - bangumiid=bangumiid - ) - if tmdbinfo: - torrents = await search_chain.async_search_by_id( - tmdbid=tmdbinfo.get("id"), - mtype=media_type, - area=area, - season=media_season, - sites=site_list, - cache_local=True, - ) - else: - return schemas.Response(success=False, message="未识别到TMDB媒体信息") - else: - # 通过BangumiID识别豆瓣ID - doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid( - bangumiid=bangumiid - ) - if doubaninfo: - torrents = await search_chain.async_search_by_id( - doubanid=doubaninfo.get("id"), - mtype=media_type, - area=area, - season=media_season, - sites=site_list, - cache_local=True, - ) - else: - return schemas.Response(success=False, message="未识别到豆瓣媒体信息") - else: - # 未知前缀,广播事件解析媒体信息 - event_data = MediaRecognizeConvertEventData( - mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE - ) - event = await eventmanager.async_send_event( - ChainEventType.MediaRecognizeConvert, event_data - ) - # 使用事件返回的上下文数据 - if event and event.event_data: - event_data: MediaRecognizeConvertEventData = event.event_data - if event_data.media_dict: - search_id = event_data.media_dict.get("id") - if event_data.convert_type == "themoviedb": - torrents = await search_chain.async_search_by_id( - tmdbid=search_id, - mtype=media_type, - area=area, - season=media_season, - cache_local=True, - ) - elif event_data.convert_type == "douban": - torrents = await search_chain.async_search_by_id( - doubanid=search_id, - mtype=media_type, - area=area, - season=media_season, - cache_local=True, - ) - else: - if not title: - return schemas.Response(success=False, message="未知的媒体ID") - # 使用名称识别兜底 - meta = MetaInfo(title) - if year: - meta.year = year - if media_type: - meta.type = media_type - if media_season is not None: - meta.type = MediaType.TV - meta.begin_season = media_season - mediainfo = await media_chain.async_recognize_by_meta( - meta, - obtain_images=False, - ) - if mediainfo: - if settings.RECOGNIZE_SOURCE == "themoviedb": - torrents = await search_chain.async_search_by_id( - tmdbid=mediainfo.tmdb_id, - mtype=media_type, - area=area, - season=media_season, - cache_local=True, - ) - else: - torrents = await search_chain.async_search_by_id( - doubanid=mediainfo.douban_id, - mtype=media_type, - area=area, - season=media_season, - cache_local=True, - ) - # 返回搜索结果 + media_season = int(season) if season else None + search_params, message = await _resolve_media_search_params( + mediaid=mediaid, + media_type=media_type, + title=title, + year=year, + media_season=media_season, + ) + if not search_params: + return schemas.Response(success=False, message=message) + torrents = await SearchChain().async_search_by_id( + **search_params, + mtype=media_type, + area=area, + season=media_season, + sites=_parse_site_list(sites), + cache_local=True, + ) if not torrents: return schemas.Response(success=False, message="未搜索到任何资源") - else: - return schemas.Response( - success=True, data=[torrent.to_dict() for torrent in torrents] - ) + return schemas.Response( + success=True, data=[torrent.to_dict() for torrent in torrents] + ) @router.get("/title/stream", summary="渐进式模糊搜索资源") @@ -746,7 +486,6 @@ async def _build_subtitle_search_source( media_season = int(season) if season else None media_episode = int(episode) if episode else None site_list = _parse_site_list(sites) - media_chain = MediaChain() search_chain = SearchChain() def call_search(**kwargs): @@ -765,82 +504,16 @@ async def _build_subtitle_search_source( return search_chain.async_search_subtitles_by_id_stream(**params) return search_chain.async_search_subtitles_by_id(**params) - if mediaid.startswith("tmdb:"): - tmdbid = int(mediaid.replace("tmdb:", "")) - if settings.RECOGNIZE_SOURCE == "douban": - doubaninfo = await media_chain.async_get_doubaninfo_by_tmdbid( - tmdbid=tmdbid, mtype=media_type - ) - if not doubaninfo: - return None, "未识别到豆瓣媒体信息" - return call_search(doubanid=doubaninfo.get("id")), "" - return call_search(tmdbid=tmdbid), "" - - if mediaid.startswith("douban:"): - doubanid = mediaid.replace("douban:", "") - if settings.RECOGNIZE_SOURCE == "themoviedb": - tmdbinfo = await media_chain.async_get_tmdbinfo_by_doubanid( - doubanid=doubanid, mtype=media_type - ) - if not tmdbinfo: - return None, "未识别到TMDB媒体信息" - media_season = _resolve_media_season( - explicit_season=media_season, - recognized_season=tmdbinfo.get("season"), - ) - return call_search(tmdbid=tmdbinfo.get("id")), "" - return call_search(doubanid=doubanid), "" - - if mediaid.startswith("bangumi:"): - bangumiid = int(mediaid.replace("bangumi:", "")) - if settings.RECOGNIZE_SOURCE == "themoviedb": - tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid( - bangumiid=bangumiid - ) - if not tmdbinfo: - return None, "未识别到TMDB媒体信息" - return call_search(tmdbid=tmdbinfo.get("id")), "" - doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid( - bangumiid=bangumiid - ) - if not doubaninfo: - return None, "未识别到豆瓣媒体信息" - return call_search(doubanid=doubaninfo.get("id")), "" - - event_data = MediaRecognizeConvertEventData( - mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE + search_params, message = await _resolve_media_search_params( + mediaid=mediaid, + media_type=media_type, + title=title, + year=year, + media_season=media_season, ) - event = await eventmanager.async_send_event( - ChainEventType.MediaRecognizeConvert, event_data - ) - if event and event.event_data and event.event_data.media_dict: - event_data = event.event_data - search_id = event_data.media_dict.get("id") - if event_data.convert_type == "themoviedb": - return call_search(tmdbid=search_id), "" - if event_data.convert_type == "douban": - return call_search(doubanid=search_id), "" - - if not title: - return None, "未知的媒体ID" - - meta = MetaInfo(title) - if year: - meta.year = year - if media_type: - meta.type = media_type - if media_season is not None: - meta.type = MediaType.TV - meta.begin_season = media_season - mediainfo = await media_chain.async_recognize_by_meta( - meta, - obtain_images=False, - ) - if not mediainfo: - return None, "未识别到媒体信息" - if settings.RECOGNIZE_SOURCE == "themoviedb": - return call_search(tmdbid=mediainfo.tmdb_id), "" - return call_search(doubanid=mediainfo.douban_id), "" + if not search_params: + return None, message + return call_search(**search_params), "" @router.get("/subtitle/media/{mediaid}/stream", summary="渐进式精确搜索字幕") @@ -856,7 +529,7 @@ async def search_subtitle_by_id_stream( _: schemas.TokenPayload = Depends(verify_resource_token), ) -> Any: """ - 根据TMDBID/豆瓣ID渐进式精确搜索站点字幕资源,返回格式为SSE。 + 根据带来源前缀的媒体 ID 渐进式精确搜索站点字幕资源,返回格式为SSE。 """ subtitles, message = await _build_subtitle_search_source( mediaid=mediaid, @@ -900,7 +573,7 @@ async def search_subtitle_by_id( _: schemas.TokenPayload = Depends(verify_token), ) -> Any: """ - 根据TMDBID/豆瓣ID精确搜索站点字幕资源。 + 根据带来源前缀的媒体 ID 精确搜索站点字幕资源。 """ subtitles, message = await _build_subtitle_search_source( mediaid=mediaid, diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 1c27dc315..dd2419bef 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -23,6 +23,7 @@ from app.log import logger from app.scheduler import Scheduler from app.schemas.event import SubscribeModifiedEventData from app.schemas.types import MediaType, EventType, SystemConfigKey +from app.utils.media import normalize_media_source, parse_media_key router = APIRouter() @@ -104,6 +105,35 @@ def select_accessible_subscribe( return None +async def list_subscribes_by_media_key( + db: AsyncSession, media_key: str, season: Optional[int] = None, +) -> List[Subscribe]: + """按统一媒体键查询订阅,并兼容迁移前的专用 ID 字段。""" + source, media_id = parse_media_key(media_key) + if not source or not media_id: + return await Subscribe.async_list_by_mediaid(db, media_key) + + subscribes = list(await Subscribe.async_list_by_media_identity( + db, media_source=source, media_id=media_id + )) + if source == "themoviedb" and media_id.isdigit(): + subscribes.extend(await Subscribe.async_get_by_tmdbid(db, int(media_id), season)) + elif source == "douban": + subscribes.extend(await Subscribe.async_list_by_doubanid(db, media_id)) + elif source == "bangumi" and media_id.isdigit(): + subscribes.extend(await Subscribe.async_list_by_bangumiid(db, int(media_id))) + elif source == "anilist" and media_id.isdigit(): + subscribes.extend(await Subscribe.async_list_by_anilistid(db, int(media_id))) + + unique_subscribes = {subscribe.id: subscribe for subscribe in subscribes} + if season is not None: + return [ + subscribe for subscribe in unique_subscribes.values() + if subscribe.season == season + ] + return list(unique_subscribes.values()) + + @router.get("/", summary="查询所有订阅", response_model=List[schemas.Subscribe]) async def read_subscribes( db: AsyncSession = Depends(get_async_db), @@ -141,8 +171,13 @@ async def create_subscribe( mtype = MediaType(subscribe_in.type) else: mtype = None - # 豆瓣标理 - if subscribe_in.doubanid or subscribe_in.bangumiid: + # 非 TMDB 来源的标题可能自带季标记,入库前统一拆分。 + if ( + subscribe_in.doubanid + or subscribe_in.bangumiid + or subscribe_in.anilistid + or normalize_media_source(subscribe_in.media_source) not in (None, "themoviedb") + ): meta = MetaInfo(subscribe_in.name) subscribe_in.name = meta.name if subscribe_in.season is None: @@ -247,36 +282,12 @@ async def subscribe_mediaid( current_user: User = Depends(get_current_active_user_async), ) -> Any: """ - 根据 TMDBID/豆瓣ID/BangumiId 查询订阅 tmdb:/douban: + 根据 TMDB、豆瓣、Bangumi、AniList 或插件媒体键查询订阅。 """ - title_check = False - if mediaid.startswith("tmdb:"): - tmdbid = mediaid[5:] - if not tmdbid or not str(tmdbid).isdigit(): - return Subscribe() - subscribes = await Subscribe.async_get_by_tmdbid(db, int(tmdbid), season) - result = select_accessible_subscribe(subscribes, current_user) - elif mediaid.startswith("douban:"): - doubanid = mediaid[7:] - if not doubanid: - return Subscribe() - subscribes = await Subscribe.async_list_by_doubanid(db, doubanid) - result = select_accessible_subscribe(subscribes, current_user) - if not result and title: - title_check = True - elif mediaid.startswith("bangumi:"): - bangumiid = mediaid[8:] - if not bangumiid or not str(bangumiid).isdigit(): - return Subscribe() - subscribes = await Subscribe.async_list_by_bangumiid(db, int(bangumiid)) - result = select_accessible_subscribe(subscribes, current_user) - if not result and title: - title_check = True - else: - subscribes = await Subscribe.async_list_by_mediaid(db, mediaid) - result = select_accessible_subscribe(subscribes, current_user) - if not result and title: - title_check = True + subscribes = await list_subscribes_by_media_key(db, mediaid, season) + result = select_accessible_subscribe(subscribes, current_user) + source, _ = parse_media_key(mediaid) + title_check = not result and bool(title) and source != "themoviedb" # 使用名称检查订阅 if title_check and title: meta = MetaInfo(title) @@ -419,24 +430,9 @@ async def delete_subscribe_by_mediaid( current_user: User = Depends(get_current_active_user_async), ) -> Any: """ - 根据TMDBID或豆瓣ID删除订阅 tmdb:/douban: + 根据任意媒体数据源 ID 删除订阅。 """ - delete_subscribes = [] - if mediaid.startswith("tmdb:"): - tmdbid = mediaid[5:] - if not tmdbid or not str(tmdbid).isdigit(): - return schemas.Response(success=False) - subscribes = await Subscribe.async_get_by_tmdbid(db, int(tmdbid), season) - delete_subscribes.extend(subscribes) - elif mediaid.startswith("douban:"): - doubanid = mediaid[7:] - if not doubanid: - return schemas.Response(success=False) - subscribes = await Subscribe.async_list_by_doubanid(db, doubanid) - delete_subscribes.extend(subscribes) - else: - subscribes = await Subscribe.async_list_by_mediaid(db, mediaid) - delete_subscribes.extend(subscribes) + delete_subscribes = await list_subscribes_by_media_key(db, mediaid, season) delete_events = [] for subscribe in [ subscribe @@ -632,6 +628,8 @@ async def popular_subscribes( media.year = sub.get("year") media.douban_id = sub.get("doubanid") media.bangumi_id = sub.get("bangumiid") + media.anilist_id = sub.get("anilistid") + media.source = sub.get("media_source") media.tvdb_id = sub.get("tvdbid") media.imdb_id = sub.get("imdbid") media.season = sub.get("season") @@ -858,6 +856,13 @@ async def delete_subscribe( ) # 统计订阅 MoviePilotServerHelper.sub_done_async( - {"tmdbid": subscribe_info.get("tmdbid"), "doubanid": subscribe_info.get("doubanid")} + { + "tmdbid": subscribe_info.get("tmdbid"), + "doubanid": subscribe_info.get("doubanid"), + "bangumiid": subscribe_info.get("bangumiid"), + "anilistid": subscribe_info.get("anilistid"), + "media_source": subscribe_info.get("media_source"), + "media_id": subscribe_info.get("media_id"), + } ) return schemas.Response(success=True) diff --git a/app/api/endpoints/torrent.py b/app/api/endpoints/torrent.py index 180c7d336..49c6c98ce 100644 --- a/app/api/endpoints/torrent.py +++ b/app/api/endpoints/torrent.py @@ -174,6 +174,10 @@ async def reidentify_cache( torrent_hash: str, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, + bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, + media_source: Optional[str] = None, + media_id: Optional[str] = None, _: User = Depends(get_current_active_superuser_async), ): """ @@ -182,6 +186,10 @@ async def reidentify_cache( :param torrent_hash: 种子hash(使用title+description的md5) :param tmdbid: 手动指定的TMDB ID :param doubanid: 手动指定的豆瓣ID + :param bangumiid: 手动指定的 Bangumi ID + :param anilistid: 手动指定的 AniList ID + :param media_source: 媒体数据源 + :param media_id: 数据源原生 ID :param _: 当前用户,必须是超级用户 """ @@ -215,10 +223,16 @@ async def reidentify_cache( title=target_context.torrent_info.title, subtitle=target_context.torrent_info.description, ) - if tmdbid or doubanid: + if tmdbid or doubanid or bangumiid or anilistid or media_source or media_id: # 手动指定媒体信息 mediainfo = await media_chain.async_recognize_media( - meta=meta, tmdbid=tmdbid, doubanid=doubanid + meta=meta, + tmdbid=tmdbid, + doubanid=doubanid, + bangumiid=bangumiid, + anilistid=anilistid, + source=media_source, + mediaid=media_id, ) else: # 自动重新识别 diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index df9995a15..8b8742d15 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -292,13 +292,13 @@ def manual_transfer( transer_item.doubanid = ( str(history.doubanid) if history.doubanid else transer_item.doubanid ) + transer_item.bangumiid = history.bangumiid or transer_item.bangumiid + transer_item.anilistid = history.anilistid or transer_item.anilistid transer_item.media_source = ( - getattr(history, "media_source", None) - or transer_item.media_source + history.media_source or transer_item.media_source ) transer_item.media_id = ( - getattr(history, "media_id", None) - or transer_item.media_id + history.media_id or transer_item.media_id ) transer_item.season = ( int(str(history.seasons).replace("S", "")) @@ -417,6 +417,8 @@ def manual_transfer( target_path=target_path, tmdbid=transer_item.tmdbid, doubanid=transer_item.doubanid, + bangumiid=transer_item.bangumiid, + anilistid=transer_item.anilistid, media_source=transer_item.media_source, media_id=transer_item.media_id, mtype=mtype, @@ -501,6 +503,8 @@ def manual_transfer( target_path=target_path, tmdbid=transer_item.tmdbid, doubanid=transer_item.doubanid, + bangumiid=transer_item.bangumiid, + anilistid=transer_item.anilistid, media_source=transer_item.media_source, media_id=transer_item.media_id, mtype=mtype, diff --git a/app/chain/__init__.py b/app/chain/__init__.py index 34fced507..20d4ede9a 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -40,6 +40,7 @@ from app.schemas import ( MessageResponse, ) from app.utils.identity import normalize_internal_user_id +from app.utils.media import normalize_media_source from app.schemas.message import ChannelCapability, ChannelCapabilityManager from app.schemas.category import CategoryConfig from app.schemas.types import ( @@ -468,7 +469,6 @@ class ChainBase(metaclass=ABCMeta): self, method: str, *args, - system_only: bool = False, **kwargs, ) -> Any: """ @@ -476,13 +476,9 @@ class ChainBase(metaclass=ABCMeta): 当kwargs包含命名参数raise_exception时,如模块方法抛出异常且raise_exception为True,则同步抛出异常 :param method: 模块方法名称 - :param system_only: 是否仅执行系统模块 """ - result = None - # 执行插件模块 - if not system_only: - result = self.__execute_plugin_modules(method, result, *args, **kwargs) + result = self.__execute_plugin_modules(method, None, *args, **kwargs) if not self.__is_valid_empty(result) and not isinstance(result, list): # 插件模块返回结果不为空且不是列表,直接返回 @@ -495,7 +491,6 @@ class ChainBase(metaclass=ABCMeta): self, method: str, *args, - system_only: bool = False, **kwargs, ) -> Any: """ @@ -504,15 +499,11 @@ class ChainBase(metaclass=ABCMeta): 支持异步和同步方法的混合调用 :param method: 模块方法名称 - :param system_only: 是否仅执行系统模块 """ - result = None - # 执行插件模块 - if not system_only: - result = await self.__async_execute_plugin_modules( - method, result, *args, **kwargs - ) + result = await self.__async_execute_plugin_modules( + method, None, *args, **kwargs + ) if not self.__is_valid_empty(result) and not isinstance(result, list): # 插件模块返回结果不为空且不是列表,直接返回 @@ -529,6 +520,7 @@ class ChainBase(metaclass=ABCMeta): tmdbid: Optional[int], doubanid: Optional[str], bangumiid: Optional[int], + anilistid: Optional[int], ) -> bool: """ 仅在名称识别场景下使用共享识别,显式ID识别不再重复回查 @@ -536,7 +528,7 @@ class ChainBase(metaclass=ABCMeta): return bool( settings.MEDIA_RECOGNIZE_SHARE and meta - and not any([tmdbid, doubanid, bangumiid]) + and not any([tmdbid, doubanid, bangumiid, anilistid]) ) @staticmethod @@ -600,14 +592,7 @@ class ChainBase(metaclass=ABCMeta): :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 + source = normalize_media_source(source) def to_int(value) -> Optional[int]: """将数字ID安全转换为整数。""" @@ -669,7 +654,6 @@ class ChainBase(metaclass=ABCMeta): :return: 识别的媒体信息,包括剧集信息 """ # 识别用名中含指定信息情形 - requested_source = source if not tmdbid and hasattr(meta, "tmdbid"): tmdbid = meta.tmdbid if not doubanid and hasattr(meta, "doubanid"): @@ -678,6 +662,7 @@ class ChainBase(metaclass=ABCMeta): source = meta.media_source if not mediaid and hasattr(meta, "media_id"): mediaid = meta.media_id + requested_mediaid = mediaid if not episode_group and hasattr(meta, "episode_group"): episode_group = meta.episode_group source, tmdbid, doubanid, bangumiid, anilistid = self._resolve_media_source_params( @@ -688,35 +673,25 @@ class ChainBase(metaclass=ABCMeta): bangumiid=bangumiid, anilistid=anilistid, ) - # 有tmdbid时,不使用meta推断的类型(由消歧逻辑决定) - if tmdbid: - source = "themoviedb" - elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]: + # 显式 TMDB ID 由模块自行消歧,不能被标题推断类型误导。 + if not mtype and not tmdbid 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, + "source": source, + "mediaid": requested_mediaid, "tmdbid": tmdbid, "doubanid": doubanid, "bangumiid": bangumiid, + "anilistid": anilistid, "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", - system_only=system_only, **module_kwargs, ) if mediainfo: @@ -729,7 +704,7 @@ class ChainBase(metaclass=ABCMeta): return mediainfo if not source and self._can_use_media_recognize_share( - share_query_meta, tmdbid, doubanid, bangumiid + share_query_meta, tmdbid, doubanid, bangumiid, anilistid ): shared_cache_meta = self._snapshot_recognize_cache_meta(meta) shared_item = MoviePilotServerHelper.query_recognize_share( @@ -744,9 +719,12 @@ class ChainBase(metaclass=ABCMeta): "recognize_media", meta=meta, mtype=shared_params.get("mtype") or mtype, + source=shared_params.get("source"), + mediaid=shared_params.get("mediaid"), tmdbid=shared_params.get("tmdbid"), doubanid=shared_params.get("doubanid"), bangumiid=shared_params.get("bangumiid"), + anilistid=shared_params.get("anilistid"), episode_group=episode_group, cache=cache, ) @@ -785,7 +763,6 @@ class ChainBase(metaclass=ABCMeta): :return: 识别的媒体信息,包括剧集信息 """ # 识别用名中含指定信息情形 - requested_source = source if not tmdbid and hasattr(meta, "tmdbid"): tmdbid = meta.tmdbid if not doubanid and hasattr(meta, "doubanid"): @@ -794,6 +771,7 @@ class ChainBase(metaclass=ABCMeta): source = meta.media_source if not mediaid and hasattr(meta, "media_id"): mediaid = meta.media_id + requested_mediaid = mediaid if not episode_group and hasattr(meta, "episode_group"): episode_group = meta.episode_group source, tmdbid, doubanid, bangumiid, anilistid = self._resolve_media_source_params( @@ -804,35 +782,25 @@ class ChainBase(metaclass=ABCMeta): bangumiid=bangumiid, anilistid=anilistid, ) - # 有tmdbid时,不使用meta推断的类型(由消歧逻辑决定) - if tmdbid: - source = "themoviedb" - elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]: + # 显式 TMDB ID 由模块自行消歧,不能被标题推断类型误导。 + if not mtype and not tmdbid 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, + "source": source, + "mediaid": requested_mediaid, "tmdbid": tmdbid, "doubanid": doubanid, "bangumiid": bangumiid, + "anilistid": anilistid, "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", - system_only=system_only, **module_kwargs, ) if mediainfo: @@ -845,7 +813,7 @@ class ChainBase(metaclass=ABCMeta): return mediainfo if not source and self._can_use_media_recognize_share( - share_query_meta, tmdbid, doubanid, bangumiid + share_query_meta, tmdbid, doubanid, bangumiid, anilistid ): shared_cache_meta = self._snapshot_recognize_cache_meta(meta) shared_item = await MoviePilotServerHelper.async_query_recognize_share( @@ -860,9 +828,12 @@ class ChainBase(metaclass=ABCMeta): "async_recognize_media", meta=meta, mtype=shared_params.get("mtype") or mtype, + source=shared_params.get("source"), + mediaid=shared_params.get("mediaid"), tmdbid=shared_params.get("tmdbid"), doubanid=shared_params.get("doubanid"), bangumiid=shared_params.get("bangumiid"), + anilistid=shared_params.get("anilistid"), episode_group=episode_group, cache=cache, ) @@ -1136,14 +1107,7 @@ class ChainBase(metaclass=ABCMeta): :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) + return self.run_module("search_medias", meta=meta, source=source) async def async_search_medias( self, meta: MetaBase, source: Optional[str] = None @@ -1154,14 +1118,9 @@ class ChainBase(metaclass=ABCMeta): :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) + return await self.async_run_module( + "async_search_medias", meta=meta, source=source + ) def search_persons(self, name: str) -> Optional[List[MediaPerson]]: """ diff --git a/app/chain/download.py b/app/chain/download.py index 6aafeba69..fc73de391 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -30,6 +30,7 @@ from app.schemas import ExistMediaInfo, FileURI, NotExistMediaInfo, DownloaderTo from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ContentType, \ ChainEventType from app.utils.http import RequestUtils +from app.utils.media import build_media_key, resolve_media_identity from app.utils.string import StringUtils from app.utils.system import SystemUtils @@ -59,6 +60,23 @@ class DownloadChain(ChainBase): ".rar": "rar", } + @staticmethod + def _media_identity_keys(media: Optional[MediaInfo]) -> Set[str]: + """返回媒体的统一身份键及全部兼容 ID,用于临时缺失集映射匹配。""" + if not media: + return set() + source, media_id = resolve_media_identity(media=media) + values = { + media.tmdb_id, media.douban_id, media.bangumi_id, media.anilist_id, + build_media_key(source, media_id), + } + return {str(value) for value in values if value is not None and str(value)} + + @classmethod + def _matches_media_identity(cls, media: Optional[MediaInfo], media_key: object) -> bool: + """判断媒体是否命中统一身份键或任一兼容 ID。""" + return media_key is not None and str(media_key) in cls._media_identity_keys(media) + @staticmethod def _safe_subtitle_file_name(file_name: str, fallback_name: str) -> str: """ @@ -330,6 +348,8 @@ class DownloadChain(ChainBase): doubanid: Optional[str] = None, save_path: Optional[str] = None, username: Optional[str] = None, + bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, ) -> Tuple[bool, str, List[str]]: """ 下载字幕文件并保存到媒体对应的下载目录。 @@ -339,6 +359,8 @@ class DownloadChain(ChainBase): :param media_id: 数据源原生ID :param tmdbid: TMDB ID :param doubanid: 豆瓣 ID + :param bangumiid: Bangumi ID + :param anilistid: AniList ID :param save_path: 保存路径 :param username: 调用下载的用户名 :return: 成功状态、提示消息、保存文件列表 @@ -353,6 +375,8 @@ class DownloadChain(ChainBase): mediaid=media_id, tmdbid=tmdbid, doubanid=doubanid, + bangumiid=bangumiid, + anilistid=anilistid, ) if not mediainfo: return False, "无法识别媒体信息", [] @@ -485,10 +509,11 @@ class DownloadChain(ChainBase): return None media_type = getattr(getattr(media, "type", None), "value", getattr(media, "type", None)) + media_source, media_id = resolve_media_identity(media=media) media_key = ( - getattr(media, "tmdb_id", None) - or getattr(media, "douban_id", None) - or getattr(media, "imdb_id", None) + f"{media_source}:{media_id}" + if media_source and media_id + else getattr(media, "imdb_id", None) or getattr(media, "tvdb_id", None) or f"{getattr(media, 'title', '')}:{getattr(media, 'year', '')}" ) @@ -542,6 +567,7 @@ class DownloadChain(ChainBase): time.localtime(now_timestamp + self._download_failure_ttl(error_msg)), ) media = context.media_info + media_source, media_id = resolve_media_identity(media=media) meta = context.meta_info torrent = context.torrent_info site = getattr(torrent, "site", None) @@ -555,6 +581,10 @@ class DownloadChain(ChainBase): year=getattr(media, "year", None), tmdbid=getattr(media, "tmdb_id", None), doubanid=getattr(media, "douban_id", None), + bangumiid=media.bangumi_id, + anilistid=media.anilist_id, + media_source=media_source, + media_id=media_id, seasons=getattr(meta, "season", None), episodes=StringUtils.format_ep(list(episodes)) if episodes else self._format_failure_episodes(meta), site=site if isinstance(site, int) else None, @@ -785,6 +815,7 @@ class DownloadChain(ChainBase): if not _media.genre_ids: new_media = self.recognize_media(mtype=_media.type, tmdbid=_media.tmdb_id, doubanid=_media.douban_id, bangumiid=_media.bangumi_id, + anilistid=_media.anilist_id, episode_group=_media.episode_group) if new_media: _media = new_media @@ -867,6 +898,7 @@ class DownloadChain(ChainBase): # 登记下载记录 downloadhis = DownloadHistoryOper() + media_source, media_id = resolve_media_identity(media=_media) downloadhis.add( path=download_path.as_posix(), type=_media.type.value, @@ -876,6 +908,10 @@ class DownloadChain(ChainBase): imdbid=_media.imdb_id, tvdbid=_media.tvdb_id, doubanid=_media.douban_id, + bangumiid=_media.bangumi_id, + anilistid=_media.anilist_id, + media_source=media_source, + media_id=media_id, seasons=_meta.season, episodes=download_episodes or _meta.episode, image=_media.get_backdrop_image(), @@ -1212,7 +1248,7 @@ class DownloadChain(ChainBase): if meta.episode_list: continue # 匹配TMDBID - if need_mid == media.tmdb_id or need_mid == media.douban_id: + if self._matches_media_identity(media, need_mid): # 不重复添加 if context in downloaded_list: continue @@ -1343,7 +1379,7 @@ class DownloadChain(ChainBase): if media.type != MediaType.TV: continue # 匹配TMDB - if media.tmdb_id == need_mid or media.douban_id == need_mid: + if self._matches_media_identity(media, need_mid): # 不重复添加 if context in downloaded_list: continue @@ -1445,7 +1481,7 @@ class DownloadChain(ChainBase): if not effective_need: continue # 选中一个单季整季的或单季包括需要的所有集的 - if (media.tmdb_id == need_mid or media.douban_id == need_mid) \ + if self._matches_media_identity(media, need_mid) \ and (not meta.episode_list or set(meta.episode_list).intersection(effective_need)) \ and len(meta.season_list) == 1 \ @@ -1523,6 +1559,7 @@ class DownloadChain(ChainBase): :param totals: 电视剧每季的总集数 :return: 当前媒体是否缺失,各标题总的季集和缺失的季集 """ + media_source, media_id = resolve_media_identity(media=mediainfo) def __append_no_exists(_season: int, _episodes: list, _total: int, _start: int): """ @@ -1534,7 +1571,7 @@ class DownloadChain(ChainBase): "start_episode": int ]} """ - mediakey = mediainfo.tmdb_id or mediainfo.douban_id + mediakey = build_media_key(media_source, media_id) if not no_exists.get(mediakey): no_exists[mediakey] = { _season: NotExistMediaInfo( @@ -1575,6 +1612,10 @@ class DownloadChain(ChainBase): mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type, tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, + bangumiid=mediainfo.bangumi_id, + anilistid=mediainfo.anilist_id, + source=media_source, + mediaid=media_id, episode_group=mediainfo.episode_group) if not mediainfo: logger.error(f"媒体信息识别失败!") diff --git a/app/chain/message.py b/app/chain/message.py index 2d6511cbf..b2f08260f 100644 --- a/app/chain/message.py +++ b/app/chain/message.py @@ -42,6 +42,7 @@ from app.schemas.message import ChannelCapabilityManager, ChannelCapability from app.schemas.system import TransferDirectoryConf from app.schemas.types import EventType, MessageChannel, MediaType from app.utils.http import RequestUtils +from app.utils.media import build_media_key, resolve_media_identity from app.utils.string import StringUtils @@ -2071,6 +2072,10 @@ class MediaInteractionChain(ChainBase): mtype=mediainfo.type, tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, + bangumiid=mediainfo.bangumi_id, + anilistid=mediainfo.anilist_id, + source=resolve_media_identity(media=mediainfo)[0], + mediaid=resolve_media_identity(media=mediainfo)[1], cache=False, ) if not mediainfo: @@ -2085,7 +2090,8 @@ class MediaInteractionChain(ChainBase): ) return {} - mediakey = mediainfo.tmdb_id or mediainfo.douban_id + media_source, media_id = resolve_media_identity(media=mediainfo) + mediakey = build_media_key(media_source, media_id) no_exists = {mediakey: {}} if meta.begin_season is not None: episodes = mediainfo.seasons.get(meta.begin_season) @@ -3528,7 +3534,8 @@ class MediaInteractionChain(ChainBase): """ if not no_exists: return [] - mediakey = mediainfo.tmdb_id or mediainfo.douban_id + media_source, media_id = resolve_media_identity(media=mediainfo) + mediakey = build_media_key(media_source, media_id) season_map = no_exists.get(mediakey) or {} if show_missing_only: return [ diff --git a/app/chain/search.py b/app/chain/search.py index 47c1f56ef..e375795e3 100644 --- a/app/chain/search.py +++ b/app/chain/search.py @@ -24,6 +24,7 @@ from app.helper.torrent import TorrentHelper from app.log import logger from app.schemas import NotExistMediaInfo from app.schemas.types import MediaType, ProgressKey, SystemConfigKey, EventType +from app.utils.media import build_media_key, resolve_media_identity from app.utils.string import StringUtils @@ -171,16 +172,38 @@ class SearchChain(ChainBase): @staticmethod def _build_search_keyword( - tmdbid: Optional[int] = None, doubanid: Optional[str] = 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, ) -> str: """ 根据媒体ID生成可重放的搜索关键字。 """ - if tmdbid is not None: - return f"tmdb:{tmdbid}" - if doubanid: - return f"douban:{doubanid}" - return "" + media_source, media_id = resolve_media_identity( + source=source, + media_id=mediaid, + tmdbid=tmdbid, + doubanid=doubanid, + bangumiid=bangumiid, + anilistid=anilistid, + ) + return build_media_key(media_source, media_id) + + @staticmethod + def _media_recognize_kwargs(mediainfo: MediaInfo) -> dict: + """从统一媒体信息构造完整的识别 ID 参数。""" + media_source, media_id = resolve_media_identity(media=mediainfo) + return { + "source": media_source, + "mediaid": media_id, + "tmdbid": mediainfo.tmdb_id, + "doubanid": mediainfo.douban_id, + "bangumiid": mediainfo.bangumi_id, + "anilistid": mediainfo.anilist_id, + } @staticmethod def _stringify_sites(sites: Optional[List[int]]) -> str: @@ -488,13 +511,22 @@ class SearchChain(ChainBase): state._ai_recommend_task = asyncio.create_task(run_recommend()) - def search_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, - mtype: MediaType = None, area: Optional[str] = "title", season: Optional[int] = None, - sites: List[int] = None, cache_local: bool = False) -> List[Context]: + def search_by_id( + self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, + mtype: MediaType = None, area: Optional[str] = "title", + season: Optional[int] = None, sites: List[int] = None, + cache_local: bool = False, + bangumiid: Optional[int] = None, anilistid: Optional[int] = None, + source: Optional[str] = None, mediaid: Optional[str] = None, + ) -> List[Context]: """ - 根据TMDBID/豆瓣ID搜索资源,精确匹配,不过滤本地存在的资源 + 根据数据源媒体 ID 搜索资源,精确匹配,不过滤本地存在的资源 :param tmdbid: TMDB ID :param doubanid: 豆瓣 ID + :param bangumiid: Bangumi ID + :param anilistid: AniList ID + :param source: 媒体数据源 + :param mediaid: 数据源原生 ID :param mtype: 媒体,电影 or 电视剧 :param area: 搜索范围,title or imdbid :param season: 季数 @@ -504,20 +536,26 @@ class SearchChain(ChainBase): if cache_local: self.cancel_ai_recommend() self.save_last_search_params( - keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid), + keyword=self._build_search_keyword( + source, mediaid, tmdbid, doubanid, bangumiid, anilistid + ), mtype=mtype, area=area, season=season, sites=sites, ) - mediainfo = self.recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype) + mediainfo = self.recognize_media( + source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid, + bangumiid=bangumiid, anilistid=anilistid, mtype=mtype, + ) if not mediainfo: - logger.error(f'{tmdbid} 媒体信息识别失败!') + logger.error(f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} 媒体信息识别失败!') return [] no_exists = None if season is not None: + media_source, media_id = resolve_media_identity(media=mediainfo) no_exists = { - tmdbid or doubanid: { + build_media_key(media_source, media_id): { season: NotExistMediaInfo(episodes=[]) } } @@ -658,14 +696,22 @@ class SearchChain(ChainBase): "total_items": len(subtitles) } - async def async_search_subtitles_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, - mtype: MediaType = None, season: Optional[int] = None, - episode: Optional[int] = None, sites: List[int] = None, - cache_local: bool = False) -> List[SubtitleInfo]: + async def async_search_subtitles_by_id( + self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, + mtype: MediaType = None, season: Optional[int] = None, + episode: Optional[int] = None, sites: List[int] = None, + cache_local: bool = False, + bangumiid: Optional[int] = None, anilistid: Optional[int] = None, + source: Optional[str] = None, mediaid: Optional[str] = None, + ) -> List[SubtitleInfo]: """ - 根据TMDBID/豆瓣ID异步精确搜索字幕,不应用过滤规则。 + 根据数据源媒体 ID 异步精确搜索字幕,不应用过滤规则。 :param tmdbid: TMDB ID :param doubanid: 豆瓣 ID + :param bangumiid: Bangumi ID + :param anilistid: AniList ID + :param source: 媒体数据源 + :param mediaid: 数据源原生 ID :param mtype: 媒体,电影 or 电视剧 :param season: 季数 :param episode: 集数 @@ -675,7 +721,9 @@ class SearchChain(ChainBase): if cache_local: self.cancel_ai_recommend() await self.async_save_last_search_params( - keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid), + keyword=self._build_search_keyword( + source, mediaid, tmdbid, doubanid, bangumiid, anilistid + ), mtype=mtype, area="title", season=season, @@ -683,14 +731,24 @@ class SearchChain(ChainBase): sites=sites, result_type="subtitle", ) - mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype) + mediainfo = await self.async_recognize_media( + source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid, + bangumiid=bangumiid, anilistid=anilistid, mtype=mtype, + ) if not mediainfo: - logger.error(f'{tmdbid} 媒体信息识别失败!') + logger.error( + f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} ' + '媒体信息识别失败!' + ) return [] subtitles = await self.__async_search_subtitles_for_media( mediainfo=mediainfo, tmdbid=tmdbid, doubanid=doubanid, + bangumiid=bangumiid, + anilistid=anilistid, + source=source, + mediaid=mediaid, season=season, episode=episode, sites=sites, @@ -708,14 +766,20 @@ class SearchChain(ChainBase): episode: Optional[int] = None, sites: List[int] = None, cache_local: bool = False, + bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, + source: Optional[str] = None, + mediaid: Optional[str] = None, ) -> AsyncIterator[dict]: """ - 根据TMDBID/豆瓣ID渐进式精确搜索字幕,先返回站点候选,再返回标题和剧集匹配后的结果。 + 根据数据源媒体 ID 渐进式精确搜索字幕,先返回站点候选,再返回标题和剧集匹配后的结果。 """ if cache_local: self.cancel_ai_recommend() await self.async_save_last_search_params( - keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid), + keyword=self._build_search_keyword( + source, mediaid, tmdbid, doubanid, bangumiid, anilistid + ), mtype=mtype, area="title", season=season, @@ -723,9 +787,15 @@ class SearchChain(ChainBase): sites=sites, result_type="subtitle", ) - mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype) + mediainfo = await self.async_recognize_media( + source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid, + bangumiid=bangumiid, anilistid=anilistid, mtype=mtype, + ) if not mediainfo: - logger.error(f'{tmdbid} 媒体信息识别失败!') + logger.error( + f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} ' + '媒体信息识别失败!' + ) yield { "type": "error", "success": False, @@ -738,6 +808,10 @@ class SearchChain(ChainBase): mediainfo=mediainfo, tmdbid=tmdbid, doubanid=doubanid, + bangumiid=bangumiid, + anilistid=anilistid, + source=source, + mediaid=mediaid, season=season, episode=episode, sites=sites): @@ -753,13 +827,22 @@ class SearchChain(ChainBase): if cache_local: await self.async_save_cache(subtitles, self.__subtitle_result_temp_file) - async def async_search_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, - mtype: MediaType = None, area: Optional[str] = "title", season: Optional[int] = None, - sites: List[int] = None, cache_local: bool = False) -> List[Context]: + async def async_search_by_id( + self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, + mtype: MediaType = None, area: Optional[str] = "title", + season: Optional[int] = None, sites: List[int] = None, + cache_local: bool = False, + bangumiid: Optional[int] = None, anilistid: Optional[int] = None, + source: Optional[str] = None, mediaid: Optional[str] = None, + ) -> List[Context]: """ - 根据TMDBID/豆瓣ID异步搜索资源,精确匹配,不过滤本地存在的资源 + 根据数据源媒体 ID 异步搜索资源,精确匹配,不过滤本地存在的资源 :param tmdbid: TMDB ID :param doubanid: 豆瓣 ID + :param bangumiid: Bangumi ID + :param anilistid: AniList ID + :param source: 媒体数据源 + :param mediaid: 数据源原生 ID :param mtype: 媒体,电影 or 电视剧 :param area: 搜索范围,title or imdbid :param season: 季数 @@ -769,20 +852,29 @@ class SearchChain(ChainBase): if cache_local: self.cancel_ai_recommend() await self.async_save_last_search_params( - keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid), + keyword=self._build_search_keyword( + source, mediaid, tmdbid, doubanid, bangumiid, anilistid + ), mtype=mtype, area=area, season=season, sites=sites, ) - mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype) + mediainfo = await self.async_recognize_media( + source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid, + bangumiid=bangumiid, anilistid=anilistid, mtype=mtype, + ) if not mediainfo: - logger.error(f'{tmdbid} 媒体信息识别失败!') + logger.error( + f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} ' + '媒体信息识别失败!' + ) return [] no_exists = None if season is not None: + media_source, media_id = resolve_media_identity(media=mediainfo) no_exists = { - tmdbid or doubanid: { + build_media_key(media_source, media_id): { season: NotExistMediaInfo(episodes=[]) } } @@ -913,25 +1005,37 @@ class SearchChain(ChainBase): logger.info(f'标题搜索过滤完成,剩余 {len(filtered_torrents)} 个资源') return filtered_torrents - async def async_search_by_id_stream(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, - mtype: MediaType = None, area: Optional[str] = "title", - season: Optional[int] = None, sites: List[int] = None, - cache_local: bool = False) -> AsyncIterator[dict]: + async def async_search_by_id_stream( + self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, + mtype: MediaType = None, area: Optional[str] = "title", + season: Optional[int] = None, sites: List[int] = None, + cache_local: bool = False, + bangumiid: Optional[int] = None, anilistid: Optional[int] = None, + source: Optional[str] = None, mediaid: Optional[str] = None, + ) -> AsyncIterator[dict]: """ - 根据TMDBID/豆瓣ID渐进式搜索资源,先返回站点原始候选,再返回过滤匹配后的最终结果 + 根据数据源媒体 ID 渐进式搜索资源,先返回站点原始候选,再返回过滤匹配后的最终结果 """ if cache_local: self.cancel_ai_recommend() await self.async_save_last_search_params( - keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid), + keyword=self._build_search_keyword( + source, mediaid, tmdbid, doubanid, bangumiid, anilistid + ), mtype=mtype, area=area, season=season, sites=sites, ) - mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype) + mediainfo = await self.async_recognize_media( + source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid, + bangumiid=bangumiid, anilistid=anilistid, mtype=mtype, + ) if not mediainfo: - logger.error(f'{tmdbid} 媒体信息识别失败!') + logger.error( + f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} ' + '媒体信息识别失败!' + ) yield { "type": "error", "success": False, @@ -941,8 +1045,9 @@ class SearchChain(ChainBase): no_exists = None if season is not None: + media_source, media_id = resolve_media_identity(media=mediainfo) no_exists = { - tmdbid or doubanid: { + build_media_key(media_source, media_id): { season: NotExistMediaInfo(episodes=[]) } } @@ -970,7 +1075,8 @@ class SearchChain(ChainBase): 准备搜索参数 """ # 缺失的季集 - mediakey = mediainfo.tmdb_id or mediainfo.douban_id + media_source, media_id = resolve_media_identity(media=mediainfo) + mediakey = build_media_key(media_source, media_id) if no_exists and no_exists.get(mediakey): # 过滤剧集 season_episodes = {sea: info.episodes @@ -1230,9 +1336,10 @@ class SearchChain(ChainBase): # 补充媒体信息 if not mediainfo.names: - mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type, - tmdbid=mediainfo.tmdb_id, - doubanid=mediainfo.douban_id) + mediainfo: MediaInfo = self.recognize_media( + mtype=mediainfo.type, + **self._media_recognize_kwargs(mediainfo), + ) if not mediainfo: logger.error(f'媒体信息识别失败!') return [] @@ -1313,9 +1420,10 @@ class SearchChain(ChainBase): # 补充媒体信息 if not mediainfo.names: - mediainfo: MediaInfo = await self.async_recognize_media(mtype=mediainfo.type, - tmdbid=mediainfo.tmdb_id, - doubanid=mediainfo.douban_id) + mediainfo: MediaInfo = await self.async_recognize_media( + mtype=mediainfo.type, + **self._media_recognize_kwargs(mediainfo), + ) if not mediainfo: logger.error(f'媒体信息识别失败!') return [] @@ -1385,9 +1493,10 @@ class SearchChain(ChainBase): # 补充媒体信息 if not mediainfo.names: - mediainfo = await self.async_recognize_media(mtype=mediainfo.type, - tmdbid=mediainfo.tmdb_id, - doubanid=mediainfo.douban_id) + mediainfo = await self.async_recognize_media( + mtype=mediainfo.type, + **self._media_recognize_kwargs(mediainfo), + ) if not mediainfo: logger.error(f'媒体信息识别失败!') yield { @@ -1619,6 +1728,10 @@ class SearchChain(ChainBase): mediainfo: MediaInfo, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, + bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, + source: Optional[str] = None, + mediaid: Optional[str] = None, season: Optional[int] = None, episode: Optional[int] = None, sites: List[int] = None, @@ -1633,17 +1746,23 @@ class SearchChain(ChainBase): logger.info(f'开始精确搜索字幕,关键词:{mediainfo.title} ...') if not mediainfo.names: - mediainfo = await self.async_recognize_media(mtype=mediainfo.type, - tmdbid=mediainfo.tmdb_id, - doubanid=mediainfo.douban_id) + mediainfo = await self.async_recognize_media( + mtype=mediainfo.type, + **self._media_recognize_kwargs(mediainfo), + ) if not mediainfo: logger.error('媒体信息识别失败!') return [] no_exists = None if season is not None: + media_source, media_id = resolve_media_identity( + media=mediainfo, source=source, media_id=mediaid, + tmdbid=tmdbid, doubanid=doubanid, + bangumiid=bangumiid, anilistid=anilistid, + ) no_exists = { - tmdbid or doubanid: { + build_media_key(media_source, media_id): { season: NotExistMediaInfo(episodes=[episode] if episode is not None else []) } } @@ -1689,6 +1808,10 @@ class SearchChain(ChainBase): mediainfo: MediaInfo, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, + bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, + source: Optional[str] = None, + mediaid: Optional[str] = None, season: Optional[int] = None, episode: Optional[int] = None, sites: List[int] = None, @@ -1704,9 +1827,10 @@ class SearchChain(ChainBase): logger.info(f'开始渐进式精确搜索字幕,关键词:{mediainfo.title} ...') if not mediainfo.names: - mediainfo = await self.async_recognize_media(mtype=mediainfo.type, - tmdbid=mediainfo.tmdb_id, - doubanid=mediainfo.douban_id) + mediainfo = await self.async_recognize_media( + mtype=mediainfo.type, + **self._media_recognize_kwargs(mediainfo), + ) if not mediainfo: logger.error('媒体信息识别失败!') yield { @@ -1718,8 +1842,13 @@ class SearchChain(ChainBase): no_exists = None if season is not None: + media_source, media_id = resolve_media_identity( + media=mediainfo, source=source, media_id=mediaid, + tmdbid=tmdbid, doubanid=doubanid, + bangumiid=bangumiid, anilistid=anilistid, + ) no_exists = { - tmdbid or doubanid: { + build_media_key(media_source, media_id): { season: NotExistMediaInfo(episodes=[episode] if episode is not None else []) } } diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index b7337223a..d6b799c1a 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -43,6 +43,12 @@ from app.schemas import (MediaRecognizeConvertEventData, SubscribeEpisodesRefres SubscribeCompletionCheckEventData) from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType, \ ContentType +from app.utils.media import ( + build_media_key, + normalize_media_source, + parse_media_key, + resolve_media_identity, +) subscribe_interaction_manager = SlashInteractionManager() @@ -55,9 +61,61 @@ def build_subscribe_meta(subscribe: Subscribe) -> MetaBase: meta.year = subscribe.year meta.begin_season = subscribe.season meta.type = MediaType(subscribe.type) + meta.tmdbid = subscribe.tmdbid + meta.doubanid = subscribe.doubanid + meta.bangumiid = subscribe.bangumiid + meta.anilistid = subscribe.anilistid + meta.media_source = subscribe.media_source + meta.media_id = subscribe.media_id return meta +def _media_recognize_kwargs(mediainfo: MediaInfo) -> dict: + """从统一媒体信息构造完整的识别 ID 参数。""" + media_source, media_id = resolve_media_identity(media=mediainfo) + return { + "source": media_source, + "mediaid": media_id, + "tmdbid": mediainfo.tmdb_id, + "doubanid": mediainfo.douban_id, + "bangumiid": mediainfo.bangumi_id, + "anilistid": mediainfo.anilist_id, + } + + +def _subscribe_recognize_kwargs(subscribe: Subscribe) -> dict: + """从订阅记录构造完整的识别 ID 参数。""" + media_source, media_id = resolve_media_identity(media=subscribe) + return { + "source": media_source, + "mediaid": media_id, + "tmdbid": subscribe.tmdbid, + "doubanid": subscribe.doubanid, + "bangumiid": subscribe.bangumiid, + "anilistid": subscribe.anilistid, + } + + +def _subscribe_media_key(subscribe: Subscribe) -> Union[str, int, None]: + """返回订阅缺失集映射使用的稳定媒体键。""" + media_source, media_id = resolve_media_identity(media=subscribe) + return build_media_key(media_source, media_id) or media_id + + +def _subscribe_media_keys(subscribe: Subscribe) -> List[Union[str, int]]: + """返回新旧缺失集缓存均可识别的订阅媒体键。""" + media_source, media_id = resolve_media_identity(media=subscribe) + candidates = [ + build_media_key(media_source, media_id), + subscribe.mediaid, + subscribe.tmdbid, + subscribe.doubanid, + subscribe.bangumiid, + subscribe.anilistid, + ] + return [candidate for candidate in candidates if candidate not in (None, "")] + + class SubscribeChain(ChainBase): """ 订阅管理处理链。 @@ -227,8 +285,14 @@ class SubscribeChain(ChainBase): if not subscribe.best_version: no_exists = no_exists or {} - mediakey = subscribe.tmdbid or subscribe.doubanid - left_seasons = no_exists.get(mediakey) or {} + left_seasons = next( + ( + no_exists.get(media_key) + for media_key in _subscribe_media_keys(subscribe) + if no_exists.get(media_key) is not None + ), + {}, + ) for season_info in left_seasons.values(): if season_info.season != subscribe.season: continue @@ -739,10 +803,12 @@ class SubscribeChain(ChainBase): if event_data.media_dict: mediachain = MediaChain() new_id = event_data.media_dict.get("id") - if event_data.convert_type == "themoviedb": - return mediachain.recognize_media(meta=_meta, tmdbid=new_id) - elif event_data.convert_type == "douban": - return mediachain.recognize_media(meta=_meta, doubanid=new_id) + if new_id is not None and event_data.convert_type: + return mediachain.recognize_media( + meta=_meta, + source=event_data.convert_type, + mediaid=str(new_id), + ) return None @staticmethod @@ -761,10 +827,12 @@ class SubscribeChain(ChainBase): if event_data.media_dict: mediachain = MediaChain() new_id = event_data.media_dict.get("id") - if event_data.convert_type == "themoviedb": - return await mediachain.async_recognize_media(meta=_meta, tmdbid=new_id) - elif event_data.convert_type == "douban": - return await mediachain.async_recognize_media(meta=_meta, doubanid=new_id) + if new_id is not None and event_data.convert_type: + return await mediachain.async_recognize_media( + meta=_meta, + source=event_data.convert_type, + mediaid=str(new_id), + ) return None def __get_default_kwargs(self, mtype: MediaType, **kwargs) -> dict: @@ -815,6 +883,9 @@ class SubscribeChain(ChainBase): username: Optional[str] = None, message: Optional[bool] = True, exist_ok: Optional[bool] = False, + anilistid: Optional[int] = None, + media_source: Optional[str] = None, + media_id: Optional[str] = None, **kwargs) -> Tuple[Optional[int], str]: """ 识别媒体信息并添加订阅 @@ -831,35 +902,29 @@ class SubscribeChain(ChainBase): if season is not None: metainfo.type = MediaType.TV metainfo.begin_season = season - # 识别媒体信息 - if settings.RECOGNIZE_SOURCE == "themoviedb": - # TMDB识别模式 - if not tmdbid: - if doubanid: - # 将豆瓣信息转换为TMDB信息 - tmdbinfo = MediaChain().get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype) - if tmdbinfo: - mediainfo = MediaInfo(tmdb_info=tmdbinfo) - elif mediaid: - # 未知前缀,广播事件解析媒体信息 - mediainfo = self.__get_event_media(mediaid, metainfo) - else: - # 使用TMDBID识别 - mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid, - episode_group=episode_group, cache=False) - else: - if doubanid: - # 豆瓣识别模式,不使用缓存 - mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid, cache=False) - elif mediaid: - # 未知前缀,广播事件解析媒体信息 - mediainfo = self.__get_event_media(mediaid, metainfo) - if mediainfo: - # 豆瓣标题处理 - meta = MetaInfo(mediainfo.title) - mediainfo.title = meta.name - if season is None: - season = meta.begin_season + if not media_source and not media_id and mediaid: + media_source, media_id = parse_media_key(mediaid) + if any((media_id, tmdbid, doubanid, bangumiid, anilistid)): + mediainfo = self.recognize_media( + meta=metainfo, + mtype=mtype, + source=media_source, + mediaid=media_id, + tmdbid=tmdbid, + doubanid=doubanid, + bangumiid=bangumiid, + anilistid=anilistid, + episode_group=episode_group, + cache=False, + ) + elif mediaid: + mediainfo = self.__get_event_media(mediaid, metainfo) + + if mediainfo and mediainfo.source != "themoviedb": + meta = MetaInfo(mediainfo.title) + mediainfo.title = meta.name + if season is None: + season = meta.begin_season # 使用名称识别兜底 if not mediainfo: @@ -883,9 +948,7 @@ class SubscribeChain(ChainBase): if not mediainfo.seasons or episode_group: # 补充媒体信息 mediainfo = self.recognize_media(mtype=mediainfo.type, - tmdbid=mediainfo.tmdb_id, - doubanid=mediainfo.douban_id, - bangumiid=mediainfo.bangumi_id, + **_media_recognize_kwargs(mediainfo), episode_group=episode_group, cache=False) if not mediainfo: @@ -898,7 +961,10 @@ class SubscribeChain(ChainBase): # 创建场景没有旧订阅事实,仅允许外部补正未知或扩展总集数。 total_episode = self.__apply_episodes_refresh( current_total_episode, season=season, mediainfo=mediainfo, - tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, scene="create") + tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, + bangumiid=mediainfo.bangumi_id, anilistid=mediainfo.anilist_id, + media_source=resolve_media_identity(media=mediainfo)[0], + media_id=resolve_media_identity(media=mediainfo)[1], scene="create") if current_total_episode and total_episode < current_total_episode: total_episode = current_total_episode if not total_episode: @@ -923,6 +989,13 @@ class SubscribeChain(ChainBase): mediainfo.douban_id = doubanid if bangumiid: mediainfo.bangumi_id = bangumiid + if anilistid: + mediainfo.anilist_id = anilistid + + media_source, media_id = resolve_media_identity( + media=mediainfo, source=media_source, media_id=media_id + ) + kwargs.update({"media_source": media_source, "media_id": media_id}) # 添加订阅 kwargs.update(self.__get_default_kwargs(mediainfo.type, **kwargs)) @@ -979,6 +1052,9 @@ class SubscribeChain(ChainBase): "tvdbid": mediainfo.tvdb_id, "doubanid": mediainfo.douban_id, "bangumiid": mediainfo.bangumi_id, + "anilistid": mediainfo.anilist_id, + "media_source": media_source, + "media_id": media_id, "season": metainfo.begin_season, "poster": mediainfo.get_poster_image(), "backdrop": mediainfo.get_backdrop_image(), @@ -1002,6 +1078,9 @@ class SubscribeChain(ChainBase): username: Optional[str] = None, message: Optional[bool] = True, exist_ok: Optional[bool] = False, + anilistid: Optional[int] = None, + media_source: Optional[str] = None, + media_id: Optional[str] = None, **kwargs) -> Tuple[Optional[int], str]: """ 异步识别媒体信息并添加订阅 @@ -1018,35 +1097,29 @@ class SubscribeChain(ChainBase): if season is not None: metainfo.type = MediaType.TV metainfo.begin_season = season - # 识别媒体信息 - if settings.RECOGNIZE_SOURCE == "themoviedb": - # TMDB识别模式 - if not tmdbid: - if doubanid: - # 将豆瓣信息转换为TMDB信息 - tmdbinfo = await MediaChain().async_get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype) - if tmdbinfo: - mediainfo = MediaInfo(tmdb_info=tmdbinfo) - elif mediaid: - # 未知前缀,广播事件解析媒体信息 - mediainfo = await self.__async_get_event_meida(mediaid, metainfo) - else: - # 使用TMDBID识别 - mediainfo = await self.async_recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid, - episode_group=episode_group, cache=False) - else: - if doubanid: - # 豆瓣识别模式,不使用缓存 - mediainfo = await self.async_recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid, cache=False) - elif mediaid: - # 未知前缀,广播事件解析媒体信息 - mediainfo = await self.__async_get_event_meida(mediaid, metainfo) - if mediainfo: - # 豆瓣标题处理 - meta = MetaInfo(mediainfo.title) - mediainfo.title = meta.name - if season is None: - season = meta.begin_season + if not media_source and not media_id and mediaid: + media_source, media_id = parse_media_key(mediaid) + if any((media_id, tmdbid, doubanid, bangumiid, anilistid)): + mediainfo = await self.async_recognize_media( + meta=metainfo, + mtype=mtype, + source=media_source, + mediaid=media_id, + tmdbid=tmdbid, + doubanid=doubanid, + bangumiid=bangumiid, + anilistid=anilistid, + episode_group=episode_group, + cache=False, + ) + elif mediaid: + mediainfo = await self.__async_get_event_meida(mediaid, metainfo) + + if mediainfo and mediainfo.source != "themoviedb": + meta = MetaInfo(mediainfo.title) + mediainfo.title = meta.name + if season is None: + season = meta.begin_season # 使用名称识别兜底 if not mediainfo: @@ -1070,9 +1143,7 @@ class SubscribeChain(ChainBase): if not mediainfo.seasons or episode_group: # 补充媒体信息 mediainfo = await self.async_recognize_media(mtype=mediainfo.type, - tmdbid=mediainfo.tmdb_id, - doubanid=mediainfo.douban_id, - bangumiid=mediainfo.bangumi_id, + **_media_recognize_kwargs(mediainfo), episode_group=episode_group, cache=False) if not mediainfo: @@ -1085,7 +1156,10 @@ class SubscribeChain(ChainBase): # 创建场景没有旧订阅事实,仅允许外部补正未知或扩展总集数。 total_episode = await self.__async_apply_episodes_refresh( current_total_episode, season=season, mediainfo=mediainfo, - tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, scene="create") + tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, + bangumiid=mediainfo.bangumi_id, anilistid=mediainfo.anilist_id, + media_source=resolve_media_identity(media=mediainfo)[0], + media_id=resolve_media_identity(media=mediainfo)[1], scene="create") if current_total_episode and total_episode < current_total_episode: total_episode = current_total_episode if not total_episode: @@ -1110,6 +1184,13 @@ class SubscribeChain(ChainBase): mediainfo.douban_id = doubanid if bangumiid: mediainfo.bangumi_id = bangumiid + if anilistid: + mediainfo.anilist_id = anilistid + + media_source, media_id = resolve_media_identity( + media=mediainfo, source=media_source, media_id=media_id + ) + kwargs.update({"media_source": media_source, "media_id": media_id}) # 列新默认参数 kwargs.update(self.__get_default_kwargs(mediainfo.type, **kwargs)) @@ -1166,6 +1247,9 @@ class SubscribeChain(ChainBase): "tvdbid": mediainfo.tvdb_id, "doubanid": mediainfo.douban_id, "bangumiid": mediainfo.bangumi_id, + "anilistid": mediainfo.anilist_id, + "media_source": media_source, + "media_id": media_id, "season": metainfo.begin_season, "poster": mediainfo.get_poster_image(), "backdrop": mediainfo.get_backdrop_image(), @@ -1180,9 +1264,16 @@ class SubscribeChain(ChainBase): """ 判断订阅是否已存在 """ - if SubscribeOper().exists(tmdbid=mediainfo.tmdb_id, - doubanid=mediainfo.douban_id, - season=meta.begin_season if meta else None): + media_source, media_id = resolve_media_identity(media=mediainfo) + if SubscribeOper().exists( + tmdbid=mediainfo.tmdb_id, + doubanid=mediainfo.douban_id, + bangumiid=mediainfo.bangumi_id, + anilistid=mediainfo.anilist_id, + media_source=media_source, + media_id=media_id, + season=meta.begin_season if meta else None, + ): return True return False @@ -1239,7 +1330,7 @@ class SubscribeChain(ChainBase): "current": subscribe.id, }, ) - mediakey = subscribe.tmdbid or subscribe.doubanid + mediakey = _subscribe_media_key(subscribe) custom_word_list = subscribe.custom_words.split("\n") if subscribe.custom_words else None search_attempted = False # 校验当前时间减订阅创建时间是否大于1分钟,否则跳过先,留出编辑订阅的时间 @@ -1267,11 +1358,13 @@ class SubscribeChain(ChainBase): logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}') continue # 识别媒体信息 - mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type, - tmdbid=subscribe.tmdbid, - doubanid=subscribe.doubanid, - episode_group=subscribe.episode_group, - cache=False) + mediainfo: MediaInfo = self.recognize_media( + meta=meta, + mtype=meta.type, + **_subscribe_recognize_kwargs(subscribe), + episode_group=subscribe.episode_group, + cache=False, + ) if not mediainfo: logger.warn( f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}') @@ -1463,9 +1556,9 @@ class SubscribeChain(ChainBase): """ 判断是否应完成订阅 """ - mediakey = subscribe.tmdbid or subscribe.doubanid + media_keys = _subscribe_media_keys(subscribe) # 是否有剩余集 - no_lefts = not lefts or not lefts.get(mediakey) + no_lefts = not lefts or not any(lefts.get(media_key) for media_key in media_keys) if downloads and meta.type == MediaType.TV: self.__record_subscribe_download_facts(subscribe=subscribe, mediainfo=mediainfo, downloads=downloads) elif downloads: @@ -1638,8 +1731,10 @@ class SubscribeChain(ChainBase): if global_vars.is_system_stopped: break # 如果种子未识别且失败次数未超过3次,尝试识别 - if (not context.media_info or (not context.media_info.tmdb_id - and not context.media_info.douban_id)) and context.media_recognize_fail_count < 3: + if ( + not context.media_info + or not resolve_media_identity(media=context.media_info)[1] + ) and context.media_recognize_fail_count < 3: logger.debug( f'尝试重新识别种子:{context.torrent_info.title},当前失败次数:{context.media_recognize_fail_count}/3') re_mediainfo = MediaChain().recognize_by_meta( @@ -1653,7 +1748,7 @@ class SubscribeChain(ChainBase): context.media_info = re_mediainfo context.match_source = self.__get_media_id_match_source(re_mediainfo) context.candidate_recognized = bool( - re_mediainfo.tmdb_id or re_mediainfo.douban_id + resolve_media_identity(media=re_mediainfo)[1] ) context.media_info_is_target = False # 重置失败次数 @@ -1698,7 +1793,7 @@ class SubscribeChain(ChainBase): }, ) logger.info(f'开始匹配订阅,标题:{subscribe.name} ...') - mediakey = subscribe.tmdbid or subscribe.doubanid + mediakey = _subscribe_media_key(subscribe) try: meta = build_subscribe_meta(subscribe) except ValueError: @@ -1709,11 +1804,13 @@ class SubscribeChain(ChainBase): if subscribe.sites: domains = SiteOper().get_domains_by_ids(subscribe.sites) # 识别媒体信息 - mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type, - tmdbid=subscribe.tmdbid, - doubanid=subscribe.doubanid, - episode_group=subscribe.episode_group, - cache=False) + mediainfo: MediaInfo = self.recognize_media( + meta=meta, + mtype=meta.type, + **_subscribe_recognize_kwargs(subscribe), + episode_group=subscribe.episode_group, + cache=False, + ) if not mediainfo: logger.warn( f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}') @@ -1787,13 +1884,14 @@ class SubscribeChain(ChainBase): _context.media_info = torrent_mediainfo _context.match_source = self.__get_media_id_match_source(torrent_mediainfo) _context.candidate_recognized = bool( - torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id + resolve_media_identity(media=torrent_mediainfo)[1] ) _context.media_info_is_target = False # 如果仍然没有识别到媒体信息,尝试标题匹配 - if not torrent_mediainfo or ( - not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id): + if not torrent_mediainfo or not resolve_media_identity( + media=torrent_mediainfo + )[1]: logger.debug( f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...') if TorrentHelper.match_torrent(mediainfo=mediainfo, @@ -1812,7 +1910,9 @@ class SubscribeChain(ChainBase): continue # 直接比对媒体信息 - if torrent_mediainfo and (torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id): + if torrent_mediainfo and resolve_media_identity( + media=torrent_mediainfo + )[1]: if torrent_mediainfo.type != mediainfo.type: continue if torrent_mediainfo.tmdb_id \ @@ -2022,11 +2122,13 @@ class SubscribeChain(ChainBase): logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}') continue # 识别媒体信息 - mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type, - tmdbid=subscribe.tmdbid, - doubanid=subscribe.doubanid, - episode_group=subscribe.episode_group, - cache=False) + mediainfo: MediaInfo = self.recognize_media( + meta=meta, + mtype=meta.type, + **_subscribe_recognize_kwargs(subscribe), + episode_group=subscribe.episode_group, + cache=False, + ) if not mediainfo: logger.warn( f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}') @@ -2040,6 +2142,10 @@ class SubscribeChain(ChainBase): total_episode = self.__apply_episodes_refresh( current_total_episode, season=subscribe.season, mediainfo=mediainfo, tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid, + bangumiid=subscribe.bangumiid, + anilistid=subscribe.anilistid, + media_source=subscribe.media_source, + media_id=subscribe.media_id, subscribe_id=subscribe.id, scene="refresh") old_total_episode = subscribe.total_episode or 0 if total_episode and total_episode < old_total_episode: @@ -2048,7 +2154,7 @@ class SubscribeChain(ChainBase): candidate_total=total_episode, meta=meta, mediainfo=mediainfo, - mediakey=subscribe.tmdbid or subscribe.doubanid, + mediakey=_subscribe_media_key(subscribe), ) if total_episode and total_episode != old_total_episode: progress_update = self.__prepare_total_episode_change_fields( @@ -2079,6 +2185,12 @@ class SubscribeChain(ChainBase): "description": mediainfo.overview, "imdbid": mediainfo.imdb_id, "tvdbid": mediainfo.tvdb_id, + "tmdbid": mediainfo.tmdb_id, + "doubanid": mediainfo.douban_id, + "bangumiid": mediainfo.bangumi_id, + "anilistid": mediainfo.anilist_id, + "media_source": resolve_media_identity(media=mediainfo)[0], + "media_id": resolve_media_identity(media=mediainfo)[1], "total_episode": total_episode, } update_data.update(progress_update) @@ -2103,8 +2215,13 @@ class SubscribeChain(ChainBase): if not source_keyword: return None # 只保留需要的字段动态获取订阅 - valid_fields = {k: v for k, v in source_keyword.items() - if k in ["type", "season", "tmdbid", "doubanid", "bangumiid"]} + valid_fields = { + k: v for k, v in source_keyword.items() + if k in [ + "type", "season", "tmdbid", "doubanid", "bangumiid", + "anilistid", "media_source", "media_id", + ] + } # 暂时不考虑订阅历史, 若有必要再添加 return SubscribeOper().get_by(**valid_fields) @@ -2145,11 +2262,19 @@ class SubscribeChain(ChainBase): # 订阅已存在则跳过 if subscribeoper.exists(tmdbid=share_sub.get("tmdbid"), doubanid=share_sub.get("doubanid"), + bangumiid=share_sub.get("bangumiid"), + anilistid=share_sub.get("anilistid"), + media_source=share_sub.get("media_source"), + media_id=share_sub.get("media_id"), season=share_sub.get("season")): continue # 已经订阅过跳过 if subscribeoper.exist_history(tmdbid=share_sub.get("tmdbid"), doubanid=share_sub.get("doubanid"), + bangumiid=share_sub.get("bangumiid"), + anilistid=share_sub.get("anilistid"), + media_source=share_sub.get("media_source"), + media_id=share_sub.get("media_id"), season=share_sub.get("season")): continue # 去除无效属性 @@ -2160,7 +2285,13 @@ class SubscribeChain(ChainBase): subscribe_in = schemas.Subscribe(**share_sub) mtype = MediaType(subscribe_in.type) # 豆瓣标题处理 - if subscribe_in.doubanid or subscribe_in.bangumiid: + if ( + subscribe_in.doubanid + or subscribe_in.bangumiid + or subscribe_in.anilistid + or normalize_media_source(subscribe_in.media_source) + not in (None, "themoviedb") + ): meta = MetaInfo(subscribe_in.name) subscribe_in.name = meta.name if subscribe_in.season is None: @@ -2177,6 +2308,9 @@ class SubscribeChain(ChainBase): season=subscribe_in.season, doubanid=subscribe_in.doubanid, bangumiid=subscribe_in.bangumiid, + anilistid=subscribe_in.anilistid, + media_source=subscribe_in.media_source, + media_id=subscribe_in.media_id, username="订阅分享", best_version=subscribe_in.best_version, save_path=subscribe_in.save_path, @@ -2239,25 +2373,25 @@ class SubscribeChain(ChainBase): except ValueError: logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}') continue - # 识别媒体信息 - if mtype == MediaType.MOVIE: - mediainfo: MediaInfo = await self.async_recognize_media(mtype=mtype, - tmdbid=subscribe.tmdbid, - doubanid=subscribe.doubanid, - bangumiid=subscribe.bangumiid, - episode_group=subscribe.episode_group, - cache=False) - if not mediainfo: - logger.warn( - f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}') - continue - else: - episodes = await TmdbChain().async_tmdb_episodes(tmdbid=subscribe.tmdbid, + # 先按订阅的主媒体身份预热对应数据源,再对 TMDB 额外预热分集接口。 + mediainfo: MediaInfo = await self.async_recognize_media( + mtype=mtype, + **_subscribe_recognize_kwargs(subscribe), + episode_group=subscribe.episode_group, + cache=False, + ) + if not mediainfo: + logger.warn( + f'未识别到媒体信息,标题:{subscribe.name},' + f'媒体源:{subscribe.media_source},媒体ID:{subscribe.media_id}') + continue + if mtype == MediaType.TV and mediainfo.source == "themoviedb" and mediainfo.tmdb_id: + episodes = await TmdbChain().async_tmdb_episodes(tmdbid=mediainfo.tmdb_id, season=subscribe.season, episode_group=subscribe.episode_group) if not episodes: logger.warn( - f'未识别到季集信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},豆瓣ID:{subscribe.doubanid},季:{subscribe.season}') + f'未识别到季集信息,标题:{subscribe.name},tmdbid:{mediainfo.tmdb_id},季:{subscribe.season}') continue if progress_callback: progress_callback( @@ -2289,6 +2423,21 @@ class SubscribeChain(ChainBase): if subscribe.doubanid and mediainfo.douban_id \ and mediainfo.douban_id != subscribe.doubanid: continue + subscribe_bangumiid = subscribe.bangumiid + media_bangumiid = mediainfo.bangumi_id + if subscribe_bangumiid and media_bangumiid \ + and media_bangumiid != subscribe_bangumiid: + continue + subscribe_anilistid = subscribe.anilistid + media_anilistid = mediainfo.anilist_id + if subscribe_anilistid and media_anilistid \ + and media_anilistid != subscribe_anilistid: + continue + subscribe_source, subscribe_media_id = resolve_media_identity(media=subscribe) + media_source, media_id = resolve_media_identity(media=mediainfo) + if subscribe_source == media_source and subscribe_media_id and media_id \ + and subscribe_media_id != media_id: + continue items = [] if mediainfo.type == MediaType.TV: # 电视剧有集数,使用 episode_list @@ -2419,15 +2568,13 @@ class SubscribeChain(ChainBase): mediainfo = self.recognize_media( meta=meta, mtype=meta.type, - tmdbid=subscribe.tmdbid, - doubanid=subscribe.doubanid, - bangumiid=subscribe.bangumiid, + **_subscribe_recognize_kwargs(subscribe), episode_group=subscribe.episode_group, cache=False, ) if not mediainfo: return {"scene": scene, "updated": False, "fields": [], "reason": "recognize_failed"} - mediakey = subscribe.tmdbid or subscribe.doubanid + mediakey = _subscribe_media_key(subscribe) exist_flag, no_exists = self.resolve_subscribe_missing( subscribe=subscribe, meta=meta, @@ -3636,7 +3783,14 @@ class SubscribeChain(ChainBase): # 所有下载记录 downloadhis = DownloadHistoryOper() - download_his = downloadhis.get_by_mediaid(tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid) + download_his = downloadhis.get_by_mediaid( + tmdbid=subscribe.tmdbid, + doubanid=subscribe.doubanid, + bangumiid=subscribe.bangumiid, + anilistid=subscribe.anilistid, + media_source=subscribe.media_source, + media_id=subscribe.media_id, + ) if download_his: for his in download_his: # 查询下载文件 @@ -3669,11 +3823,13 @@ class SubscribeChain(ChainBase): logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}') return subscribe_info # 识别媒体信息 - mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type, - tmdbid=subscribe.tmdbid, - doubanid=subscribe.doubanid, - episode_group=subscribe.episode_group, - cache=False) + mediainfo: MediaInfo = self.recognize_media( + meta=meta, + mtype=meta.type, + **_subscribe_recognize_kwargs(subscribe), + episode_group=subscribe.episode_group, + cache=False, + ) if not mediainfo: logger.warn( f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}') @@ -3837,7 +3993,7 @@ class SubscribeChain(ChainBase): priority>0 的目标集视为已满足;默认 False 保持主程序洗版完成需 priority==100 的搜索/完成口径。 """ - mediakey = mediakey or subscribe.tmdbid or subscribe.doubanid + mediakey = mediakey or _subscribe_media_key(subscribe) effective_total_episode = self.__resolve_effective_total_episode(subscribe, mediainfo) if not subscribe.best_version: @@ -3923,7 +4079,7 @@ class SubscribeChain(ChainBase): if subscribe.type != MediaType.TV.value or self.__is_full_best_version_enabled(subscribe): return candidate_total - target_key = mediakey or subscribe.tmdbid or subscribe.doubanid + target_key = mediakey or _subscribe_media_key(subscribe) target_season = subscribe.season target_start = subscribe.start_episode or 1 snapshot = copy.copy(subscribe) @@ -3944,7 +4100,14 @@ class SubscribeChain(ChainBase): return old_total if not isinstance(no_exists, dict): return candidate_total - seasons = no_exists.get(target_key) + seasons = next( + ( + no_exists.get(media_key) + for media_key in [target_key, *_subscribe_media_keys(subscribe)] + if no_exists.get(media_key) is not None + ), + None, + ) if not isinstance(seasons, dict): return candidate_total missing_info = seasons.get(target_season) @@ -3994,19 +4157,25 @@ class SubscribeChain(ChainBase): mediainfo: Optional[MediaInfo] = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, + bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, + media_source: Optional[str] = None, + media_id: Optional[str] = None, subscribe_id: Optional[int] = None, scene: Optional[str] = None) -> int: """ - 发送订阅总集数推算事件,允许外部把主程序本次识别到的 TMDB 当前季总集数向上覆盖。 + 发送订阅总集数推算事件,允许外部把当前数据源识别到的季总集数向上覆盖。 用途:插件在"待定集数"等场景经事件注入 total_episode 无监听者或外部未覆盖时返回入参原值,保证零行为变更。 - :param current_total: 主程序本次识别到的 TMDB 当前季总集数 + :param current_total: 主程序本次识别到的当前季总集数 :param season: 季号 :return: 最终采用的总集数 """ event_data = SubscribeEpisodesRefreshEventData( - tmdbid=tmdbid, doubanid=doubanid, season=season, mediainfo=mediainfo, + tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid, + anilistid=anilistid, media_source=media_source, media_id=media_id, + season=season, mediainfo=mediainfo, current_total_episode=current_total, subscribe_id=subscribe_id, scene=scene) event = eventmanager.send_event(ChainEventType.SubscribeEpisodesRefresh, event_data) if event and event.event_data: @@ -4021,13 +4190,19 @@ class SubscribeChain(ChainBase): mediainfo: Optional[MediaInfo] = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, + bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, + media_source: Optional[str] = None, + media_id: Optional[str] = None, subscribe_id: Optional[int] = None, scene: Optional[str] = None) -> int: """ __apply_episodes_refresh 的异步版本 """ event_data = SubscribeEpisodesRefreshEventData( - tmdbid=tmdbid, doubanid=doubanid, season=season, mediainfo=mediainfo, + tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid, + anilistid=anilistid, media_source=media_source, media_id=media_id, + season=season, mediainfo=mediainfo, current_total_episode=current_total, subscribe_id=subscribe_id, scene=scene) event = await eventmanager.async_send_event(ChainEventType.SubscribeEpisodesRefresh, event_data) if event and event.event_data: @@ -4059,6 +4234,10 @@ class SubscribeChain(ChainBase): new_total_episode = self.__apply_episodes_refresh( current_total_episode, season=subscribe.season, mediainfo=mediainfo, tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid, + bangumiid=subscribe.bangumiid, + anilistid=subscribe.anilistid, + media_source=subscribe.media_source, + media_id=subscribe.media_id, subscribe_id=subscribe.id, scene="precheck") old_total_episode = subscribe.total_episode or 0 if meta is not None and new_total_episode and new_total_episode < old_total_episode: @@ -4113,6 +4292,12 @@ class SubscribeChain(ChainBase): return "tmdbid" if mediainfo and mediainfo.douban_id: return "doubanid" + if mediainfo and mediainfo.bangumi_id: + return "bangumiid" + if mediainfo and mediainfo.anilist_id: + return "anilistid" + if mediainfo and all(resolve_media_identity(media=mediainfo)): + return "plugin" return "unknown" @staticmethod @@ -4150,7 +4335,10 @@ class SubscribeChain(ChainBase): 'imdbid': subscribe.imdbid, 'tvdbid': subscribe.tvdbid, 'doubanid': subscribe.doubanid, - 'bangumiid': subscribe.bangumiid + 'bangumiid': subscribe.bangumiid, + 'anilistid': subscribe.anilistid, + 'media_source': subscribe.media_source, + 'media_id': subscribe.media_id, } return f"Subscribe|{json.dumps(source_keyword, ensure_ascii=False)}" diff --git a/app/chain/torrents.py b/app/chain/torrents.py index a62fb4405..155318549 100644 --- a/app/chain/torrents.py +++ b/app/chain/torrents.py @@ -17,6 +17,7 @@ from app.helper.torrent import TorrentHelper from app.log import logger from app.schemas import Notification from app.schemas.types import SystemConfigKey, MessageChannel, NotificationType, MediaType +from app.utils.media import resolve_media_identity from app.utils.string import StringUtils @@ -131,12 +132,21 @@ class TorrentsChain(ChainBase): subscribe_tmdbid = cls._normalize_id(getattr(subscribe, "tmdbid", None)) subscribe_doubanid = cls._normalize_id(getattr(subscribe, "doubanid", None)) + subscribe_bangumiid = cls._normalize_id(subscribe.bangumiid) + subscribe_anilistid = cls._normalize_id(subscribe.anilistid) context_tmdbids = cls._context_tmdb_ids(context) context_doubanids = cls._context_douban_ids(context) + context_bangumiids = cls._context_bangumi_ids(context) + context_anilistids = cls._context_anilist_ids(context) + subscribe_identity = resolve_media_identity(media=subscribe) + context_identities = cls._context_media_identities(context) return bool( subscribe_tmdbid and subscribe_tmdbid in context_tmdbids or subscribe_doubanid and subscribe_doubanid in context_doubanids + or subscribe_bangumiid and subscribe_bangumiid in context_bangumiids + or subscribe_anilistid and subscribe_anilistid in context_anilistids + or all(subscribe_identity) and subscribe_identity in context_identities ) @classmethod @@ -181,6 +191,9 @@ class TorrentsChain(ChainBase): title=getattr(subscribe, "name", None), tmdb_id=getattr(subscribe, "tmdbid", None), douban_id=getattr(subscribe, "doubanid", None), + bangumi_id=subscribe.bangumiid, + anilist_id=subscribe.anilistid, + source=subscribe.media_source, season=getattr(subscribe, "season", None), ) @@ -255,7 +268,26 @@ class TorrentsChain(ChainBase): """ 判断候选是否已经带有明确媒体 ID。 """ - return bool(TorrentsChain._context_tmdb_ids(context) or TorrentsChain._context_douban_ids(context)) + return bool( + TorrentsChain._context_tmdb_ids(context) + or TorrentsChain._context_douban_ids(context) + or TorrentsChain._context_bangumi_ids(context) + or TorrentsChain._context_anilist_ids(context) + or TorrentsChain._context_media_identities(context) + ) + + @staticmethod + def _context_media_identities(context: Context) -> set[tuple[str, str]]: + """提取候选媒体信息与标题标签中的通用媒体身份。""" + identities = { + resolve_media_identity(media=getattr(context, "media_info", None)), + resolve_media_identity(media=getattr(context, "meta_info", None)), + } + return { + (source, media_id) + for source, media_id in identities + if source and media_id + } @staticmethod def _context_tmdb_ids(context: Context) -> set[str]: @@ -285,6 +317,30 @@ class TorrentsChain(ChainBase): ) if value } + @staticmethod + def _context_bangumi_ids(context: Context) -> set[str]: + """提取候选已有 Bangumi ID,兼容媒体信息与标题显式标签。""" + media_info = getattr(context, "media_info", None) + meta_info = getattr(context, "meta_info", None) + return { + value for value in ( + TorrentsChain._normalize_id(media_info.bangumi_id if media_info else None), + TorrentsChain._normalize_id(meta_info.bangumiid if meta_info else None), + ) if value + } + + @staticmethod + def _context_anilist_ids(context: Context) -> set[str]: + """提取候选已有 AniList ID,兼容媒体信息与标题显式标签。""" + media_info = getattr(context, "media_info", None) + meta_info = getattr(context, "meta_info", None) + return { + value for value in ( + TorrentsChain._normalize_id(media_info.anilist_id if media_info else None), + TorrentsChain._normalize_id(meta_info.anilistid if meta_info else None), + ) if value + } + @staticmethod def _normalize_id(value) -> Optional[str]: """ @@ -556,7 +612,9 @@ class TorrentsChain(ChainBase): mediainfo = MediaInfo() # 清理多余数据,减少内存占用 mediainfo.clear() - candidate_recognized = bool(mediainfo and (mediainfo.tmdb_id or mediainfo.douban_id)) + candidate_recognized = bool( + mediainfo and all(resolve_media_identity(media=mediainfo)) + ) match_source = self._get_media_id_match_source(mediainfo) # 上下文 context = Context( @@ -569,7 +627,7 @@ class TorrentsChain(ChainBase): media_info_is_target=False, ) # 如果未识别到媒体信息,设置初始失败次数为1 - if not mediainfo or (not mediainfo.tmdb_id and not mediainfo.douban_id): + if not mediainfo or not all(resolve_media_identity(media=mediainfo)): context.media_recognize_fail_count = 1 # 添加到缓存 if not torrents_cache.get(domain): @@ -616,14 +674,16 @@ class TorrentsChain(ChainBase): if "media_recognize_fail_count" not in context_fields: context.media_recognize_fail_count = 0 # 如果媒体信息未识别,设置初始失败次数 - if (not context.media_info or - (not context.media_info.tmdb_id and not context.media_info.douban_id)): + if not context.media_info or not all( + resolve_media_identity(media=context.media_info) + ): context.media_recognize_fail_count = 1 if "resource_source" not in context_fields: context.resource_source = "spider" if stype == "spider" else "rss" if "candidate_recognized" not in context_fields: context.candidate_recognized = bool( - context.media_info and (context.media_info.tmdb_id or context.media_info.douban_id) + context.media_info + and all(resolve_media_identity(media=context.media_info)) ) if "match_source" not in context_fields: context.match_source = ( @@ -642,6 +702,12 @@ class TorrentsChain(ChainBase): return "tmdbid" if mediainfo and mediainfo.douban_id: return "doubanid" + if mediainfo and mediainfo.bangumi_id: + return "bangumiid" + if mediainfo and mediainfo.anilist_id: + return "anilistid" + if mediainfo and all(resolve_media_identity(media=mediainfo)): + return "plugin" return "unknown" def __renew_rss_url(self, domain: str, site: dict): diff --git a/app/chain/transfer.py b/app/chain/transfer.py index 4974c8888..ddb82c606 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -55,6 +55,7 @@ from app.schemas.types import ( ContentType, ) from app.utils.mixins import ConfigReloadMixin +from app.utils.media import parse_media_key from app.utils.singleton import Singleton from app.utils.string import StringUtils from app.utils.system import SystemUtils @@ -142,12 +143,12 @@ class JobManager: if not media: return None, 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), + "themoviedb": media.tmdb_id, + "douban": media.douban_id, + "bangumi": media.bangumi_id, + "anilist": media.anilist_id, } - source = getattr(media, "source", None) + source = media.source 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), @@ -1591,7 +1592,13 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): task.meta, download_history ) if ( - (download_history.tmdbid or download_history.doubanid) + ( + download_history.media_id + or download_history.tmdbid + or download_history.doubanid + or download_history.bangumiid + or download_history.anilistid + ) and not history_year_conflict ): # 下载记录中已存在识别信息 @@ -1599,6 +1606,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): mtype=MediaType(download_history.type), tmdbid=download_history.tmdbid, doubanid=download_history.doubanid, + bangumiid=download_history.bangumiid, + anilistid=download_history.anilistid, + source=download_history.media_source, + mediaid=download_history.media_id, episode_group=download_history.episode_group, ) need_obtain_images = True @@ -2096,6 +2107,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): mtype=mtype, tmdbid=downloadhis.tmdbid, doubanid=downloadhis.doubanid, + bangumiid=downloadhis.bangumiid, + anilistid=downloadhis.anilistid, + source=downloadhis.media_source, + mediaid=downloadhis.media_id, episode_group=downloadhis.episode_group, ) if mediainfo: @@ -3332,7 +3347,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): source: Optional[str] = None, ): """ - 远程重新整理,参数 历史记录ID TMDBID|类型 + 远程重新整理,参数 历史记录ID 来源前缀:媒体ID|类型 """ def args_error(): @@ -3340,7 +3355,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): Notification( channel=channel, source=source, - title="请输入正确的命令格式:/redo [id] 或 /redo [id] [tmdbid/豆瓣id]|[类型]," + title="请输入正确的命令格式:/redo [id] 或 /redo [id] [来源前缀:媒体ID]|[类型]," "[id] 为整理记录编号", userid=userid, save_history=False, @@ -3374,7 +3389,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ) ) return - # TMDBID/豆瓣ID + # 带来源前缀的媒体 ID;旧格式继续兼容纯数字 TMDB ID 和非数字豆瓣 ID。 id_strs = arg_strs[1].split("|") media_id = id_strs[0] if not logid.isdigit(): @@ -3434,7 +3449,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): 根据历史记录,重新识别整理,只支持简单条件 :param logid: 历史记录ID :param mtype: 媒体类型 - :param mediaid: TMDB ID/豆瓣ID + :param mediaid: 带来源前缀的媒体 ID,或旧格式 TMDB/豆瓣 ID """ # 查询历史记录 history: TransferHistory = TransferHistoryOper().get(logid) @@ -3447,12 +3462,21 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return False, f"源目录不存在:{src_path}" # 查询媒体信息 if mtype and mediaid: - mediainfo = self.recognize_media( - mtype=mtype, - tmdbid=int(mediaid) if str(mediaid).isdigit() else None, - doubanid=mediaid, - episode_group=history.episode_group, - ) + media_source, source_media_id = parse_media_key(mediaid) + if media_source and source_media_id: + mediainfo = self.recognize_media( + mtype=mtype, + source=media_source, + mediaid=source_media_id, + episode_group=history.episode_group, + ) + else: + mediainfo = self.recognize_media( + mtype=mtype, + tmdbid=int(mediaid) if str(mediaid).isdigit() else None, + doubanid=mediaid if not str(mediaid).isdigit() else None, + episode_group=history.episode_group, + ) if mediainfo: # 更新媒体图片 self.obtain_images(mediainfo=mediainfo) @@ -3514,6 +3538,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): preview: Optional[bool] = False, sync_extra_files: Optional[bool] = True, cleanup_dest_fileitem: Optional[FileItem] = None, + bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, ) -> Tuple[bool, Union[str, dict]]: """ 手动整理,支持复杂条件,带进度显示 @@ -3522,6 +3548,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param target_path: 目标路径 :param tmdbid: TMDB ID :param doubanid: 豆瓣ID + :param bangumiid: Bangumi ID + :param anilistid: AniList ID :param media_source: 媒体数据源 :param media_id: 数据源原生ID :param mtype: 媒体类型 @@ -3542,12 +3570,14 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件 """ logger.info(f"手动整理:{fileitem.path} ...") - if tmdbid or doubanid or media_id: + if tmdbid or doubanid or bangumiid or anilistid or media_id: # 有输入媒体ID时单个识别 # 识别媒体信息 mediainfo: MediaInfo = MediaChain().recognize_media( tmdbid=tmdbid, doubanid=doubanid, + bangumiid=bangumiid, + anilistid=anilistid, source=media_source, mediaid=media_id, mtype=mtype, diff --git a/app/core/context.py b/app/core/context.py index e19d42710..85cd8d292 100644 --- a/app/core/context.py +++ b/app/core/context.py @@ -254,6 +254,8 @@ class MediaInfo: recognize_cache_hit = False # 来源:themoviedb、douban、bangumi、anilist source: str = None + # 当前数据源原生ID,主要用于保留插件自定义数据源身份 + media_id: str = None # 请求级刮削来源;为空时使用系统设置 scrape_source: str = None # 类型 电影、电视剧 @@ -1062,14 +1064,19 @@ class MediaInfo: 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) + media_source = self.source or next( + (source for source, media_id in source_ids.items() if media_id is not None), + None, + ) + dicts["source"] = media_source + dicts["mediaid_prefix"] = media_source + media_id = self.media_id or source_ids.get(media_source) dicts["media_id"] = str(media_id) if media_id is not None else None return dicts @@ -1111,7 +1118,7 @@ class Context: media_recognize_fail_count: int = 0 # 候选资源来源:rss、spider、search、unknown。 resource_source: str = "unknown" - # 候选匹配来源:tmdbid、doubanid、imdbid、title、plugin、unknown。 + # 候选匹配来源:tmdbid、doubanid、bangumiid、anilistid、imdbid、title、plugin、unknown。 match_source: str = "unknown" # 候选自身是否已经识别出有效媒体 ID。 candidate_recognized: bool = False diff --git a/app/core/meta/infopath.py b/app/core/meta/infopath.py index cf363e901..5e82d1844 100644 --- a/app/core/meta/infopath.py +++ b/app/core/meta/infopath.py @@ -23,7 +23,10 @@ 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 or file_meta.media_id: + if any(( + file_meta.tmdbid, file_meta.doubanid, + file_meta.bangumiid, file_meta.anilistid, 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 3343c7cd8..c06f3d21b 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 + bangumiid: int = None + anilistid: int = None media_source: Optional[str] = None media_id: Optional[str] = None episode_group: Optional[str] = None diff --git a/app/db/downloadhistory_oper.py b/app/db/downloadhistory_oper.py index 6f6f614be..5a3cebfb0 100644 --- a/app/db/downloadhistory_oper.py +++ b/app/db/downloadhistory_oper.py @@ -34,13 +34,29 @@ class DownloadHistoryOper(DbOper): if history and history.download_hash } - def get_by_mediaid(self, tmdbid: int, doubanid: str) -> List[DownloadHistory]: + def get_by_mediaid( + self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, + bangumiid: Optional[int] = None, anilistid: Optional[int] = None, + media_source: Optional[str] = None, media_id: Optional[str] = None, + ) -> List[DownloadHistory]: """ 按媒体ID查询下载记录 :param tmdbid: tmdbid :param doubanid: doubanid + :param bangumiid: Bangumi ID + :param anilistid: AniList ID + :param media_source: 媒体数据源 + :param media_id: 数据源原生 ID """ - return DownloadHistory.get_by_mediaid(self._db, tmdbid=tmdbid, doubanid=doubanid) + return DownloadHistory.get_by_mediaid( + self._db, + tmdbid=tmdbid, + doubanid=doubanid, + bangumiid=bangumiid, + anilistid=anilistid, + media_source=media_source, + media_id=media_id, + ) def add(self, **kwargs): """ diff --git a/app/db/models/downloadfailure.py b/app/db/models/downloadfailure.py index b90e5bda0..24356c9e4 100644 --- a/app/db/models/downloadfailure.py +++ b/app/db/models/downloadfailure.py @@ -24,6 +24,13 @@ class DownloadFailure(Base): tmdbid = Column(Integer) # 豆瓣ID doubanid = Column(String) + # Bangumi ID + bangumiid = Column(Integer) + # AniList ID + anilistid = Column(Integer) + # 统一媒体数据源与原生ID + media_source = Column(String) + media_id = Column(String) # Sxx seasons = Column(String) # Exx @@ -57,6 +64,7 @@ class DownloadFailure(Base): Index("ux_downloadfailure_fingerprint", "fingerprint", unique=True), Index("ix_downloadfailure_next_retry_at", "next_retry_at"), Index("ix_downloadfailure_media_site", "type", "tmdbid", "doubanid", "site"), + Index("ix_downloadfailure_media_identity_site", "type", "media_source", "media_id", "site"), ) @classmethod diff --git a/app/db/models/downloadhistory.py b/app/db/models/downloadhistory.py index ba54297f9..efe91c06b 100644 --- a/app/db/models/downloadhistory.py +++ b/app/db/models/downloadhistory.py @@ -31,6 +31,10 @@ class DownloadHistory(Base): imdbid = Column(String) tvdbid = Column(Integer) doubanid = Column(String) + bangumiid = Column(Integer, index=True) + anilistid = Column(Integer, index=True) + media_source = Column(String, index=True) + media_id = Column(String, index=True) # Sxx seasons = Column(String) # Exx @@ -67,6 +71,7 @@ class DownloadHistory(Base): __table_args__ = ( Index('ix_downloadhistory_download_hash_date', 'download_hash', 'date'), Index('ix_downloadhistory_date_id', 'date', 'id'), + Index('ix_downloadhistory_media_identity', 'media_source', 'media_id'), ) @classmethod @@ -115,17 +120,27 @@ class DownloadHistory(Base): @classmethod @db_query - def get_by_mediaid(cls, db: Session, tmdbid: int, doubanid: str): - if tmdbid: - return ( - db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).all() - ) - elif doubanid: - return ( - db.query(DownloadHistory) - .filter(DownloadHistory.doubanid == doubanid) - .all() - ) + def get_by_mediaid( + cls, db: Session, tmdbid: Optional[int] = None, + doubanid: Optional[str] = None, bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, media_source: Optional[str] = None, + media_id: Optional[str] = None, + ): + """按统一媒体身份或兼容 ID 查询下载历史。""" + query = db.query(DownloadHistory) + if media_source and media_id: + return query.filter( + DownloadHistory.media_source == media_source, + DownloadHistory.media_id == str(media_id), + ).all() + if tmdbid is not None: + return query.filter(DownloadHistory.tmdbid == tmdbid).all() + if doubanid: + return query.filter(DownloadHistory.doubanid == doubanid).all() + if bangumiid is not None: + return query.filter(DownloadHistory.bangumiid == bangumiid).all() + if anilistid is not None: + return query.filter(DownloadHistory.anilistid == anilistid).all() return [] @classmethod diff --git a/app/db/models/subscribe.py b/app/db/models/subscribe.py index 69fa719e5..634de70c2 100644 --- a/app/db/models/subscribe.py +++ b/app/db/models/subscribe.py @@ -26,7 +26,10 @@ class Subscribe(Base): tvdbid = Column(Integer) doubanid = Column(String, index=True) bangumiid = Column(Integer, index=True) + anilistid = Column(Integer, index=True) mediaid = Column(String, index=True) + media_source = Column(String, index=True) + media_id = Column(String, index=True) # 季号 season = Column(Integer) # 海报 @@ -94,80 +97,116 @@ class Subscribe(Base): __table_args__ = ( Index('ix_subscribe_type_date', 'type', 'date'), + Index('ix_subscribe_media_identity', 'media_source', 'media_id'), ) @classmethod - @db_query - def exists(cls, db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, - season: Optional[int] = None): - if tmdbid: - if season is not None: - return db.query(cls).filter(cls.tmdbid == tmdbid, - cls.season == season).first() - return db.query(cls).filter(cls.tmdbid == tmdbid).first() - elif doubanid: - return db.query(cls).filter(cls.doubanid == doubanid).first() + def _identity_condition( + cls, + media_source: Optional[str] = None, + media_id: Optional[str] = None, + tmdbid: Optional[int] = None, + doubanid: Optional[str] = None, + bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, + ): + """按统一媒体身份优先级构造订阅查询条件。""" + if media_source and media_id: + return (cls.media_source == media_source) & (cls.media_id == str(media_id)) + if tmdbid is not None: + return cls.tmdbid == tmdbid + if doubanid: + return cls.doubanid == doubanid + if bangumiid is not None: + return cls.bangumiid == bangumiid + if anilistid is not None: + return cls.anilistid == anilistid return None @classmethod - @async_db_query - async def async_exists(cls, db: AsyncSession, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, - season: Optional[int] = None): - if tmdbid: - if season is not None: - result = await db.execute( - select(cls).filter(cls.tmdbid == tmdbid, cls.season == season) - ) - else: - result = await db.execute( - select(cls).filter(cls.tmdbid == tmdbid) - ) - elif doubanid: - result = await db.execute( - select(cls).filter(cls.doubanid == doubanid) - ) - else: + @db_query + def exists( + cls, db: Session, tmdbid: Optional[int] = None, + doubanid: Optional[str] = None, bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, media_source: Optional[str] = None, + media_id: Optional[str] = None, season: Optional[int] = None, + ): + """按媒体身份与季号查询已有订阅。""" + condition = cls._identity_condition( + media_source, media_id, tmdbid, doubanid, bangumiid, anilistid + ) + if condition is None: return None + query = db.query(cls).filter(condition) + if season is not None: + query = query.filter(cls.season == season) + return query.first() + + @classmethod + @async_db_query + async def async_exists( + cls, db: AsyncSession, tmdbid: Optional[int] = None, + doubanid: Optional[str] = None, bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, media_source: Optional[str] = None, + media_id: Optional[str] = None, season: Optional[int] = None, + ): + """异步按媒体身份与季号查询已有订阅。""" + condition = cls._identity_condition( + media_source, media_id, tmdbid, doubanid, bangumiid, anilistid + ) + if condition is None: + return None + query = select(cls).filter(condition) + if season is not None: + query = query.filter(cls.season == season) + result = await db.execute(query) return result.scalars().first() @classmethod @db_query - def exists_by_username(cls, db: Session, username: str, tmdbid: Optional[int] = None, - doubanid: Optional[str] = None, season: Optional[int] = None): + def exists_by_username( + cls, db: Session, username: str, tmdbid: Optional[int] = None, + doubanid: Optional[str] = None, bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, media_source: Optional[str] = None, + media_id: Optional[str] = None, season: Optional[int] = None, + ): """ 按订阅 owner 查询同一媒体的订阅行。 """ if not username: return None - if tmdbid: - query = db.query(cls).filter(cls.username == username, cls.tmdbid == tmdbid) - if season is not None: - query = query.filter(cls.season == season) - return query.first() - elif doubanid: - return db.query(cls).filter(cls.username == username, cls.doubanid == doubanid).first() - return None + condition = cls._identity_condition( + media_source, media_id, tmdbid, doubanid, bangumiid, anilistid + ) + if condition is None: + return None + query = db.query(cls).filter(cls.username == username, condition) + if season is not None: + query = query.filter(cls.season == season) + return query.first() @classmethod @async_db_query - async def async_exists_by_username(cls, db: AsyncSession, username: str, tmdbid: Optional[int] = None, - doubanid: Optional[str] = None, season: Optional[int] = None): + async def async_exists_by_username( + cls, db: AsyncSession, username: str, tmdbid: Optional[int] = None, + doubanid: Optional[str] = None, bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, media_source: Optional[str] = None, + media_id: Optional[str] = None, season: Optional[int] = None, + ): """ 异步按订阅 owner 查询同一媒体的订阅行。 """ if not username: return None - if tmdbid: - query = select(cls).filter(cls.username == username, cls.tmdbid == tmdbid) - if season is not None: - query = query.filter(cls.season == season) - result = await db.execute(query) - elif doubanid: - result = await db.execute( - select(cls).filter(cls.username == username, cls.doubanid == doubanid) - ) - else: + condition = cls._identity_condition( + media_source, media_id, tmdbid, doubanid, bangumiid, anilistid + ) + if condition is None: return None + query = select(cls).filter(cls.username == username, condition) + if season is not None: + query = query.filter(cls.season == season) + result = await db.execute(query) return result.scalars().first() @classmethod @@ -300,6 +339,29 @@ class Subscribe(Base): ) return result.scalars().all() + @classmethod + @async_db_query + async def async_list_by_anilistid(cls, db: AsyncSession, anilistid: int): + """异步按 AniList ID 查询候选订阅列表。""" + result = await db.execute( + select(cls).filter(cls.anilistid == anilistid) + ) + return result.scalars().all() + + @classmethod + @async_db_query + async def async_list_by_media_identity( + cls, db: AsyncSession, media_source: str, media_id: str, + ): + """异步按统一媒体身份查询候选订阅列表。""" + result = await db.execute( + select(cls).filter( + cls.media_source == media_source, + cls.media_id == str(media_id), + ) + ) + return result.scalars().all() + @classmethod @db_query def get_by_mediaid(cls, db: Session, mediaid: str): @@ -326,62 +388,45 @@ class Subscribe(Base): @classmethod @db_query - def get_by(cls, db: Session, type: str, season: Optional[str] = None, - tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[str] = None): + def get_by( + cls, db: Session, type: str, season: Optional[str] = None, + tmdbid: Optional[int] = None, doubanid: Optional[str] = None, + bangumiid: Optional[int] = None, anilistid: Optional[int] = None, + media_source: Optional[str] = None, media_id: Optional[str] = None, + ): """ 根据条件查询订阅 """ - # TMDBID - if tmdbid: - if season is not None: - result = db.query(cls).filter( - cls.tmdbid == tmdbid, cls.type == type, cls.season == season - ) - else: - result = db.query(cls).filter(cls.tmdbid == tmdbid, cls.type == type) - # 豆瓣ID - elif doubanid: - result = db.query(cls).filter(cls.doubanid == doubanid, cls.type == type) - # BangumiID - elif bangumiid: - result = db.query(cls).filter(cls.bangumiid == bangumiid, cls.type == type) - else: + condition = cls._identity_condition( + media_source, media_id, tmdbid, doubanid, bangumiid, anilistid + ) + if condition is None: return None - - return result.first() + query = db.query(cls).filter(condition, cls.type == type) + if season is not None: + query = query.filter(cls.season == season) + return query.first() @classmethod @async_db_query - async def async_get_by(cls, db: AsyncSession, type: str, season: Optional[str] = None, - tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[str] = None): + async def async_get_by( + cls, db: AsyncSession, type: str, season: Optional[str] = None, + tmdbid: Optional[int] = None, doubanid: Optional[str] = None, + bangumiid: Optional[int] = None, anilistid: Optional[int] = None, + media_source: Optional[str] = None, media_id: Optional[str] = None, + ): """ 根据条件查询订阅 """ - # TMDBID - if tmdbid: - if season is not None: - result = await db.execute( - select(cls).filter( - cls.tmdbid == tmdbid, cls.type == type, cls.season == season - ) - ) - else: - result = await db.execute( - select(cls).filter(cls.tmdbid == tmdbid, cls.type == type) - ) - # 豆瓣ID - elif doubanid: - result = await db.execute( - select(cls).filter(cls.doubanid == doubanid, cls.type == type) - ) - # BangumiID - elif bangumiid: - result = await db.execute( - select(cls).filter(cls.bangumiid == bangumiid, cls.type == type) - ) - else: + condition = cls._identity_condition( + media_source, media_id, tmdbid, doubanid, bangumiid, anilistid + ) + if condition is None: return None - + query = select(cls).filter(condition, cls.type == type) + if season is not None: + query = query.filter(cls.season == season) + result = await db.execute(query) return result.scalars().first() @db_update diff --git a/app/db/models/subscribehistory.py b/app/db/models/subscribehistory.py index de578ec35..2af988a17 100644 --- a/app/db/models/subscribehistory.py +++ b/app/db/models/subscribehistory.py @@ -25,7 +25,10 @@ class SubscribeHistory(Base): tvdbid = Column(Integer) doubanid = Column(String, index=True) bangumiid = Column(Integer, index=True) + anilistid = Column(Integer, index=True) mediaid = Column(String, index=True) + media_source = Column(String, index=True) + media_id = Column(String, index=True) # 季号 season = Column(Integer) # 海报 @@ -79,6 +82,7 @@ class SubscribeHistory(Base): __table_args__ = ( Index('ix_subscribehistory_type_date', 'type', 'date'), + Index('ix_subscribehistory_media_identity', 'media_source', 'media_id'), ) @classmethod @@ -128,35 +132,63 @@ class SubscribeHistory(Base): return result.scalars().all() @classmethod - @db_query - def exists(cls, db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, - season: Optional[int] = None): - if tmdbid: - if season is not None: - return db.query(cls).filter(cls.tmdbid == tmdbid, - cls.season == season).first() - return db.query(cls).filter(cls.tmdbid == tmdbid).first() - elif doubanid: - return db.query(cls).filter(cls.doubanid == doubanid).first() + def _identity_condition( + cls, + media_source: Optional[str] = None, + media_id: Optional[str] = None, + tmdbid: Optional[int] = None, + doubanid: Optional[str] = None, + bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, + ): + """按统一媒体身份优先级构造订阅历史查询条件。""" + if media_source and media_id: + return (cls.media_source == media_source) & (cls.media_id == str(media_id)) + if tmdbid is not None: + return cls.tmdbid == tmdbid + if doubanid: + return cls.doubanid == doubanid + if bangumiid is not None: + return cls.bangumiid == bangumiid + if anilistid is not None: + return cls.anilistid == anilistid return None @classmethod - @async_db_query - async def async_exists(cls, db: AsyncSession, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, - season: Optional[int] = None): - if tmdbid: - if season is not None: - result = await db.execute( - select(cls).filter(cls.tmdbid == tmdbid, cls.season == season) - ) - else: - result = await db.execute( - select(cls).filter(cls.tmdbid == tmdbid) - ) - elif doubanid: - result = await db.execute( - select(cls).filter(cls.doubanid == doubanid) - ) - else: + @db_query + def exists( + cls, db: Session, tmdbid: Optional[int] = None, + doubanid: Optional[str] = None, bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, media_source: Optional[str] = None, + media_id: Optional[str] = None, season: Optional[int] = None, + ): + """按媒体身份与季号查询订阅历史。""" + condition = cls._identity_condition( + media_source, media_id, tmdbid, doubanid, bangumiid, anilistid + ) + if condition is None: return None + query = db.query(cls).filter(condition) + if season is not None: + query = query.filter(cls.season == season) + return query.first() + + @classmethod + @async_db_query + async def async_exists( + cls, db: AsyncSession, tmdbid: Optional[int] = None, + doubanid: Optional[str] = None, bangumiid: Optional[int] = None, + anilistid: Optional[int] = None, media_source: Optional[str] = None, + media_id: Optional[str] = None, season: Optional[int] = None, + ): + """异步按媒体身份与季号查询订阅历史。""" + condition = cls._identity_condition( + media_source, media_id, tmdbid, doubanid, bangumiid, anilistid + ) + if condition is None: + return None + query = select(cls).filter(condition) + if season is not None: + query = query.filter(cls.season == season) + result = await db.execute(query) return result.scalars().first() diff --git a/app/db/models/transferhistory.py b/app/db/models/transferhistory.py index 68afc543a..339e61ac9 100644 --- a/app/db/models/transferhistory.py +++ b/app/db/models/transferhistory.py @@ -48,6 +48,8 @@ class TransferHistory(Base): imdbid = Column(String) tvdbid = Column(Integer) doubanid = Column(String) + bangumiid = Column(Integer, index=True) + anilistid = Column(Integer, index=True) # 统一媒体数据源与原生ID media_source = Column(String, index=True) media_id = Column(String, index=True) @@ -75,6 +77,7 @@ class TransferHistory(Base): __table_args__ = ( Index('ix_transferhistory_status_date', 'status', 'date'), Index('ix_transferhistory_date_id', 'date', 'id'), + Index('ix_transferhistory_media_identity', 'media_source', 'media_id'), ) @classmethod diff --git a/app/db/subscribe_oper.py b/app/db/subscribe_oper.py index 1a517b578..ec4849f55 100644 --- a/app/db/subscribe_oper.py +++ b/app/db/subscribe_oper.py @@ -5,6 +5,7 @@ from app.core.context import MediaInfo from app.db import DbOper from app.db.models.subscribe import Subscribe from app.db.models.subscribehistory import SubscribeHistory +from app.utils.media import resolve_media_identity INTEGER_FLAG_FIELDS = ("best_version", "best_version_full", "search_imdbid", "manual_total_episode") @@ -31,17 +32,26 @@ class SubscribeOper(DbOper): """ owner_scope = bool(kwargs.pop("owner_scope", False)) username = kwargs.get("username") if owner_scope else None + media_source, media_id = resolve_media_identity( + media=mediainfo, + source=kwargs.get("media_source"), + media_id=kwargs.get("media_id"), + ) + identity_params = { + "tmdbid": mediainfo.tmdb_id, + "doubanid": mediainfo.douban_id, + "bangumiid": mediainfo.bangumi_id, + "anilistid": mediainfo.anilist_id, + "media_source": media_source, + "media_id": media_id, + "season": kwargs.get("season"), + } if username: subscribe = Subscribe.exists_by_username(self._db, username=username, - tmdbid=mediainfo.tmdb_id, - doubanid=mediainfo.douban_id, - season=kwargs.get('season')) + **identity_params) else: - subscribe = Subscribe.exists(self._db, - tmdbid=mediainfo.tmdb_id, - doubanid=mediainfo.douban_id, - season=kwargs.get('season')) + subscribe = Subscribe.exists(self._db, **identity_params) kwargs.update({ "name": mediainfo.title, "year": mediainfo.year, @@ -51,6 +61,9 @@ class SubscribeOper(DbOper): "tvdbid": mediainfo.tvdb_id, "doubanid": mediainfo.douban_id, "bangumiid": mediainfo.bangumi_id, + "anilistid": mediainfo.anilist_id, + "media_source": media_source, + "media_id": media_id, "episode_group": mediainfo.episode_group, "poster": mediainfo.get_poster_image(), "backdrop": mediainfo.get_backdrop_image(), @@ -67,14 +80,9 @@ class SubscribeOper(DbOper): if username: subscribe = Subscribe.exists_by_username(self._db, username=username, - tmdbid=mediainfo.tmdb_id, - doubanid=mediainfo.douban_id, - season=kwargs.get('season')) + **identity_params) else: - subscribe = Subscribe.exists(self._db, - tmdbid=mediainfo.tmdb_id, - doubanid=mediainfo.douban_id, - season=kwargs.get('season')) + subscribe = Subscribe.exists(self._db, **identity_params) return subscribe.id, "新增订阅成功" else: return subscribe.id, "订阅已存在" @@ -85,17 +93,26 @@ class SubscribeOper(DbOper): """ owner_scope = bool(kwargs.pop("owner_scope", False)) username = kwargs.get("username") if owner_scope else None + media_source, media_id = resolve_media_identity( + media=mediainfo, + source=kwargs.get("media_source"), + media_id=kwargs.get("media_id"), + ) + identity_params = { + "tmdbid": mediainfo.tmdb_id, + "doubanid": mediainfo.douban_id, + "bangumiid": mediainfo.bangumi_id, + "anilistid": mediainfo.anilist_id, + "media_source": media_source, + "media_id": media_id, + "season": kwargs.get("season"), + } if username: subscribe = await Subscribe.async_exists_by_username(self._db, username=username, - tmdbid=mediainfo.tmdb_id, - doubanid=mediainfo.douban_id, - season=kwargs.get('season')) + **identity_params) else: - subscribe = await Subscribe.async_exists(self._db, - tmdbid=mediainfo.tmdb_id, - doubanid=mediainfo.douban_id, - season=kwargs.get('season')) + subscribe = await Subscribe.async_exists(self._db, **identity_params) kwargs.update({ "name": mediainfo.title, "year": mediainfo.year, @@ -105,6 +122,9 @@ class SubscribeOper(DbOper): "tvdbid": mediainfo.tvdb_id, "doubanid": mediainfo.douban_id, "bangumiid": mediainfo.bangumi_id, + "anilistid": mediainfo.anilist_id, + "media_source": media_source, + "media_id": media_id, "episode_group": mediainfo.episode_group, "poster": mediainfo.get_poster_image(), "backdrop": mediainfo.get_backdrop_image(), @@ -121,31 +141,32 @@ class SubscribeOper(DbOper): if username: subscribe = await Subscribe.async_exists_by_username(self._db, username=username, - tmdbid=mediainfo.tmdb_id, - doubanid=mediainfo.douban_id, - season=kwargs.get('season')) + **identity_params) else: - subscribe = await Subscribe.async_exists(self._db, - tmdbid=mediainfo.tmdb_id, - doubanid=mediainfo.douban_id, - season=kwargs.get('season')) + subscribe = await Subscribe.async_exists(self._db, **identity_params) return subscribe.id, "新增订阅成功" else: return subscribe.id, "订阅已存在" - def exists(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, - season: Optional[int] = None) -> bool: + def exists( + self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, + bangumiid: Optional[int] = None, anilistid: Optional[int] = None, + media_source: Optional[str] = None, media_id: Optional[str] = None, + season: Optional[int] = None, + ) -> bool: """ 判断是否存在 """ - if tmdbid: - if season is not None: - return True if Subscribe.exists(self._db, tmdbid=tmdbid, season=season) else False - else: - return True if Subscribe.exists(self._db, tmdbid=tmdbid) else False - elif doubanid: - return True if Subscribe.exists(self._db, doubanid=doubanid) else False - return False + return bool(Subscribe.exists( + self._db, + tmdbid=tmdbid, + doubanid=doubanid, + bangumiid=bangumiid, + anilistid=anilistid, + media_source=media_source, + media_id=media_id, + season=season, + )) def get(self, sid: int) -> Subscribe: """ @@ -159,19 +180,33 @@ class SubscribeOper(DbOper): """ return await Subscribe.async_get(self._db, rid=sid) - def get_by(self, type: str, season: Optional[str] = None, tmdbid: Optional[int] = None, - doubanid: Optional[str] = None, bangumiid: Optional[str] = None) -> Optional[Subscribe]: + def get_by( + self, type: str, season: Optional[str] = None, + tmdbid: Optional[int] = None, doubanid: Optional[str] = None, + bangumiid: Optional[int] = None, anilistid: Optional[int] = None, + media_source: Optional[str] = None, media_id: Optional[str] = None, + ) -> Optional[Subscribe]: """ 根据条件查询订阅 """ - return Subscribe.get_by(self._db, type, season, tmdbid, doubanid, bangumiid) + return Subscribe.get_by( + self._db, type, season, tmdbid, doubanid, bangumiid, anilistid, + media_source, media_id, + ) - async def async_get_by(self, type: str, season: Optional[str] = None, tmdbid: Optional[int] = None, - doubanid: Optional[str] = None, bangumiid: Optional[str] = None) -> Optional[Subscribe]: + async def async_get_by( + self, type: str, season: Optional[str] = None, + tmdbid: Optional[int] = None, doubanid: Optional[str] = None, + bangumiid: Optional[int] = None, anilistid: Optional[int] = None, + media_source: Optional[str] = None, media_id: Optional[str] = None, + ) -> Optional[Subscribe]: """ 根据条件查询订阅 """ - return await Subscribe.async_get_by(self._db, type, season, tmdbid, doubanid, bangumiid) + return await Subscribe.async_get_by( + self._db, type, season, tmdbid, doubanid, bangumiid, anilistid, + media_source, media_id, + ) def list(self, state: Optional[str] = None) -> List[Subscribe]: """ @@ -261,15 +296,22 @@ class SubscribeOper(DbOper): subscribe = SubscribeHistory(**kwargs) subscribe.create(self._db) - def exist_history(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, season: Optional[int] = None): + def exist_history( + self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, + bangumiid: Optional[int] = None, anilistid: Optional[int] = None, + media_source: Optional[str] = None, media_id: Optional[str] = None, + season: Optional[int] = None, + ) -> bool: """ 判断是否存在订阅历史 """ - if tmdbid: - if season is not None: - return True if SubscribeHistory.exists(self._db, tmdbid=tmdbid, season=season) else False - else: - return True if SubscribeHistory.exists(self._db, tmdbid=tmdbid) else False - elif doubanid: - return True if SubscribeHistory.exists(self._db, doubanid=doubanid) else False - return False + return bool(SubscribeHistory.exists( + self._db, + tmdbid=tmdbid, + doubanid=doubanid, + bangumiid=bangumiid, + anilistid=anilistid, + media_source=media_source, + media_id=media_id, + season=season, + )) diff --git a/app/db/transferhistory_oper.py b/app/db/transferhistory_oper.py index 663aff96f..cd5a40075 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, + bangumiid=mediainfo.bangumi_id, + anilistid=mediainfo.anilist_id, media_source=mediainfo.source, media_id=mediainfo.to_dict().get("media_id"), seasons=meta.season, @@ -231,6 +233,8 @@ class TransferHistoryOper(DbOper): imdbid=mediainfo.imdb_id, tvdbid=mediainfo.tvdb_id, doubanid=mediainfo.douban_id, + bangumiid=mediainfo.bangumi_id, + anilistid=mediainfo.anilist_id, media_source=mediainfo.source, media_id=mediainfo.to_dict().get("media_id"), seasons=meta.season, @@ -249,6 +253,8 @@ class TransferHistoryOper(DbOper): year=meta.year, tmdbid=meta.tmdbid, doubanid=meta.doubanid, + bangumiid=meta.bangumiid, + anilistid=meta.anilistid, media_source=meta.media_source, media_id=meta.media_id, src=fileitem.path, diff --git a/app/helper/message.py b/app/helper/message.py index e185dde99..40bf20ddc 100644 --- a/app/helper/message.py +++ b/app/helper/message.py @@ -92,6 +92,17 @@ class TemplateContextBuilder: if not mediainfo: return season_fmt = f"S{mediainfo.season:02d}" if mediainfo.season is not None else None + source_ids = { + "themoviedb": mediainfo.tmdb_id, + "douban": mediainfo.douban_id, + "bangumi": mediainfo.bangumi_id, + "anilist": mediainfo.anilist_id, + } + media_source = mediainfo.source or next( + (source for source, media_id in source_ids.items() if media_id is not None), + None, + ) + media_id = mediainfo.media_id or source_ids.get(media_source) base_info = { # 标题 "title": cls.__convert_invalid_characters(mediainfo.title), @@ -135,6 +146,14 @@ class TemplateContextBuilder: "imdbid": mediainfo.imdb_id, # 豆瓣ID "doubanid": mediainfo.douban_id, + # Bangumi ID + "bangumiid": mediainfo.bangumi_id, + # AniList ID + "anilistid": mediainfo.anilist_id, + # 当前媒体数据源 + "media_source": media_source, + # 当前数据源原生ID + "media_id": str(media_id) if media_id is not None else None, } context.update({**base_info, **media_info}) diff --git a/app/helper/server.py b/app/helper/server.py index 4958325df..ec784659d 100644 --- a/app/helper/server.py +++ b/app/helper/server.py @@ -15,6 +15,7 @@ from app.db.workflow_oper import WorkflowOper from app.log import logger from app.schemas.types import MediaType, SystemConfigKey, media_type_to_agent from app.utils.http import AsyncRequestUtils, RequestUtils +from app.utils.media import resolve_media_identity from app.utils.system import SystemUtils from version import APP_VERSION, FRONTEND_VERSION @@ -1332,7 +1333,10 @@ class MoviePilotServerHelper: tmdbid = item.get("tmdbid") doubanid = item.get("doubanid") bangumiid = item.get("bangumiid") - if not any([tmdbid, doubanid, bangumiid]): + anilistid = item.get("anilistid") + media_source = item.get("media_source") + media_id = item.get("media_id") + if not any([tmdbid, doubanid, bangumiid, anilistid, media_id]): return None return { @@ -1340,6 +1344,9 @@ class MoviePilotServerHelper: "tmdbid": tmdbid, "doubanid": doubanid, "bangumiid": bangumiid, + "anilistid": anilistid, + "source": media_source, + "mediaid": media_id, "season": item.get("season"), } @@ -1486,7 +1493,8 @@ class MoviePilotServerHelper: media_type = cls._extract_media_type(meta=meta, mediainfo=mediainfo) if not keyword or not media_type: return None - if not any([mediainfo.tmdb_id, mediainfo.douban_id, mediainfo.bangumi_id]): + media_source, media_id = resolve_media_identity(media=mediainfo) + if not media_id: return None return { @@ -1502,6 +1510,9 @@ class MoviePilotServerHelper: "tmdbid": mediainfo.tmdb_id, "doubanid": mediainfo.douban_id, "bangumiid": mediainfo.bangumi_id, + "anilistid": mediainfo.anilist_id, + "media_source": media_source, + "media_id": media_id, } @classmethod diff --git a/app/helper/torrent.py b/app/helper/torrent.py index 7d43d295f..7a61e84f4 100644 --- a/app/helper/torrent.py +++ b/app/helper/torrent.py @@ -397,7 +397,10 @@ class TorrentHelper: :param torrent: 种子信息 """ # 比对词条指定的tmdbid - if torrent_meta.tmdbid or torrent_meta.doubanid: + if any(( + torrent_meta.tmdbid, torrent_meta.doubanid, + torrent_meta.bangumiid, torrent_meta.anilistid, + )): if torrent_meta.tmdbid and torrent_meta.tmdbid == mediainfo.tmdb_id: logger.info( f'{mediainfo.title} 通过词表指定TMDBID匹配到资源:{torrent.site_name} - {torrent.title}') @@ -406,6 +409,14 @@ class TorrentHelper: logger.info( f'{mediainfo.title} 通过词表指定豆瓣ID匹配到资源:{torrent.site_name} - {torrent.title}') return True + if torrent_meta.bangumiid and torrent_meta.bangumiid == mediainfo.bangumi_id: + logger.info( + f'{mediainfo.title} 通过词表指定 Bangumi ID 匹配到资源:{torrent.site_name} - {torrent.title}') + return True + if torrent_meta.anilistid and torrent_meta.anilistid == mediainfo.anilist_id: + logger.info( + f'{mediainfo.title} 通过词表指定 AniList ID 匹配到资源:{torrent.site_name} - {torrent.title}') + return True # 要匹配的媒体标题、原标题 media_titles = { StringUtils.clear_upper(mediainfo.title), diff --git a/app/locales/en-US.json b/app/locales/en-US.json index 2ab31b1e2..36a87e48d 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -118,6 +118,8 @@ "模型响应为空": "Model response is empty", "LLM 调用超时": "LLM call timed out", "刮削路径无效": "Scraping path is invalid", + "指定媒体ID时必须同时指定媒体数据源": "The media source must be specified together with the media ID", + "媒体ID格式无效": "Invalid media ID format", "刮削失败,无法识别媒体信息": "Scraping failed: unable to recognize media information", "刮削路径不存在": "Scraping path does not exist", "保存成功": "Saved successfully", diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json index 5d0539023..ddcb1cd1d 100644 --- a/app/locales/zh-TW.json +++ b/app/locales/zh-TW.json @@ -118,6 +118,8 @@ "模型响应为空": "模型回應為空", "LLM 调用超时": "LLM 呼叫逾時", "刮削路径无效": "刮削路徑無效", + "指定媒体ID时必须同时指定媒体数据源": "指定媒體ID時必須同時指定媒體資料源", + "媒体ID格式无效": "媒體ID格式無效", "刮削失败,无法识别媒体信息": "刮削失敗,無法識別媒體資訊", "刮削路径不存在": "刮削路徑不存在", "保存成功": "儲存成功", diff --git a/app/schemas/context.py b/app/schemas/context.py index 0fd0eab8a..d3e8b65f7 100644 --- a/app/schemas/context.py +++ b/app/schemas/context.py @@ -67,6 +67,14 @@ class MetaInfo(BaseModel): media_source: Optional[str] = None # 显式媒体数据源原生ID media_id: Optional[str] = None + # TMDB ID + tmdbid: Optional[int] = None + # 豆瓣 ID + doubanid: Optional[str] = None + # Bangumi ID + bangumiid: Optional[int] = None + # AniList ID + anilistid: Optional[int] = None class MediaInfo(BaseModel): @@ -318,7 +326,7 @@ class Context(BaseModel): torrent_info: Optional[TorrentInfo] = None # 候选资源来源:rss、spider、search、unknown resource_source: Optional[str] = "unknown" - # 候选匹配来源:tmdbid、doubanid、imdbid、title、plugin、unknown + # 候选匹配来源:tmdbid、doubanid、bangumiid、anilistid、imdbid、title、plugin、unknown match_source: Optional[str] = "unknown" # 候选自身是否已经识别出有效媒体 ID candidate_recognized: Optional[bool] = False diff --git a/app/schemas/event.py b/app/schemas/event.py index aeaa19edc..be03b931a 100644 --- a/app/schemas/event.py +++ b/app/schemas/event.py @@ -593,6 +593,10 @@ class SubscribeEpisodesRefreshEventData(ChainEventData): # 输入参数 tmdbid: Optional[int] = Field(default=None, description="TMDB ID") doubanid: Optional[str] = Field(default=None, description="豆瓣 ID") + bangumiid: Optional[int] = Field(default=None, description="Bangumi ID") + anilistid: Optional[int] = Field(default=None, description="AniList ID") + media_source: Optional[str] = Field(default=None, description="媒体数据源") + media_id: Optional[str] = Field(default=None, description="数据源原生 ID") season: Optional[int] = Field(default=None, description="季号") mediainfo: Any = Field(default=None, description="媒体信息") current_total_episode: int = Field(default=0, description="主程序本次识别到的 TMDB 当前季总集数") diff --git a/app/schemas/history.py b/app/schemas/history.py index 071c136db..48fccca18 100644 --- a/app/schemas/history.py +++ b/app/schemas/history.py @@ -22,6 +22,14 @@ class DownloadHistory(BaseModel): tvdbid: Optional[int] = None # 豆瓣ID doubanid: Optional[str] = None + # Bangumi ID + bangumiid: Optional[int] = None + # AniList ID + anilistid: Optional[int] = None + # 媒体数据源 + media_source: Optional[str] = None + # 数据源原生ID + media_id: Optional[str] = None # 季Sxx seasons: Optional[str] = None # 集Exx @@ -83,6 +91,10 @@ class TransferHistory(BaseModel): tvdbid: Optional[int] = None # 豆瓣ID doubanid: Optional[str] = None + # Bangumi ID + bangumiid: Optional[int] = None + # AniList ID + anilistid: Optional[int] = None # 媒体数据源 media_source: Optional[str] = None # 数据源原生ID diff --git a/app/schemas/subscribe.py b/app/schemas/subscribe.py index 9cfc6fd26..482729dff 100644 --- a/app/schemas/subscribe.py +++ b/app/schemas/subscribe.py @@ -62,7 +62,10 @@ class Subscribe(BaseModel): tmdbid: Optional[int] = None doubanid: Optional[str] = None bangumiid: Optional[int] = None + anilistid: Optional[int] = None mediaid: Optional[str] = None + media_source: Optional[str] = None + media_id: Optional[str] = None # 季号 season: Optional[int] = None # 海报 @@ -171,6 +174,9 @@ class SubscribeShare(BaseModel): tmdbid: Optional[int] = None doubanid: Optional[str] = None bangumiid: Optional[int] = None + anilistid: Optional[int] = None + media_source: Optional[str] = None + media_id: Optional[str] = None # 季号 season: Optional[int] = None # 海报 diff --git a/app/schemas/transfer.py b/app/schemas/transfer.py index 7729fd73c..1b25ac5b4 100644 --- a/app/schemas/transfer.py +++ b/app/schemas/transfer.py @@ -215,6 +215,10 @@ class ManualTransferItem(BaseModel): tmdbid: Optional[int] = None # 豆瓣ID doubanid: Optional[str] = None + # Bangumi ID + bangumiid: Optional[int] = None + # AniList ID + anilistid: Optional[int] = None # 媒体数据源 media_source: Optional[ Literal["themoviedb", "douban", "bangumi", "anilist"] diff --git a/app/utils/media.py b/app/utils/media.py new file mode 100644 index 000000000..4e60caaf2 --- /dev/null +++ b/app/utils/media.py @@ -0,0 +1,112 @@ +from typing import Any, Optional, Tuple + + +MEDIA_SOURCE_ALIASES = { + "tmdb": "themoviedb", + "themoviedb": "themoviedb", + "douban": "douban", + "bangumi": "bangumi", + "anilist": "anilist", +} + +MEDIA_SOURCE_PREFIXES = { + "themoviedb": "tmdb", + "douban": "douban", + "bangumi": "bangumi", + "anilist": "anilist", +} + +MEDIA_SOURCE_ID_FIELDS = { + "themoviedb": ("tmdb_id", "tmdbid"), + "douban": ("douban_id", "doubanid"), + "bangumi": ("bangumi_id", "bangumiid"), + "anilist": ("anilist_id", "anilistid"), +} + + +def normalize_media_source(source: Optional[str]) -> Optional[str]: + """规范化媒体数据源名称,兼容外部使用的 ``tmdb`` 前缀。""" + if not source: + return None + normalized = str(source).strip().casefold() + return MEDIA_SOURCE_ALIASES.get(normalized, normalized or None) + + +def parse_media_key(media_key: Optional[str]) -> Tuple[Optional[str], Optional[str]]: + """解析带来源前缀的媒体键,返回规范化数据源与原生 ID。""" + if not media_key or ":" not in str(media_key): + return None, None + prefix, media_id = str(media_key).split(":", 1) + source = normalize_media_source(prefix) + media_id = media_id.strip() + if not source or not media_id: + return None, None + return source, media_id + + +def resolve_media_identity( + media: Any = None, + source: Optional[str] = None, + media_id: Optional[Any] = None, + tmdbid: Optional[Any] = None, + doubanid: Optional[Any] = None, + bangumiid: Optional[Any] = None, + anilistid: Optional[Any] = None, +) -> Tuple[Optional[str], Optional[str]]: + """ + 从统一媒体对象、通用身份或兼容 ID 中解析主媒体身份。 + + 显式 ``source/media_id`` 优先;未指定来源时按 TMDB、豆瓣、Bangumi、 + AniList 的兼容顺序选择首个有效 ID。 + """ + normalized_source = normalize_media_source(source) + if normalized_source and media_id is not None and str(media_id).strip(): + return normalized_source, str(media_id).strip() + + values = { + "themoviedb": tmdbid, + "douban": doubanid, + "bangumi": bangumiid, + "anilist": anilistid, + } + if media is not None: + normalized_source = normalized_source or normalize_media_source( + getattr(media, "source", None) or getattr(media, "media_source", None) + ) + object_media_id = getattr(media, "media_id", None) + if normalized_source and object_media_id is not None and str(object_media_id).strip(): + return normalized_source, str(object_media_id).strip() + for media_source, fields in MEDIA_SOURCE_ID_FIELDS.items(): + for field in fields: + value = getattr(media, field, None) + if value is not None and str(value).strip(): + values[media_source] = value + break + + legacy_source, legacy_media_id = parse_media_key( + getattr(media, "mediaid", None) + ) + if not normalized_source and legacy_source and legacy_media_id: + return legacy_source, legacy_media_id + + if normalized_source: + value = values.get(normalized_source) + return ( + normalized_source, + str(value).strip() if value is not None and str(value).strip() else None, + ) + + for media_source in MEDIA_SOURCE_ID_FIELDS: + value = values.get(media_source) + if value is not None and str(value).strip(): + return media_source, str(value).strip() + return None, None + + +def build_media_key(source: Optional[str], media_id: Optional[Any]) -> str: + """构造 API 使用的带来源前缀媒体键。""" + normalized_source = normalize_media_source(source) + if not normalized_source or media_id is None or not str(media_id).strip(): + return "" + prefix = MEDIA_SOURCE_PREFIXES.get(normalized_source, normalized_source) + return f"{prefix}:{str(media_id).strip()}" diff --git a/app/workflow/actions/add_subscribe.py b/app/workflow/actions/add_subscribe.py index 10460328a..60e8af275 100644 --- a/app/workflow/actions/add_subscribe.py +++ b/app/workflow/actions/add_subscribe.py @@ -76,6 +76,9 @@ class AddSubscribeAction(BaseAction): season=mediainfo.season, doubanid=mediainfo.douban_id, bangumiid=mediainfo.bangumi_id, + anilistid=mediainfo.anilist_id, + media_source=mediainfo.source, + media_id=mediainfo.to_dict().get("media_id"), username=settings.SUPERUSER) if sid: self._added_subscribes.append(sid) diff --git a/app/workflow/actions/fetch_torrents.py b/app/workflow/actions/fetch_torrents.py index 94234d142..f817270b9 100644 --- a/app/workflow/actions/fetch_torrents.py +++ b/app/workflow/actions/fetch_torrents.py @@ -93,6 +93,10 @@ class FetchTorrentsAction(BaseAction): break torrents = searchchain.search_by_id(tmdbid=media.tmdb_id, doubanid=media.douban_id, + bangumiid=media.bangumi_id, + anilistid=media.anilist_id, + source=media.source, + mediaid=media.media_id, mtype=MediaType(media.type), sites=params.sites) for torrent in torrents: diff --git a/database/versions/f7b2d5c9a301_2_2_14.py b/database/versions/f7b2d5c9a301_2_2_14.py new file mode 100644 index 000000000..53e3b5362 --- /dev/null +++ b/database/versions/f7b2d5c9a301_2_2_14.py @@ -0,0 +1,209 @@ +"""2.2.14 +统一媒体身份字段并补齐 Bangumi/AniList ID + +Revision ID: f7b2d5c9a301 +Revises: e6a1c4b8d2f0 +Create Date: 2026-07-21 +""" + +from typing import Iterable + +from alembic import op +import sqlalchemy as sa + + +revision = "f7b2d5c9a301" +down_revision = "e6a1c4b8d2f0" +branch_labels = None +depends_on = None + + +def _has_column( + inspector: sa.Inspector, + table_name: str, + column_name: str, +) -> bool: + """检查数据表是否已存在指定字段。""" + 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 _has_index( + inspector: sa.Inspector, + table_name: str, + index_name: str, +) -> bool: + """检查数据表是否已存在指定索引。""" + if table_name not in inspector.get_table_names(): + return False + return any( + index["name"] == index_name + for index in inspector.get_indexes(table_name) + ) + + +def _add_columns(table_name: str, columns: Iterable[sa.Column]) -> None: + """为指定表补充尚不存在的字段。""" + for column in columns: + inspector = sa.inspect(op.get_bind()) + if not _has_column(inspector, table_name, column.name): + op.add_column(table_name, column) + + +def _create_index(table_name: str, index_name: str, columns: list[str]) -> None: + """为指定表创建尚不存在的索引。""" + inspector = sa.inspect(op.get_bind()) + if not _has_index(inspector, table_name, index_name): + op.create_index(index_name, table_name, columns) + + +def _backfill_media_identity(table_name: str, has_mediaid: bool = False) -> None: + """使用兼容 ID 幂等回填统一媒体身份。""" + columns = [ + sa.column("tmdbid", sa.Integer()), + sa.column("doubanid", sa.String()), + sa.column("bangumiid", sa.Integer()), + sa.column("anilistid", sa.Integer()), + sa.column("media_source", sa.String()), + sa.column("media_id", sa.String()), + ] + if has_mediaid: + columns.append(sa.column("mediaid", sa.String())) + table = sa.table(table_name, *columns) + connection = op.get_bind() + + if has_mediaid: + for prefix, source in ( + ("tmdb", "themoviedb"), + ("themoviedb", "themoviedb"), + ("douban", "douban"), + ("bangumi", "bangumi"), + ("anilist", "anilist"), + ): + connection.execute( + table.update() + .where(table.c.media_id.is_(None)) + .where(table.c.mediaid.like(f"{prefix}:%")) + .values( + media_source=source, + media_id=sa.func.substr(table.c.mediaid, len(prefix) + 2), + ) + ) + + for source, field in ( + ("themoviedb", "tmdbid"), + ("douban", "doubanid"), + ("bangumi", "bangumiid"), + ("anilist", "anilistid"), + ): + identity_column = table.c[field] + connection.execute( + table.update() + .where(table.c.media_id.is_(None)) + .where(identity_column.is_not(None)) + .values( + media_source=source, + media_id=sa.cast(identity_column, sa.String()), + ) + ) + + +def upgrade() -> None: + """升级媒体身份字段并迁移存量数据。""" + _add_columns("subscribe", ( + sa.Column("anilistid", sa.Integer(), nullable=True), + sa.Column("media_source", sa.String(), nullable=True), + sa.Column("media_id", sa.String(), nullable=True), + )) + _add_columns("subscribehistory", ( + sa.Column("anilistid", sa.Integer(), nullable=True), + sa.Column("media_source", sa.String(), nullable=True), + sa.Column("media_id", sa.String(), nullable=True), + )) + _add_columns("downloadhistory", ( + sa.Column("bangumiid", sa.Integer(), nullable=True), + sa.Column("anilistid", sa.Integer(), nullable=True), + sa.Column("media_source", sa.String(), nullable=True), + sa.Column("media_id", sa.String(), nullable=True), + )) + _add_columns("transferhistory", ( + sa.Column("bangumiid", sa.Integer(), nullable=True), + sa.Column("anilistid", sa.Integer(), nullable=True), + )) + _add_columns("downloadfailure", ( + sa.Column("bangumiid", sa.Integer(), nullable=True), + sa.Column("anilistid", sa.Integer(), nullable=True), + sa.Column("media_source", sa.String(), nullable=True), + sa.Column("media_id", sa.String(), nullable=True), + )) + + for table_name, fields in { + "subscribe": ("anilistid", "media_source", "media_id"), + "subscribehistory": ("anilistid", "media_source", "media_id"), + "downloadhistory": ("bangumiid", "anilistid", "media_source", "media_id"), + "transferhistory": ("bangumiid", "anilistid"), + }.items(): + for field in fields: + _create_index(table_name, f"ix_{table_name}_{field}", [field]) + + for table_name in ("subscribe", "subscribehistory", "downloadhistory", "transferhistory"): + _create_index( + table_name, + f"ix_{table_name}_media_identity", + ["media_source", "media_id"], + ) + _create_index( + "downloadfailure", + "ix_downloadfailure_media_identity_site", + ["type", "media_source", "media_id", "site"], + ) + + _backfill_media_identity("subscribe", has_mediaid=True) + _backfill_media_identity("subscribehistory", has_mediaid=True) + _backfill_media_identity("downloadhistory") + _backfill_media_identity("transferhistory") + _backfill_media_identity("downloadfailure") + + +def downgrade() -> None: + """回滚统一媒体身份及 Bangumi/AniList 字段。""" + for table_name, index_names in { + "subscribe": ( + "ix_subscribe_media_identity", "ix_subscribe_media_id", + "ix_subscribe_media_source", "ix_subscribe_anilistid", + ), + "subscribehistory": ( + "ix_subscribehistory_media_identity", "ix_subscribehistory_media_id", + "ix_subscribehistory_media_source", "ix_subscribehistory_anilistid", + ), + "downloadhistory": ( + "ix_downloadhistory_media_identity", "ix_downloadhistory_media_id", + "ix_downloadhistory_media_source", "ix_downloadhistory_anilistid", + "ix_downloadhistory_bangumiid", + ), + "transferhistory": ( + "ix_transferhistory_media_identity", "ix_transferhistory_anilistid", + "ix_transferhistory_bangumiid", + ), + "downloadfailure": ("ix_downloadfailure_media_identity_site",), + }.items(): + for index_name in index_names: + inspector = sa.inspect(op.get_bind()) + if _has_index(inspector, table_name, index_name): + op.drop_index(index_name, table_name=table_name) + + for table_name, fields in { + "subscribe": ("media_id", "media_source", "anilistid"), + "subscribehistory": ("media_id", "media_source", "anilistid"), + "downloadhistory": ("media_id", "media_source", "anilistid", "bangumiid"), + "transferhistory": ("anilistid", "bangumiid"), + "downloadfailure": ("media_id", "media_source", "anilistid", "bangumiid"), + }.items(): + for field in fields: + inspector = sa.inspect(op.get_bind()) + if _has_column(inspector, table_name, field): + op.drop_column(table_name, field) diff --git a/docs/mcp-api.md b/docs/mcp-api.md index fda38f011..ff70e2091 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -112,29 +112,31 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返 #### 媒体识别 / 整理 -媒体识别、搜索和手动整理支持 `themoviedb`、`douban`、`bangumi`、`anilist` 四种数据源。请求未指定 `source` 时继续使用后台配置;显式指定时仅在该数据源中识别或搜索。 +媒体识别、搜索和手动整理内置支持 `themoviedb`、`douban`、`bangumi`、`anilist` 四种数据源,也允许插件处理自定义来源。自动识别仍使用系统默认来源;手动操作可通过请求级 `source` 或 `media_source` + `media_id` 临时指定来源,不修改系统默认值。 + +涉及媒体身份的请求统一以 `media_source` + `media_id` 表示本次选定的主身份,同时保留 `tmdbid`、`doubanid`、`bangumiid`、`anilistid` 作为跨数据源映射和旧客户端兼容字段。两者并非两套独立数据流:显式通用主身份优先,专用 ID 用于补全映射和兼容回退。 | 方法 | 路径 | 说明 | | :--- | :--- | :--- | | 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:` 前缀 | +| GET | `/api/v1/media/{mediaid}` | 查询媒体详情,`mediaid` 支持 `tmdb:`、`douban:`、`bangumi:`、`anilist:` 及插件自定义来源前缀 | | POST | `/api/v1/media/scrape/{storage}` | 刮削媒体元数据;请求体为 `FileItem`,可选查询参数 `media_source`、`media_id`、`type_name`(电影/电视剧)可指定本次刮削媒体 | | POST | `/api/v1/transfer/manual/target-path` | 匹配手动整理目标路径;请求体可用 `media_source` + `media_id` 指定数据源原生ID | -| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源,同时兼容 `tmdbid`、`doubanid` | +| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源,同时兼容 `tmdbid`、`doubanid`、`bangumiid`、`anilistid` | #### 搜索 / 种子 / 字幕 | 方法 | 路径 | 说明 | | :--- | :--- | :--- | -| GET | `/api/v1/search/media/{mediaid}` | 按媒体 ID 搜索站点种子资源,`mediaid` 支持 `tmdb:123`、`douban:123`、`bangumi:123`,参数:`mtype`、`area`、`title`、`year`、`season`、`sites` | +| GET | `/api/v1/search/media/{mediaid}` | 按媒体 ID 搜索站点种子资源,`mediaid` 支持 `tmdb:123`、`douban:123`、`bangumi:123`、`anilist:123` 及插件来源前缀,参数:`mtype`、`area`、`title`、`year`、`season`、`sites` | | GET | `/api/v1/search/media/{mediaid}/stream` | 按媒体 ID 渐进式搜索站点种子资源,返回 SSE,参数同上 | | GET | `/api/v1/search/title` | 按关键字模糊搜索站点种子资源,参数:`keyword`、`page`、`sites` | | GET | `/api/v1/search/title/stream` | 按关键字渐进式搜索站点种子资源,返回 SSE,参数:`keyword`、`page`、`sites` | | GET | `/api/v1/search/subtitle/title` | 按关键字搜索站点字幕资源,参数:`keyword`、`page`、`sites` | | GET | `/api/v1/search/subtitle/title/stream` | 按关键字渐进式搜索站点字幕资源,返回 SSE,参数:`keyword`、`page`、`sites` | -| GET | `/api/v1/search/subtitle/media/{mediaid}` | 按媒体 ID 精确搜索站点字幕资源,`mediaid` 支持 `tmdb:123`、`douban:123`、`bangumi:123`,参数:`mtype`、`title`、`year`、`season`、`episode`、`sites` | +| GET | `/api/v1/search/subtitle/media/{mediaid}` | 按媒体 ID 精确搜索站点字幕资源,`mediaid` 支持四种内置来源及插件来源前缀,参数:`mtype`、`title`、`year`、`season`、`episode`、`sites` | | GET | `/api/v1/search/subtitle/media/{mediaid}/stream` | 按媒体 ID 渐进式精确搜索站点字幕资源,返回 SSE,参数同上 | | GET | `/api/v1/search/last` | 获取上一次种子搜索结果 | | GET | `/api/v1/search/last/context` | 获取上一次搜索结果及可复用搜索参数,`params.result_type` 为 `torrent` 或 `subtitle` | @@ -146,8 +148,8 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返 | :--- | :--- | :--- | | GET | `/api/v1/download/` | 查询正在下载的任务,参数:`name` | | POST | `/api/v1/download/` | 添加含媒体信息的下载任务,请求体包含媒体信息和种子信息 | -| 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` | +| POST | `/api/v1/download/add` | 添加不含媒体信息的下载任务,请求体包含 `torrent_in`,可选 `media_source` + `media_id`;继续兼容四种专用 ID,并支持 `downloader`、`save_path` | +| POST | `/api/v1/download/subtitle` | 下载字幕到识别出的媒体下载目录,请求体包含 `subtitle_in`,可选 `media_source` + `media_id`;继续兼容四种专用 ID,并支持 `save_path` | | GET | `/api/v1/download/start/{hashString}` | 恢复下载任务,参数:`name` | | GET | `/api/v1/download/stop/{hashString}` | 暂停下载任务,参数:`name` | | GET | `/api/v1/download/clients` | 查询可用下载器 | @@ -195,6 +197,8 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返 工具的 `inputSchema` 只包含实际执行业务所需的参数,不包含用于解释调用原因的通用 `explanation` 参数,以减少 Agent 上下文消耗。 +媒体相关 MCP 工具(如 `query_media_detail`、`search_torrents`、`query_library_exists`、`add_subscribe`、`transfer_file`)接受 `tmdb_id`/`tmdbid`、`douban_id`/`doubanid`、`bangumi_id`/`bangumiid`、`anilist_id`/`anilistid`,也接受 `media_source` + `media_id`。工具返回的媒体、订阅、下载和整理记录会同步带回可用的四种专用 ID 及通用主身份。 + #### Agent 自主定时任务工具 以下工具用于管理会在指定时间重新唤醒 Agent 的持久化任务,均为管理员级工具: diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index ae3034a7b..687a83a68 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -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. `mediaid` supports `tmdb:`, `douban:`, `bangumi:`, and `anilist:`. Params: `type_name` (required: movie/tv), `title`, `year` | +| GET | `/api/v1/media/{mediaid}` | Get media detail. `mediaid` supports `tmdb:`, `douban:`, `bangumi:`, `anilist:`, and plugin-defined source prefixes. Params: `type_name` (required: movie/tv), `title`, `year` | ### TMDB (8 endpoints) @@ -142,13 +142,13 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as ` | Method | Path | Description | |--------|------|-------------| -| GET | `/api/v1/search/media/{mediaid}` | Search torrents by media ID (format: `tmdb:123` / `douban:123` / `bangumi:123`). Params: `mtype`, `area`, `title`, `year`, `season`, `sites` | +| GET | `/api/v1/search/media/{mediaid}` | Search torrents by media ID (four built-in prefixes or a plugin-defined source prefix). Params: `mtype`, `area`, `title`, `year`, `season`, `sites` | | GET | `/api/v1/search/media/{mediaid}/stream` | Stream torrent search by media ID with SSE. Params: `mtype`, `area`, `title`, `year`, `season`, `sites` | | GET | `/api/v1/search/title` | Fuzzy search torrents by keyword. Params: `keyword`, `page`, `sites` | | GET | `/api/v1/search/title/stream` | Stream fuzzy torrent search with SSE. Params: `keyword`, `page`, `sites` | | GET | `/api/v1/search/subtitle/title` | Fuzzy search site subtitles by keyword. Params: `keyword`, `page`, `sites` | | GET | `/api/v1/search/subtitle/title/stream` | Stream fuzzy site subtitle search with SSE. Params: `keyword`, `page`, `sites` | -| GET | `/api/v1/search/subtitle/media/{mediaid}` | Exact subtitle search by media ID (format: `tmdb:123` / `douban:123` / `bangumi:123`). Params: `mtype`, `title`, `year`, `season`, `episode`, `sites` | +| GET | `/api/v1/search/subtitle/media/{mediaid}` | Exact subtitle search by media ID (four built-in prefixes or a plugin-defined source prefix). Params: `mtype`, `title`, `year`, `season`, `episode`, `sites` | | GET | `/api/v1/search/subtitle/media/{mediaid}/stream` | Stream exact subtitle search by media ID with SSE. Params: `mtype`, `title`, `year`, `season`, `episode`, `sites` | | GET | `/api/v1/search/last` | Get latest search results | | GET | `/api/v1/search/last/context` | Get latest search results with replayable params. `params.result_type` is `torrent` or `subtitle` | @@ -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: `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` | +| POST | `/api/v1/download/add` | Add download without media info. Body: `torrent_in`, optional `media_source` + `media_id` (all four dedicated IDs 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` (all four dedicated IDs 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 | @@ -172,14 +172,14 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as ` | Method | Path | Description | |--------|------|-------------| | GET | `/api/v1/subscribe/` | List all subscriptions | -| POST | `/api/v1/subscribe/` | Add subscription. Body: Subscribe JSON | +| POST | `/api/v1/subscribe/` | Add subscription. Body accepts `media_source` + `media_id` and compatible `tmdbid`, `doubanid`, `bangumiid`, `anilistid` fields | | PUT | `/api/v1/subscribe/` | Update subscription. Body: Subscribe JSON | | GET | `/api/v1/subscribe/list` | List subscriptions (API_TOKEN auth, use `--token-param`) | | GET | `/api/v1/subscribe/{subscribe_id}` | Subscription detail | | DELETE | `/api/v1/subscribe/{subscribe_id}` | Delete subscription | | PUT | `/api/v1/subscribe/status/{subid}` | Update subscription status. Params: `state` (required) | -| GET | `/api/v1/subscribe/media/{mediaid}` | Query subscription by media ID. Params: `season`, `title` | -| DELETE | `/api/v1/subscribe/media/{mediaid}` | Delete subscription by media ID. Params: `season` | +| GET | `/api/v1/subscribe/media/{mediaid}` | Query subscription by a built-in or plugin-prefixed media ID. Params: `season`, `title` | +| DELETE | `/api/v1/subscribe/media/{mediaid}` | Delete subscription by a built-in or plugin-prefixed media ID. Params: `season` | | GET | `/api/v1/subscribe/refresh` | Refresh all subscriptions | | GET | `/api/v1/subscribe/reset/{subid}` | Reset subscription | | GET | `/api/v1/subscribe/check` | Refresh subscription TMDB info | diff --git a/tests/test_delete_transfer_history_tool.py b/tests/test_delete_transfer_history_tool.py index 09203b64a..0f550bdeb 100644 --- a/tests/test_delete_transfer_history_tool.py +++ b/tests/test_delete_transfer_history_tool.py @@ -307,6 +307,10 @@ def test_manual_redo_context_uses_dest_path_for_successful_move_record(): mode="move", tmdbid=100, doubanid=None, + bangumiid=None, + anilistid=None, + media_source="themoviedb", + media_id="100", errmsg=None, ) @@ -346,6 +350,10 @@ def test_manual_redo_context_only_treats_exact_move_as_dest_source(): mode="not-move", tmdbid=100, doubanid=None, + bangumiid=None, + anilistid=None, + media_source="themoviedb", + media_id="100", errmsg=None, ) diff --git a/tests/test_download_chain.py b/tests/test_download_chain.py index 6c3a650a0..e77a64abb 100644 --- a/tests/test_download_chain.py +++ b/tests/test_download_chain.py @@ -396,6 +396,8 @@ def _build_tv_context(episode_list=None): title_year="Test Show (2026)", tmdb_id=1, douban_id=None, + bangumi_id=None, + anilist_id=None, ), meta_info=SimpleNamespace( season_list=[1], diff --git a/tests/test_history_batch_ai_redo_prompt.py b/tests/test_history_batch_ai_redo_prompt.py index c1e5bce1b..7a5e4f80a 100644 --- a/tests/test_history_batch_ai_redo_prompt.py +++ b/tests/test_history_batch_ai_redo_prompt.py @@ -24,6 +24,10 @@ def test_batch_manual_redo_prompt_requires_plain_text_result(): mode="copy", tmdbid=123, doubanid=None, + bangumiid=None, + anilistid=None, + media_source="themoviedb", + media_id="123", errmsg="识别失败", ) diff --git a/tests/test_media_identity_migration.py b/tests/test_media_identity_migration.py new file mode 100644 index 000000000..37ecacaf1 --- /dev/null +++ b/tests/test_media_identity_migration.py @@ -0,0 +1,116 @@ +import importlib + +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + + +def _create_legacy_tables(connection) -> dict[str, sa.Table]: + """创建执行 2.2.14 迁移前的最小历史表结构。""" + metadata = sa.MetaData() + common_identity_columns = ( + sa.Column("tmdbid", sa.Integer()), + sa.Column("doubanid", sa.String()), + ) + tables = { + "subscribe": sa.Table( + "subscribe", metadata, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("mediaid", sa.String()), + *common_identity_columns, + sa.Column("bangumiid", sa.Integer()), + ), + "subscribehistory": sa.Table( + "subscribehistory", metadata, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("mediaid", sa.String()), + sa.Column("tmdbid", sa.Integer()), + sa.Column("doubanid", sa.String()), + sa.Column("bangumiid", sa.Integer()), + ), + "downloadhistory": sa.Table( + "downloadhistory", metadata, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("tmdbid", sa.Integer()), + sa.Column("doubanid", sa.String()), + ), + "transferhistory": sa.Table( + "transferhistory", metadata, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("tmdbid", sa.Integer()), + sa.Column("doubanid", sa.String()), + sa.Column("media_source", sa.String()), + sa.Column("media_id", sa.String()), + ), + "downloadfailure": sa.Table( + "downloadfailure", metadata, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("type", sa.String()), + sa.Column("site", sa.Integer()), + sa.Column("tmdbid", sa.Integer()), + sa.Column("doubanid", sa.String()), + ), + } + metadata.create_all(connection) + return tables + + +def test_media_identity_migration_adds_fields_and_backfills_rows(monkeypatch) -> None: + """迁移应补齐五张表的媒体 ID 字段并幂等迁移存量身份。""" + migration = importlib.import_module( + "database.versions.f7b2d5c9a301_2_2_14" + ) + engine = sa.create_engine("sqlite://") + + with engine.begin() as connection: + tables = _create_legacy_tables(connection) + connection.execute(tables["subscribe"].insert(), { + "id": 1, "mediaid": "anilist:154587", + }) + connection.execute(tables["subscribehistory"].insert(), { + "id": 1, "bangumiid": 29648, + }) + connection.execute(tables["downloadhistory"].insert(), { + "id": 1, "doubanid": "35209731", + }) + connection.execute(tables["transferhistory"].insert(), { + "id": 1, "tmdbid": 209867, + "media_source": "plugin_source", "media_id": "custom-1", + }) + connection.execute(tables["downloadfailure"].insert(), { + "id": 1, "type": "电视剧", "site": 1, "tmdbid": 209867, + }) + + context = MigrationContext.configure(connection) + monkeypatch.setattr(migration, "op", Operations(context)) + migration.upgrade() + migration.upgrade() + + migrated = { + table_name: sa.Table( + table_name, sa.MetaData(), autoload_with=connection, + ) + for table_name in tables + } + rows = { + table_name: connection.execute( + sa.select(table).where(table.c.id == 1) + ).mappings().one() + for table_name, table in migrated.items() + } + + for table_name in migrated: + assert "bangumiid" in migrated[table_name].c + assert "anilistid" in migrated[table_name].c + assert "media_source" in migrated[table_name].c + assert "media_id" in migrated[table_name].c + assert rows["subscribe"]["media_source"] == "anilist" + assert rows["subscribe"]["media_id"] == "154587" + assert rows["subscribehistory"]["media_source"] == "bangumi" + assert rows["subscribehistory"]["media_id"] == "29648" + assert rows["downloadhistory"]["media_source"] == "douban" + assert rows["downloadhistory"]["media_id"] == "35209731" + assert rows["transferhistory"]["media_source"] == "plugin_source" + assert rows["transferhistory"]["media_id"] == "custom-1" + assert rows["downloadfailure"]["media_source"] == "themoviedb" + assert rows["downloadfailure"]["media_id"] == "209867" diff --git a/tests/test_media_interaction.py b/tests/test_media_interaction.py index 8fcca8f8d..b5af3db55 100644 --- a/tests/test_media_interaction.py +++ b/tests/test_media_interaction.py @@ -152,8 +152,8 @@ def test_rebuild_download_scope_keeps_special_season_zero(): no_exists = MediaInteractionChain._get_noexits_info(meta, mediainfo) - assert list(no_exists[1]) == [0] - assert no_exists[1][0].total_episode == 2 + assert list(no_exists["tmdb:1"]) == [0] + assert no_exists["tmdb:1"][0].total_episode == 2 def test_message_routes_text_reply_to_media_interaction_before_ai(): diff --git a/tests/test_media_source_routing.py b/tests/test_media_source_routing.py index 6cf4b5f27..bc5c96d13 100644 --- a/tests/test_media_source_routing.py +++ b/tests/test_media_source_routing.py @@ -23,8 +23,18 @@ def test_generic_source_id_wins_over_legacy_ids() -> None: assert resolved == ("anilist", None, None, None, 154587) -def test_explicit_source_recognition_runs_system_modules_only() -> None: - """显式选择数据源时应跳过插件并只向系统模块传递该来源ID。""" +def test_custom_plugin_source_is_not_discarded() -> None: + """插件自定义来源应保留来源名,并通过通用原生 ID 交给插件。""" + resolved = ChainBase._resolve_media_source_params( + source="plugin_source", + mediaid="custom-1", + ) + + assert resolved == ("plugin_source", None, None, None, None) + + +def test_explicit_source_recognition_reaches_plugins_with_all_ids() -> None: + """显式选择数据源时应保留通用参数并进入完整模块调度。""" chain = _chain_without_init() media = MediaInfo( anilist_info={ @@ -48,14 +58,14 @@ def test_explicit_source_recognition_runs_system_modules_only() -> None: 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["mediaid"] == "154587" assert call.kwargs["anilistid"] == 154587 assert call.kwargs["tmdbid"] is None -def test_default_recognition_preserves_plugin_method_contract() -> None: - """未显式选择来源时不应向既有插件额外传递source参数。""" +def test_default_recognition_passes_empty_generic_identity() -> None: + """默认识别也应向插件传递完整但为空的通用媒体身份参数。""" chain = _chain_without_init() media = MediaInfo(title="测试电影", type=MediaType.MOVIE, tmdb_id=1) chain.run_module = Mock(return_value=media) @@ -71,26 +81,26 @@ def test_default_recognition_preserves_plugin_method_contract() -> None: 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 + assert call.kwargs["source"] is None + assert call.kwargs["mediaid"] is None + assert call.kwargs["anilistid"] is None -def test_system_only_module_dispatch_skips_plugins() -> None: - """模块调度的system_only模式不得执行插件模块。""" +def test_module_dispatch_always_reaches_plugins() -> None: + """模块调度必须始终先执行插件模块。""" 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")) + result = chain.run_module("search_medias", meta=MetaBase("test")) - assert result == "system" - chain._ChainBase__execute_plugin_modules.assert_not_called() - chain._ChainBase__execute_system_modules.assert_called_once() + assert result == "plugin" + chain._ChainBase__execute_plugin_modules.assert_called_once() + chain._ChainBase__execute_system_modules.assert_not_called() -def test_explicit_search_source_uses_system_only_dispatch() -> None: - """请求级搜索来源应进入仅系统模块调度。""" +def test_explicit_search_source_reaches_plugins() -> None: + """请求级搜索来源应进入包含插件的完整模块调度。""" chain = _chain_without_init() chain.run_module = Mock(return_value=[]) meta = MetaBase("Frieren") @@ -102,5 +112,4 @@ def test_explicit_search_source_uses_system_only_dispatch() -> None: "search_medias", meta=meta, source="anilist", - system_only=True, ) diff --git a/tests/test_media_source_signature_compatibility.py b/tests/test_media_source_signature_compatibility.py new file mode 100644 index 000000000..e2845e73d --- /dev/null +++ b/tests/test_media_source_signature_compatibility.py @@ -0,0 +1,58 @@ +import inspect + +from app.chain.download import DownloadChain +from app.chain.search import SearchChain +from app.chain.subscribe import SubscribeChain +from app.chain.transfer import TransferChain + + +def _assert_parameter_prefix(method, expected: list[str]) -> None: + """断言新增媒体源参数未改变已有位置参数顺序。""" + parameters = list(inspect.signature(method).parameters) + assert parameters[:len(expected)] == expected + + +def test_search_chain_media_source_parameters_preserve_old_order() -> None: + """搜索链新增媒体源参数必须追加在原有位置参数之后。""" + resource_parameters = [ + "self", "tmdbid", "doubanid", "mtype", "area", "season", "sites", "cache_local" + ] + subtitle_parameters = [ + "self", "tmdbid", "doubanid", "mtype", "season", "episode", "sites", "cache_local" + ] + + _assert_parameter_prefix(SearchChain.search_by_id, resource_parameters) + _assert_parameter_prefix(SearchChain.async_search_by_id, resource_parameters) + _assert_parameter_prefix(SearchChain.async_search_by_id_stream, resource_parameters) + _assert_parameter_prefix(SearchChain.async_search_subtitles_by_id, subtitle_parameters) + _assert_parameter_prefix(SearchChain.async_search_subtitles_by_id_stream, subtitle_parameters) + + +def test_download_chain_media_source_parameters_preserve_old_order() -> None: + """字幕下载链新增媒体源参数必须追加在原有位置参数之后。""" + _assert_parameter_prefix(DownloadChain.download_subtitle, [ + "self", "subtitle", "media_source", "media_id", "tmdbid", "doubanid", + "save_path", "username", + ]) + + +def test_transfer_chain_media_source_parameters_preserve_old_order() -> None: + """整理链新增媒体源参数必须追加在原有位置参数之后。""" + _assert_parameter_prefix(TransferChain.manual_transfer, [ + "self", "fileitem", "target_storage", "target_path", "tmdbid", "doubanid", + "media_source", "media_id", "mtype", "season", "episode_group", "transfer_type", + "epformat", "min_filesize", "scrape", "library_type_folder", + "library_category_folder", "force", "background", "downloader", "download_hash", + "preview", "sync_extra_files", "cleanup_dest_fileitem", + ]) + + +def test_subscribe_chain_media_source_parameters_preserve_old_order() -> None: + """订阅链新增媒体源参数必须追加在原有位置参数之后。""" + parameters = [ + "self", "title", "year", "mtype", "tmdbid", "doubanid", "bangumiid", "mediaid", + "episode_group", "season", "channel", "source", "userid", "username", "message", "exist_ok", + ] + + _assert_parameter_prefix(SubscribeChain.add, parameters) + _assert_parameter_prefix(SubscribeChain.async_add, parameters) diff --git a/tests/test_message_media_identity.py b/tests/test_message_media_identity.py new file mode 100644 index 000000000..bcc7e0e9d --- /dev/null +++ b/tests/test_message_media_identity.py @@ -0,0 +1,41 @@ +from app.core.context import MediaInfo +from app.helper.message import TemplateContextBuilder +from app.schemas.types import MediaType + + +def test_message_context_contains_all_media_identity_fields() -> None: + """消息模板上下文应向插件暴露全部媒体源 ID 和当前主身份。""" + context = {} + media = MediaInfo( + source="anilist", + type=MediaType.TV, + title="测试动画", + tmdb_id=24680, + douban_id="35000000", + bangumi_id=499390, + anilist_id=170942, + ) + + TemplateContextBuilder._add_media_info(context, media) + + assert context["tmdbid"] == 24680 + assert context["doubanid"] == "35000000" + assert context["bangumiid"] == 499390 + assert context["anilistid"] == 170942 + assert context["media_source"] == "anilist" + assert context["media_id"] == "170942" + assert media.to_dict()["mediaid_prefix"] == "anilist" + assert media.to_dict()["media_id"] == "170942" + + +def test_media_info_preserves_plugin_source_identity() -> None: + """核心媒体对象应原样保留插件自定义数据源的原生 ID。""" + media = MediaInfo( + source="plugin_source", + media_id="custom-100", + type=MediaType.MOVIE, + title="插件电影", + ) + + assert media.media_id == "custom-100" + assert media.to_dict()["media_id"] == "custom-100" diff --git a/tests/test_metainfo.py b/tests/test_metainfo.py index 51972abc6..ff8450e38 100644 --- a/tests/test_metainfo.py +++ b/tests/test_metainfo.py @@ -128,6 +128,8 @@ def test_torrent_title_match_ignores_question_mark_variants(): torrent_meta = SimpleNamespace( tmdbid=None, doubanid=None, + bangumiid=None, + anilistid=None, cn_name=None, en_name="Otaku ni Yasashii Gal wa Inai", type=MediaType.TV, diff --git a/tests/test_search_media_sources.py b/tests/test_search_media_sources.py new file mode 100644 index 000000000..3b8b180c9 --- /dev/null +++ b/tests/test_search_media_sources.py @@ -0,0 +1,150 @@ +import asyncio + +from app.api.endpoints import media as media_endpoint +from app.api.endpoints import search as search_endpoint +from app.core.context import MediaInfo +from app.schemas.types import MediaType + + +def test_resolve_anilist_search_params_preserves_source_identity() -> None: + """AniList 媒体键应直接解析为统一搜索身份。""" + params, message = asyncio.run( + search_endpoint._resolve_media_search_params("anilist:154587") + ) + + assert message == "" + assert params == {"source": "anilist", "mediaid": "154587"} + + +def test_resource_search_forwards_custom_plugin_source(monkeypatch) -> None: + """资源搜索 API 应把自定义插件来源原样传给搜索链。""" + captured = {} + + class FakeTorrent: + """提供资源搜索响应需要的最小种子对象。""" + + @staticmethod + def to_dict() -> dict: + """返回可序列化的测试种子。""" + return {"title": "Plugin result"} + + class FakeSearchChain: + """记录资源搜索链收到的统一身份。""" + + async def async_search_by_id(self, **kwargs): + """保存搜索参数并返回单条测试结果。""" + captured.update(kwargs) + return [FakeTorrent()] + + monkeypatch.setattr(search_endpoint, "SearchChain", FakeSearchChain) + + response = asyncio.run( + search_endpoint.search_by_id( + mediaid="plugin_source:custom-1", + mtype="tv", + _=None, + ) + ) + + assert response.success + assert captured["source"] == "plugin_source" + assert captured["mediaid"] == "custom-1" + assert captured["mtype"] == MediaType.TV + + +def test_subtitle_search_forwards_anilist_identity(monkeypatch) -> None: + """字幕搜索 API 应把 AniList 身份传给字幕搜索链。""" + captured = {} + + class FakeSearchChain: + """记录字幕搜索链收到的统一身份。""" + + async def async_search_subtitles_by_id(self, **kwargs): + """保存字幕搜索参数并返回空结果。""" + captured.update(kwargs) + return [] + + monkeypatch.setattr(search_endpoint, "SearchChain", FakeSearchChain) + + source, message = asyncio.run( + search_endpoint._build_subtitle_search_source( + mediaid="anilist:154587", + mtype="tv", + ) + ) + assert message == "" + assert asyncio.run(source) == [] + assert captured["source"] == "anilist" + assert captured["mediaid"] == "154587" + + +def test_media_detail_forwards_custom_plugin_source(monkeypatch) -> None: + """媒体详情 API 应允许插件自定义来源处理原生 ID。""" + captured = {} + media = MediaInfo( + source="plugin_source", + type=MediaType.MOVIE, + title="Plugin movie", + ) + + class FakeMediaChain: + """记录详情识别链收到的统一身份。""" + + async def async_recognize_media(self, **kwargs): + """保存识别参数并返回插件媒体信息。""" + captured.update(kwargs) + return media + + async def async_obtain_images(self, _media): + """跳过测试中的真实图片获取。""" + return None + + monkeypatch.setattr(media_endpoint, "MediaChain", FakeMediaChain) + + result = asyncio.run( + media_endpoint.detail( + mediaid="plugin_source:custom-1", + type_name=MediaType.MOVIE.value, + _=None, + ) + ) + + assert result["title"] == "Plugin movie" + assert captured["source"] == "plugin_source" + assert captured["mediaid"] == "custom-1" + + +def test_media_seasons_builds_anilist_season_response(monkeypatch) -> None: + """AniList 详情应能通过统一季信息接口返回剧集季。""" + captured = {} + media = MediaInfo( + source="anilist", + type=MediaType.TV, + title="Frieren", + anilist_id=154587, + season_info=[{ + "season_number": 1, + "name": "Season 1", + "episode_count": 28, + }], + ) + + class FakeMediaChain: + """记录季信息识别链收到的 AniList 身份。""" + + async def async_recognize_media(self, **kwargs): + """保存识别参数并返回 AniList 媒体信息。""" + captured.update(kwargs) + return media + + monkeypatch.setattr(media_endpoint, "MediaChain", FakeMediaChain) + + result = asyncio.run( + media_endpoint.seasons(mediaid="anilist:154587", _=None) + ) + + assert len(result) == 1 + assert result[0].season_number == 1 + assert result[0].episode_count == 28 + assert captured["source"] == "anilist" + assert captured["mediaid"] == "154587" diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index 9b3da71f9..af4fa8e82 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -293,6 +293,10 @@ def _load_subscribe_chain_class(): def __init__(self, **kwargs): self.best_version_full = 0 self.bangumiid = None + self.anilistid = None + self.media_source = None + self.media_id = None + self.mediaid = None self.episode_group = None for key, value in kwargs.items(): setattr(self, key, value) @@ -391,6 +395,10 @@ class SubscribeChainTest(TestCase): "imdbid": None, "tvdbid": None, "bangumiid": None, + "anilistid": None, + "media_source": "themoviedb", + "media_id": "1", + "mediaid": "tmdb:1", "episode_group": None, "poster": None, "backdrop": None, @@ -1776,6 +1784,11 @@ class SubscribeChainTest(TestCase): overview="overview", imdb_id="tt1234567", tvdb_id=99, + source="themoviedb", + tmdb_id=1, + douban_id=None, + bangumi_id=None, + anilist_id=None, get_poster_image=lambda: "poster", get_backdrop_image=lambda: "backdrop", ) @@ -1970,6 +1983,8 @@ class SubscribeNoteTrackingTest(TestCase): type=MediaType.TV, tmdb_id=1, douban_id=None, + bangumi_id=None, + anilist_id=None, ), torrent_info=SimpleNamespace(pri_order=99, title="fake-torrent"), selected_episodes=list(episodes), @@ -2467,7 +2482,7 @@ class SubscribeProgressEntrypointTest(TestCase): self.assertEqual(kwargs["meta"].name, subscribe.name) self.assertEqual(kwargs["meta"].season_seq, "1") self.assertIs(kwargs["mediainfo"], mediainfo) - self.assertEqual(kwargs["mediakey"], 10001) + self.assertEqual(kwargs["mediakey"], "tmdb:10001") self.assertTrue(summary["updated"]) self.assertEqual(summary["lack_episode"], 2) self.assertEqual(subscribe.lack_episode, 2) @@ -2550,6 +2565,8 @@ class SubscribeProgressConsolidationTest(TestCase): tmdb_id=31000, douban_id=None, bangumi_id=None, + anilist_id=None, + source="themoviedb", vote_average=9.5, overview="overview", imdb_id="tt1234567", @@ -3395,7 +3412,13 @@ class SubscribeDownloadFactsTest(TestCase): confirmed_full_coverage=confirmed_full_coverage, torrent_info=SimpleNamespace(pri_order=pri_order), meta_info=SimpleNamespace(episode_list=episodes or [], season_list=[1]), - media_info=SimpleNamespace(type=MediaType.TV, tmdb_id=30003, douban_id=None), + media_info=SimpleNamespace( + type=MediaType.TV, + tmdb_id=30003, + douban_id=None, + bangumi_id=None, + anilist_id=None, + ), ) def test_normal_tv_download_records_note_and_episode_priority_without_current_priority(self): @@ -3553,7 +3576,10 @@ class SubscribeDownloadFactsTest(TestCase): lack_episode=1, ) download = self._download(episodes=[], pri_order=90) - download.media_info = SimpleNamespace(type=MediaType.MOVIE, tmdb_id=30003, douban_id=None) + download.media_info = SimpleNamespace( + type=MediaType.MOVIE, tmdb_id=30003, douban_id=None, + bangumi_id=None, anilist_id=None, + ) download.meta_info = SimpleNamespace(episode_list=[], season_list=[]) updates = [] @@ -3589,7 +3615,10 @@ class SubscribeDownloadFactsTest(TestCase): lack_episode=1, ) download = self._download(episodes=[], pri_order=90) - download.media_info = SimpleNamespace(type=MediaType.MOVIE, tmdb_id=30003, douban_id=None) + download.media_info = SimpleNamespace( + type=MediaType.MOVIE, tmdb_id=30003, douban_id=None, + bangumi_id=None, anilist_id=None, + ) download.meta_info = SimpleNamespace(episode_list=[], season_list=[]) chain = self.SubscribeChain() @@ -3622,7 +3651,10 @@ class SubscribeDownloadFactsTest(TestCase): lack_episode=1, ) download = self._download(episodes=[], pri_order=90) - download.media_info = SimpleNamespace(type=MediaType.MOVIE, tmdb_id=30003, douban_id=None) + download.media_info = SimpleNamespace( + type=MediaType.MOVIE, tmdb_id=30003, douban_id=None, + bangumi_id=None, anilist_id=None, + ) download.meta_info = SimpleNamespace(episode_list=[], season_list=[]) chain = self.SubscribeChain() @@ -3655,7 +3687,10 @@ class SubscribeDownloadFactsTest(TestCase): lack_episode=1, ) download = self._download(episodes=[], pri_order=90) - download.media_info = SimpleNamespace(type=MediaType.MOVIE, tmdb_id=30003, douban_id=None) + download.media_info = SimpleNamespace( + type=MediaType.MOVIE, tmdb_id=30003, douban_id=None, + bangumi_id=None, anilist_id=None, + ) download.meta_info = SimpleNamespace(episode_list=[], season_list=[]) updates = [] finished = [] diff --git a/tests/test_subscribe_endpoint.py b/tests/test_subscribe_endpoint.py index 13b9750e4..746da0bf1 100644 --- a/tests/test_subscribe_endpoint.py +++ b/tests/test_subscribe_endpoint.py @@ -1025,6 +1025,7 @@ class _EndpointMediaInfo: tvdb_id = 456 douban_id = "douban-1" bangumi_id = 789 + anilist_id = 154587 episode_group = None vote_average = 8.0 overview = "测试简介" diff --git a/tests/test_subscribe_files_info.py b/tests/test_subscribe_files_info.py index d672f1dbf..4afafc4e0 100644 --- a/tests/test_subscribe_files_info.py +++ b/tests/test_subscribe_files_info.py @@ -21,6 +21,9 @@ def _build_subscribe(**overrides): "imdbid": None, "tvdbid": None, "bangumiid": None, + "anilistid": None, + "media_source": None, + "media_id": None, "episode_group": None, "start_episode": 1, "total_episode": 2, @@ -39,6 +42,10 @@ def _build_mediainfo(): year="2026", tmdb_id=None, douban_id=None, + bangumi_id=None, + anilist_id=None, + source=None, + media_id=None, ) diff --git a/tests/test_subscribe_search_state.py b/tests/test_subscribe_search_state.py index a8e981ee4..a60bef0b6 100644 --- a/tests/test_subscribe_search_state.py +++ b/tests/test_subscribe_search_state.py @@ -45,6 +45,10 @@ def _new_subscribe(created_at: datetime) -> SimpleNamespace: type=MediaType.MOVIE.value, tmdbid=12345, doubanid=None, + bangumiid=None, + anilistid=None, + media_source="themoviedb", + media_id="12345", season=None, custom_words=None, date=created_at.strftime("%Y-%m-%d %H:%M:%S"), diff --git a/tests/test_subscribe_source_keyword.py b/tests/test_subscribe_source_keyword.py index 812376ffc..b1ab57f59 100644 --- a/tests/test_subscribe_source_keyword.py +++ b/tests/test_subscribe_source_keyword.py @@ -18,6 +18,9 @@ def test_subscribe_source_keyword_includes_episode_group(): tvdbid=None, doubanid=None, bangumiid=None, + anilistid=None, + media_source="themoviedb", + media_id="12345", ) source = SubscribeChain.get_subscribe_source_keyword(subscribe) diff --git a/tests/test_torrents_subscribe_candidates.py b/tests/test_torrents_subscribe_candidates.py index 9254df09f..ed3d9c717 100644 --- a/tests/test_torrents_subscribe_candidates.py +++ b/tests/test_torrents_subscribe_candidates.py @@ -14,6 +14,10 @@ def _subscribe(**kwargs): defaults = { "tmdbid": 100, "doubanid": None, + "bangumiid": None, + "anilistid": None, + "media_source": None, + "media_id": None, "season": 1, "name": "测试剧", "type": MediaType.TV.value, @@ -41,6 +45,10 @@ def _ctx( type=meta_type, tmdbid=meta_tmdbid, doubanid=meta_doubanid, + bangumiid=None, + anilistid=None, + media_source=None, + media_id=None, begin_season=begin_season, end_season=end_season, begin_episode=5, @@ -107,6 +115,12 @@ def test_cache_candidates_ignore_default_meta_season_list_when_no_explicit_meta_ title = "测试剧 E05" name = "测试剧" type = MediaType.TV + tmdbid = None + doubanid = None + bangumiid = None + anilistid = None + media_source = None + media_id = None begin_season = None end_season = None diff --git a/tests/test_transfer_failed_retry_buttons.py b/tests/test_transfer_failed_retry_buttons.py index 3d338feec..520afc222 100644 --- a/tests/test_transfer_failed_retry_buttons.py +++ b/tests/test_transfer_failed_retry_buttons.py @@ -94,6 +94,10 @@ class TestTransferFailedRetryButtons(unittest.TestCase): mode="copy", tmdbid=123, doubanid=None, + bangumiid=None, + anilistid=None, + media_source="themoviedb", + media_id="123", errmsg="未识别到媒体信息", ) @@ -151,6 +155,10 @@ class TestTransferFailedRetryButtons(unittest.TestCase): mode="move", tmdbid=123, doubanid=None, + bangumiid=None, + anilistid=None, + media_source="themoviedb", + media_id="123", errmsg=None, ) diff --git a/tests/test_transfer_history_retransfer.py b/tests/test_transfer_history_retransfer.py index 3134683e6..96f8ecad5 100644 --- a/tests/test_transfer_history_retransfer.py +++ b/tests/test_transfer_history_retransfer.py @@ -21,6 +21,10 @@ def test_manual_transfer_from_history_preserves_download_context(monkeypatch): type="电视剧", tmdbid="100", doubanid="200", + bangumiid=None, + anilistid=None, + media_source="themoviedb", + media_id="100", seasons="S01", episodes="E01-E02", episode_group="WEB-DL", diff --git a/tests/test_transfer_job_manager.py b/tests/test_transfer_job_manager.py index 5d4c4164b..044ac6cae 100644 --- a/tests/test_transfer_job_manager.py +++ b/tests/test_transfer_job_manager.py @@ -56,15 +56,21 @@ class FakeMeta: class FakeMedia: def __init__(self, tmdb_id: int = 12345): + """构造与正式 MediaInfo 身份字段一致的测试媒体对象。""" self.tmdb_id = tmdb_id self.douban_id = None + self.bangumi_id = None + self.anilist_id = None + self.source = "themoviedb" self.type = MediaType.TV self.title_year = "Test Show (2026)" def clear(self): + """模拟正式媒体对象的清理接口。""" pass def to_dict(self): + """返回测试媒体对象的序列化字段。""" return { "type": MediaType.TV.value, "title": "Test Show", @@ -72,6 +78,8 @@ class FakeMedia: "title_year": "Test Show (2026)", "tmdb_id": self.tmdb_id, "douban_id": self.douban_id, + "bangumi_id": self.bangumi_id, + "anilist_id": self.anilist_id, }