From 2cfb94d886193d9f7425bbdcf35813050b643e41 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 26 Sep 2026 13:21:56 +0800 Subject: [PATCH] feat(messaging): support music interactions without agent --- app/application/messaging/media.py | 4 +- app/chain/_interaction_subscribe.py | 87 +++++++ app/chain/_music_interaction.py | 216 +++++++++++++++++ app/chain/interaction.py | 222 +++++++++--------- docs/architecture-overview.md | 6 +- docs/refactor/optimization-checklist.md | 4 +- .../architecture/dependency-baseline.json | 35 ++- .../fixtures/architecture/mypy-baseline.json | 7 +- tests/test_media_interaction.py | 202 +++++++++++++++- 9 files changed, 651 insertions(+), 132 deletions(-) create mode 100644 app/chain/_interaction_subscribe.py create mode 100644 app/chain/_music_interaction.py diff --git a/app/application/messaging/media.py b/app/application/messaging/media.py index 8142ed635..c19b9d884 100644 --- a/app/application/messaging/media.py +++ b/app/application/messaging/media.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import Any, Dict, List, Optional, Union from app.application.messaging.interaction import _ExpiringUserInteractionStore -from app.domain.context import MediaInfo +from app.domain.context import MediaInfo, MusicInfo from app.domain.meta.metabase import MetaBase from app.schemas.types import NotificationChannel @@ -26,7 +26,7 @@ class PendingMediaInteraction: page: int = 0 title: str = "" meta: Optional[MetaBase] = None - current_media: Optional[MediaInfo] = None + current_media: Optional[MediaInfo | MusicInfo] = None items: List[Any] = field(default_factory=list) download_dirs: List[Any] = field(default_factory=list) pending_download_mode: Optional[str] = None diff --git a/app/chain/_interaction_subscribe.py b/app/chain/_interaction_subscribe.py new file mode 100644 index 000000000..304abb061 --- /dev/null +++ b/app/chain/_interaction_subscribe.py @@ -0,0 +1,87 @@ +"""消息渠道媒体交互的订阅创建与重试提示。""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +from app.chain._music_interaction import ( + media_exists_retry_prompt, + selected_music_type, +) +from app.chain.download import DownloadChain +from app.chain.subscribe.facade import SubscribeChain +from app.domain.context import MediaInfo, MusicInfo +from app.schemas.message import Message +from app.schemas.types import NotificationChannel + +if TYPE_CHECKING: + from app.application.messaging.media import PendingMediaInteraction + from app.application.security.user import ChainUserRepository + + +class MediaInteractionSubscribeHost(Protocol): + """声明订阅创建复用的用户名称查询与消息回复能力。""" + + user_repository: ChainUserRepository + + def post_message(self, message: Message) -> None: + """将媒体库重复提示发送回当前消息渠道。""" + ... + + +def subscribe_media( + host: MediaInteractionSubscribeHost, + request: PendingMediaInteraction, + mediainfo: MediaInfo | MusicInfo, + channel: NotificationChannel, + source: str, + userid: int | str, + username: str, +) -> None: + """为已选影视或音乐目标创建普通订阅或音质洗版订阅。""" + best_version = request.action in {"ReSubscribe", "MusicReSubscribe"} + if not best_version: + exist_flag, _ = DownloadChain().get_no_exists_info( + meta=request.meta, + mediainfo=mediainfo, + ) + if exist_flag: + media_label, retry_hint = media_exists_retry_prompt( + mediainfo=mediainfo, + meta=request.meta, + keyword=request.keyword, + subscription=True, + ) + host.post_message( + Message( + channel=channel, + source=source, + userid=userid, + username=username, + title=f"【{media_label} 媒体库中已存在,如需洗版请发送:{retry_hint}】", + save_history=False, + ) + ) + return + + mp_name = ( + host.user_repository.find_name_by_bindings( + {f"{channel.name.lower()}_userid": userid} + ) + if channel + else None + ) + SubscribeChain().add( + title=mediainfo.title or "", + year=str(mediainfo.year or ""), + mtype=mediainfo.type, + media_source=mediainfo.media_source, + media_id=mediainfo.media_id, + season=request.meta.begin_season if request.meta else None, + channel=channel, + source=source, + userid=str(userid), + username=mp_name or username, + best_version=best_version, + music_type=selected_music_type(mediainfo), + ) diff --git a/app/chain/_music_interaction.py b/app/chain/_music_interaction.py new file mode 100644 index 000000000..cac9e594c --- /dev/null +++ b/app/chain/_music_interaction.py @@ -0,0 +1,216 @@ +"""消息渠道音乐交互的解析、候选展示及类型转换。""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Tuple + +from app.chain.media import MediaChain +from app.domain import title as title_rules +from app.domain.context import MediaInfo, MusicInfo +from app.domain.meta.metamusic import MetaMusic +from app.foundation import url as url_tools +from app.schemas.message import Message +from app.schemas.types import ( + MUSIC_ENTITY_ALBUM, + MUSIC_ENTITY_RECORDING, + NotificationChannel, +) + +if TYPE_CHECKING: + from app.application.messaging.media import PendingMediaInteraction + from app.domain.meta.metabase import MetaBase + + +class MusicInteractionHost(Protocol): + """声明音乐候选展示复用的消息交互链能力。""" + + def _page_items( + self, + items: List[Any], + page: int, + page_size: int, + ) -> Tuple[List[Any], int, int]: + """返回交互候选的当前页、页码及总页数。""" + ... + + def _page_size(self, channel: Optional[NotificationChannel]) -> int: + """按消息渠道能力返回候选列表的分页大小。""" + ... + + def _supports_interactive_buttons( + self, + channel: Optional[NotificationChannel], + ) -> bool: + """判断消息渠道是否支持按钮与回调。""" + ... + + def _create_media_buttons( + self, + channel: NotificationChannel, + request: PendingMediaInteraction, + items: List[MediaInfo | MusicInfo], + total: int, + total_pages: int, + ) -> List[List[Dict[str, str]]]: + """为候选生成可交互按钮。""" + ... + + def post_message(self, message: Message) -> None: + """将渠道通知交给宿主消息发送实现。""" + ... + + +def resolve_music_action(text: str) -> Tuple[Optional[str], str]: + """解析音乐交互前缀,避免把普通聊天误判为音乐搜索。""" + normalized = (text or "").strip() + action_names = { + "搜索": "MusicSearch", + "下载": "MusicReSearch", + "订阅": "MusicSubscribe", + "洗版": "MusicReSubscribe", + } + match = re.match( + r"^(?:音乐\s*(?P搜索|下载|订阅|洗版)|" + r"(?P搜索|下载|订阅|洗版)\s*音乐)\s*[::]?\s*(?P.*)$", + normalized, + ) + if match: + action = match.group("music_first") or match.group("action_first") + return action_names[action], match.group("content").strip() + + match = re.match( + r"^搜(?:索)?(?:歌曲|歌)\s*[::]?\s*(?P.*)$", + normalized, + ) + if match: + return "MusicSearch", match.group("content").strip() + + match = re.match(r"^音乐\s*[::]\s*(?P.*)$", normalized) + if match: + return "MusicSearch", match.group("content").strip() + if normalized.startswith("音乐 "): + return "MusicSearch", normalized[len("音乐 "):].strip() + return None, normalized + + +def resolve_media_action(text: str) -> Tuple[Optional[str], str]: + """归类影视或音乐搜索、订阅及普通聊天输入。""" + music_action, music_content = resolve_music_action(text) + if music_action: + return music_action, music_content + if text.startswith("订阅"): + return "Subscribe", re.sub(r"订阅[::\s]*", "", text) + if text.startswith("洗版"): + return "ReSubscribe", re.sub(r"洗版[::\s]*", "", text) + if text.startswith("搜索") or text.startswith("下载"): + return "ReSearch", re.sub(r"(搜索|下载)[::\s]*", "", text) + if url_tools.is_link(text): + return None, text + if not title_rules.is_media_title_like(text): + return None, text + return "Search", text + + +def search_music_candidates( + query: str, +) -> Tuple[MetaMusic, str, List[MusicInfo]]: + """用音乐目录搜索单曲与专辑候选,并返回交互展示标题。""" + meta = MetaMusic.parse_query(query) + candidates = MediaChain().search_music( + query=query, + limit=20, + music_types=(MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM), + ) + return meta, meta.title or query, candidates + + +def format_music_candidate(index: int, media: MusicInfo) -> str: + """格式化音乐单曲或专辑候选,明确展示实体类型和艺术家。""" + entity = "专辑" if media.music_type == MUSIC_ENTITY_ALBUM else "单曲" + title = media.title_year or media.title or "未命名音乐" + details = [f"{index}. [{entity}] {title}"] + artist = media.artist or media.album_artist + if artist: + details.append(artist) + if media.music_type != MUSIC_ENTITY_ALBUM and media.album: + details.append(f"专辑:{media.album}") + return " — ".join(details) + + +def media_exists_retry_prompt( + mediainfo: MediaInfo | MusicInfo, + meta: Optional[MetaBase], + keyword: str, + *, + subscription: bool, +) -> Tuple[str, str]: + """返回媒体库已存在时适用于影视或音乐的重试提示。""" + media_label = mediainfo.title_year + if not isinstance(mediainfo, MusicInfo) and meta: + media_label += meta.sea or "" + if isinstance(mediainfo, MusicInfo): + retry_hint = f"音乐 洗版 {keyword}" if subscription else f"音乐 下载 {keyword}" + else: + retry_hint = "洗版 XXX" if subscription else "搜索 名称 或 下载 名称" + return media_label, retry_hint + + +def selected_music_type(mediainfo: MediaInfo | MusicInfo) -> Optional[str]: + """返回已选音乐实体类型,影视媒体不携带音乐类型。""" + return mediainfo.music_type if isinstance(mediainfo, MusicInfo) else None + + +def post_music_candidates( + host: MusicInteractionHost, + request: PendingMediaInteraction, + channel: NotificationChannel, + source: str, + userid: int | str, + original_message_id: int | str | None = None, + original_chat_id: Optional[str] = None, +) -> None: + """按渠道能力以文字和按钮展示音乐候选,支持纯文本回复编号。""" + page_items, page, total_pages = host._page_items( + items=request.items, + page=request.page, + page_size=host._page_size(channel), + ) + request.page = page + total = len(request.items) + title = f"【音乐:{request.title}】共找到{total}条相关信息" + if host._supports_interactive_buttons(channel): + title += ",请选择操作" + buttons = host._create_media_buttons( + channel=channel, + request=request, + items=page_items, + total=total, + total_pages=total_pages, + ) + else: + page_size = host._page_size(channel) + title += ( + ",请回复对应数字选择" + if total <= page_size + else ",请回复对应数字选择(p: 上一页 n: 下一页)" + ) + buttons = None + text = "\n".join( + format_music_candidate(index, media) + for index, media in enumerate(page_items, start=1) + if isinstance(media, MusicInfo) + ) + host.post_message( + Message( + channel=channel, + source=source, + title=title, + text=text, + userid=userid, + buttons=buttons, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + save_history=False, + ) + ) diff --git a/app/chain/interaction.py b/app/chain/interaction.py index cde274cb3..957744d3c 100644 --- a/app/chain/interaction.py +++ b/app/chain/interaction.py @@ -8,16 +8,23 @@ from app.application.messaging.media import ( media_interaction_manager, ) from app.application.torrent.download import TorrentHelper +from app.chain._interaction_subscribe import subscribe_media +from app.chain._music_interaction import ( + format_music_candidate, + media_exists_retry_prompt, + post_music_candidates, + resolve_media_action, + search_music_candidates, + selected_music_type, +) from app.chain.base import ChainBase from app.chain.download import DownloadChain from app.chain.media import MediaChain from app.chain.search.facade import SearchChain from app.chain.subscribe.facade import SubscribeChain from app.domain import episode as episode_rules -from app.domain import title as title_rules -from app.domain.context import Context, MediaInfo +from app.domain.context import Context, MediaInfo, MusicInfo from app.domain.meta.metabase import MetaBase -from app.foundation import url as url_tools from app.runtime.log import logger from app.schemas.download import DownloadDirectory from app.schemas.file import FileURI @@ -31,7 +38,7 @@ from app.schemas.types import MediaType, NotificationChannel class MediaInteractionChain(ChainBase): """ - 处理媒体搜索、订阅、资源选择和翻页等交互流程。 + 处理影视与音乐搜索、订阅、资源选择和翻页等消息交互流程。 """ _button_page_size = 8 @@ -47,34 +54,37 @@ class MediaInteractionChain(ChainBase): @staticmethod def _get_noexits_info( - meta: MetaBase, mediainfo: MediaInfo + meta: MetaBase, mediainfo: MediaInfo | MusicInfo ) -> Dict[Union[int, str], Dict[int, NotExistMediaInfo]]: """ 构造媒体缺失集信息,用于全量重搜或自动下载补全集数。 """ if mediainfo.type == MediaType.TV: if not mediainfo.seasons: - mediainfo = MediaChain().recognize_media( + recognized_media = MediaChain().recognize_media( mtype=mediainfo.type, media_source=resolve_media_identity(media=mediainfo)[0], media_id=resolve_media_identity(media=mediainfo)[1], cache=False, ) - if not mediainfo: + if not recognized_media: logger.warn("媒体信息识别失败,无法补充季集信息") return {} - if not mediainfo.seasons: + if not recognized_media.seasons: logger.warn( "媒体信息中没有季集信息,标题:%s,tmdbid:%s,doubanid:%s", - mediainfo.title, - mediainfo.tmdb_id, - mediainfo.douban_id, + recognized_media.title, + recognized_media.tmdb_id, + recognized_media.douban_id, ) return {} + mediainfo = recognized_media media_source, media_id = resolve_media_identity(media=mediainfo) mediakey = build_media_key(media_source, media_id) - no_exists = {mediakey: {}} + no_exists: Dict[Union[int, str], Dict[int, NotExistMediaInfo]] = { + mediakey: {} + } if meta.begin_season is not None: episodes = mediainfo.seasons.get(meta.begin_season) if not episodes: @@ -394,19 +404,9 @@ class MediaInteractionChain(ChainBase): @staticmethod def _resolve_action(text: str) -> Tuple[Optional[str], str]: """ - 将用户输入归类为搜索、订阅或普通聊天。 + 将消息文本归类为影视或音乐搜索、订阅及普通聊天。 """ - if text.startswith("订阅"): - return "Subscribe", re.sub(r"订阅[::\s]*", "", text) - if text.startswith("洗版"): - return "ReSubscribe", re.sub(r"洗版[::\s]*", "", text) - if text.startswith("搜索") or text.startswith("下载"): - return "ReSearch", re.sub(r"(搜索|下载)[::\s]*", "", text) - if url_tools.is_link(text): - return None, text - if not title_rules.is_media_title_like(text): - return None, text - return "Search", text + return resolve_media_action(text) def _start_media_interaction( self, @@ -418,10 +418,19 @@ class MediaInteractionChain(ChainBase): username: str, ) -> None: """ - 根据用户输入搜索媒体,并进入媒体选择阶段。 + 根据用户输入搜索影视或音乐,并进入对应候选选择阶段。 """ - meta, medias = MediaChain().search(content) - if not meta.name: + meta: Optional[MetaBase] + medias: List[MediaInfo | MusicInfo] = [] + if action.startswith("Music"): + music_meta, title, music_candidates = search_music_candidates(content) + meta = music_meta + medias.extend(music_candidates) + else: + meta, media_candidates = MediaChain().search(content) + medias.extend(media_candidates) + title = meta.name if meta else "" + if not meta or not title: self._post_invalid_input( channel=channel, source=source, @@ -437,7 +446,7 @@ class MediaInteractionChain(ChainBase): source=source, userid=userid, username=username, - title=f"{meta.name} 没有找到对应的媒体信息!", + title=f"{title} 没有找到对应的媒体信息!", save_history=False, ) ) @@ -451,7 +460,7 @@ class MediaInteractionChain(ChainBase): username=username, action=action, keyword=content, - title=meta.name, + title=title, meta=meta, items=medias, ) @@ -491,10 +500,10 @@ class MediaInteractionChain(ChainBase): ) return - mediainfo: MediaInfo = page_items[page_index - 1] + mediainfo: MediaInfo | MusicInfo = page_items[page_index - 1] request.current_media = mediainfo - if request.action in {"Search", "ReSearch"}: + if request.action in {"Search", "ReSearch", "MusicSearch", "MusicReSearch"}: self._search_media_resources( request=request, mediainfo=mediainfo, @@ -507,8 +516,14 @@ class MediaInteractionChain(ChainBase): ) return - if request.action in {"Subscribe", "ReSubscribe"}: - self._subscribe_media( + if request.action in { + "Subscribe", + "ReSubscribe", + "MusicSubscribe", + "MusicReSubscribe", + }: + subscribe_media( + host=self, request=request, mediainfo=mediainfo, channel=channel, @@ -520,7 +535,7 @@ class MediaInteractionChain(ChainBase): def _search_media_resources( self, request: PendingMediaInteraction, - mediainfo: MediaInfo, + mediainfo: MediaInfo | MusicInfo, channel: NotificationChannel, source: str, userid: Union[str, int], @@ -531,24 +546,31 @@ class MediaInteractionChain(ChainBase): """ 根据已选媒体搜索资源,并切换到资源选择阶段。 """ + meta = request.meta exist_flag, no_exists = DownloadChain().get_no_exists_info( - meta=request.meta, + meta=meta, mediainfo=mediainfo, ) - if exist_flag and request.action == "Search": + if exist_flag and request.action in {"Search", "MusicSearch"}: + media_label, retry_hint = media_exists_retry_prompt( + mediainfo=mediainfo, + meta=meta, + keyword=request.keyword, + subscription=False, + ) self.post_message( Message( channel=channel, source=source, userid=userid, username=username, - title=f"【{mediainfo.title_year}{request.meta.sea} 媒体库中已存在,如需重新下载请发送:搜索 名称 或 下载 名称】", + title=f"【{media_label} 媒体库中已存在,如需重新下载请发送:{retry_hint}】", save_history=False, ) ) return - if exist_flag: - no_exists = self._get_noexits_info(request.meta, mediainfo) + if exist_flag and meta: + no_exists = self._get_noexits_info(meta, mediainfo) messages = self._build_no_exists_messages( mediainfo=mediainfo, @@ -581,13 +603,14 @@ class MediaInteractionChain(ChainBase): contexts = SearchChain().process(mediainfo=mediainfo, no_exists=no_exists) if not contexts: + season = meta.sea if meta else "" self.post_message( Message( channel=channel, source=source, userid=userid, username=username, - title=f"{mediainfo.title}{request.meta.sea} 未搜索到需要的资源!", + title=f"{mediainfo.title}{season} 未搜索到需要的资源!", save_history=False, ) ) @@ -598,7 +621,7 @@ class MediaInteractionChain(ChainBase): logger.info("用户 %s 在自动下载用户中,开始自动择优下载 ...", userid) request.phase = "torrent" request.page = 0 - request.title = mediainfo.title + request.title = mediainfo.title or "" request.items = list(contexts) if self._prompt_download_dir_selection( request=request, @@ -625,7 +648,7 @@ class MediaInteractionChain(ChainBase): request.phase = "torrent" request.page = 0 - request.title = mediainfo.title + request.title = mediainfo.title or "" request.items = list(contexts) self._render_interaction( request=request, @@ -636,58 +659,6 @@ class MediaInteractionChain(ChainBase): original_chat_id=original_chat_id, ) - def _subscribe_media( - self, - request: PendingMediaInteraction, - mediainfo: MediaInfo, - channel: NotificationChannel, - source: str, - userid: Union[str, int], - username: str, - ) -> None: - """ - 根据已选媒体创建订阅或洗版订阅。 - """ - best_version = request.action == "ReSubscribe" - if not best_version: - exist_flag, _ = DownloadChain().get_no_exists_info( - meta=request.meta, - mediainfo=mediainfo, - ) - if exist_flag: - self.post_message( - Message( - channel=channel, - source=source, - userid=userid, - username=username, - title=f"【{mediainfo.title_year}{request.meta.sea} 媒体库中已存在,如需洗版请发送:洗版 XXX】", - save_history=False, - ) - ) - return - - mp_name = ( - self.user_repository.find_name_by_bindings( - {f"{channel.name.lower()}_userid": userid} - ) - if channel - else None - ) - SubscribeChain().add( - title=mediainfo.title, - year=mediainfo.year, - mtype=mediainfo.type, - media_source=mediainfo.media_source, - media_id=mediainfo.media_id, - season=request.meta.begin_season, - channel=channel, - source=source, - userid=str(userid), - username=mp_name or username, - best_version=best_version, - ) - def _handle_torrent_selection( self, request: PendingMediaInteraction, @@ -948,14 +919,25 @@ class MediaInteractionChain(ChainBase): """ 自动择优下载当前资源列表,并在未完成时补建订阅。 """ + current_media = request.current_media + meta = request.meta + if not current_media or not meta: + self._post_invalid_input( + channel=channel, + source=source, + userid=userid, + username=username, + title="下载交互信息已失效,请重新搜索媒体", + ) + return downloadchain = DownloadChain() if no_exists is None: exist_flag, no_exists = downloadchain.get_no_exists_info( - meta=request.meta, - mediainfo=request.current_media, + meta=meta, + mediainfo=current_media, ) if exist_flag: - no_exists = self._get_noexits_info(request.meta, request.current_media) + no_exists = self._get_noexits_info(meta, current_media) downloads, lefts = downloadchain.batch_download( contexts=cache_list, @@ -967,11 +949,11 @@ class MediaInteractionChain(ChainBase): username=username, ) if downloads and not lefts: - logger.info("%s 下载完成", request.current_media.title_year) + logger.info("%s 下载完成", current_media.title_year) return - logger.info("%s 未下载未完整,添加订阅 ...", request.current_media.title_year) - if downloads and request.current_media.type == MediaType.TV: + logger.info("%s 未下载未完整,添加订阅 ...", current_media.title_year) + if downloads and current_media.type == MediaType.TV: note = [ download.meta_info.begin_episode for download in downloads @@ -988,18 +970,19 @@ class MediaInteractionChain(ChainBase): else None ) SubscribeChain().add( - title=request.current_media.title, - year=request.current_media.year, - mtype=request.current_media.type, - media_source=request.current_media.media_source, - media_id=request.current_media.media_id, - season=request.meta.begin_season, + title=current_media.title or "", + year=str(current_media.year or ""), + mtype=current_media.type, + media_source=current_media.media_source, + media_id=current_media.media_id, + season=meta.begin_season, channel=channel, source=source, userid=str(userid), username=mp_name or username, state="R", note=note, + music_type=selected_music_type(current_media), ) def _render_interaction( @@ -1054,6 +1037,17 @@ class MediaInteractionChain(ChainBase): """ 发送或更新媒体选择列表。 """ + if request.action.startswith("Music"): + post_music_candidates( + host=self, + request=request, + channel=channel, + source=source, + userid=userid, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + ) + return page_items, page, total_pages = self._page_items( items=request.items, page=request.page, @@ -1198,7 +1192,7 @@ class MediaInteractionChain(ChainBase): self, channel: NotificationChannel, request: PendingMediaInteraction, - items: List[MediaInfo], + items: List[MediaInfo | MusicInfo], total: int, total_pages: int, ) -> List[List[Dict[str, str]]]: @@ -1212,7 +1206,11 @@ class MediaInteractionChain(ChainBase): current_row: List[Dict[str, str]] = [] for index, media in enumerate(items, start=1): if max_per_row == 1: - button_text = f"{index}. {media.title_year}" + button_text = ( + format_music_candidate(index, media) + if isinstance(media, MusicInfo) + else f"{index}. {media.title_year}" + ) if len(button_text) > max_text_length: button_text = button_text[: max_text_length - 3] + "..." buttons.append( @@ -1401,7 +1399,9 @@ class MediaInteractionChain(ChainBase): return items[start:end], page, total_pages @classmethod - def _get_download_dirs(cls, media_info: Optional[MediaInfo] = None) -> List[DownloadDirectory]: + def _get_download_dirs( + cls, media_info: Optional[MediaInfo | MusicInfo] = None + ) -> List[DownloadDirectory]: """ 获取可供消息交互选择的下载目录。 """ @@ -1458,7 +1458,7 @@ class MediaInteractionChain(ChainBase): @staticmethod def _match_download_dir_media( dir_info: TransferDirectoryConf, - media_info: Optional[MediaInfo], + media_info: Optional[MediaInfo | MusicInfo], ) -> bool: """ 判断下载目录是否适用于当前媒体。 @@ -1499,7 +1499,7 @@ class MediaInteractionChain(ChainBase): @staticmethod def _build_no_exists_messages( - mediainfo: MediaInfo, + mediainfo: MediaInfo | MusicInfo, no_exists: Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]], show_missing_only: bool, ) -> List[str]: diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index cbfebd250..d36762243 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -760,8 +760,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 1048 | -| 内部导入边 | 8,894 | +| Python 模块 | 1050 | +| 内部导入边 | 8,915 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | @@ -771,7 +771,7 @@ flowchart LR | Model/Oper 自动事务与自建 Session | 0 | | 组合根外 `SystemConfigOper()` | 0 | -整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。宿主依赖边现为 8,894,未新增 SCC。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。当前 `app.startup.lifecycle` 为 569、`app.factory` 为 581、`app.main` 为 583。性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 +整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由独立解析、候选展示和订阅协调 owner 处理;宿主依赖边现为 8,915,新增两个单向依赖模块,未新增 SCC。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。当前 `app.startup.lifecycle` 为 569、`app.factory` 为 581、`app.main` 为 583。性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event 语义门禁,`Check host architecture snapshot` 再执行快照测试及一次 diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index 005ae9b44..1f0cfc538 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 1048 / 8,894 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档 | +| 宿主 Python 模块 / 内部依赖边 | 1050 / 8,915 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由音乐解析/展示与订阅协调 owner 处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -102,7 +102,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 | | 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 | -| 全量 mypy 历史债务 | 9,293 / 506 文件 | 删除独立活动日志中间件后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | +| 全量 mypy 历史债务 | 9,270 / 506 文件 | 删除独立活动日志中间件后的现状基线;消息媒体交互类型债务已清零,canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | | Ruff 历史诊断 | 516 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index d8f85d911..03b78a3dc 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1096,8 +1096,8 @@ "runtime_only": true } }, - "edge_count": 8894, - "edge_sha256": "806dcfdaa18be4ac59b2cb802136d444473259b2614a6480c361684270dced66", + "edge_count": 8915, + "edge_sha256": "8e06b3db97e3bca106d5965de2e555d93ceb878bf1a0c14bd4a6952e7e78ecf4", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -4248,6 +4248,16 @@ "app.chain._interaction -> app.chain._contracts", "app.chain._interaction -> app.schemas", "app.chain._interaction -> app.schemas.types", + "app.chain._interaction_subscribe -> app.chain", + "app.chain._interaction_subscribe -> app.chain._music_interaction", + "app.chain._interaction_subscribe -> app.chain.download", + "app.chain._interaction_subscribe -> app.chain.subscribe", + "app.chain._interaction_subscribe -> app.chain.subscribe.facade", + "app.chain._interaction_subscribe -> app.domain", + "app.chain._interaction_subscribe -> app.domain.context", + "app.chain._interaction_subscribe -> app.schemas", + "app.chain._interaction_subscribe -> app.schemas.message", + "app.chain._interaction_subscribe -> app.schemas.types", "app.chain._messaging -> app.application", "app.chain._messaging -> app.application.messaging", "app.chain._messaging -> app.application.messaging.message", @@ -4302,6 +4312,18 @@ "app.chain._music -> app.schemas.category", "app.chain._music -> app.schemas.common", "app.chain._music -> app.schemas.types", + "app.chain._music_interaction -> app.chain", + "app.chain._music_interaction -> app.chain.media", + "app.chain._music_interaction -> app.domain", + "app.chain._music_interaction -> app.domain.context", + "app.chain._music_interaction -> app.domain.meta", + "app.chain._music_interaction -> app.domain.meta.metamusic", + "app.chain._music_interaction -> app.domain.title", + "app.chain._music_interaction -> app.foundation", + "app.chain._music_interaction -> app.foundation.url", + "app.chain._music_interaction -> app.schemas", + "app.chain._music_interaction -> app.schemas.message", + "app.chain._music_interaction -> app.schemas.types", "app.chain._recognition -> app.application", "app.chain._recognition -> app.application.configuration", "app.chain._recognition -> app.chain", @@ -4565,6 +4587,8 @@ "app.chain.interaction -> app.application.torrent", "app.chain.interaction -> app.application.torrent.download", "app.chain.interaction -> app.chain", + "app.chain.interaction -> app.chain._interaction_subscribe", + "app.chain.interaction -> app.chain._music_interaction", "app.chain.interaction -> app.chain.base", "app.chain.interaction -> app.chain.download", "app.chain.interaction -> app.chain.media", @@ -4577,9 +4601,6 @@ "app.chain.interaction -> app.domain.episode", "app.chain.interaction -> app.domain.meta", "app.chain.interaction -> app.domain.meta.metabase", - "app.chain.interaction -> app.domain.title", - "app.chain.interaction -> app.foundation", - "app.chain.interaction -> app.foundation.url", "app.chain.interaction -> app.runtime", "app.chain.interaction -> app.runtime.log", "app.chain.interaction -> app.schemas", @@ -9994,7 +10015,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 1048, + "module_count": 1050, "modules": [ "app", "app.adapters", @@ -10380,8 +10401,10 @@ "app.chain", "app.chain._contracts", "app.chain._interaction", + "app.chain._interaction_subscribe", "app.chain._messaging", "app.chain._music", + "app.chain._music_interaction", "app.chain._recognition", "app.chain.acoustid", "app.chain.agent", diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index bfd06f886..db0db3b77 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -841,12 +841,7 @@ "no-any-return": 28 }, "app/chain/interaction.py": { - "arg-type": 4, - "assignment": 1, - "no-untyped-call": 1, - "return-value": 1, - "union-attr": 16, - "var-annotated": 1 + "no-untyped-call": 1 }, "app/chain/mediaserver.py": { "arg-type": 3, diff --git a/tests/test_media_interaction.py b/tests/test_media_interaction.py index 5571433b3..a951447bd 100644 --- a/tests/test_media_interaction.py +++ b/tests/test_media_interaction.py @@ -7,8 +7,9 @@ import pytest from app.chain.message import MessageChain from app.chain.interaction import MediaInteractionChain from app.runtime.events import EventManager -from app.domain.context import Context, MediaInfo, TorrentInfo +from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo from app.domain.meta.metabase import MetaBase +from app.domain.meta.metamusic import MetaMusic from app.application.messaging.interaction import InteractionContext from app.application.messaging.media import media_interaction_manager from app.application.messaging.plugin import ( @@ -16,7 +17,14 @@ from app.application.messaging.plugin import ( plugin_input_interaction_manager, ) from app.schemas import IncomingMessage, TransferDirectoryConf # pylint: disable=no-name-in-module -from app.schemas.types import EventType, MediaSource, MediaType, NotificationChannel +from app.schemas.types import ( + MUSIC_ENTITY_ALBUM, + MUSIC_ENTITY_RECORDING, + EventType, + MediaSource, + MediaType, + NotificationChannel, +) @pytest.fixture(autouse=True) @@ -45,6 +53,196 @@ def _build_meta(name: str) -> MetaBase: return meta +@pytest.mark.parametrize( + ("text", "action", "query"), + [ + ("音乐 搜索 周杰伦 晴天", "MusicSearch", "周杰伦 晴天"), + ("搜歌 周杰伦 晴天", "MusicSearch", "周杰伦 晴天"), + ("下载音乐 周杰伦 晴天", "MusicReSearch", "周杰伦 晴天"), + ("订阅音乐 周杰伦 晴天", "MusicSubscribe", "周杰伦 晴天"), + ("音乐 洗版 周杰伦 晴天", "MusicReSubscribe", "周杰伦 晴天"), + ], +) +def test_resolve_explicit_music_message_actions(text, action, query): + """显式音乐前缀应路由到相应音乐操作并保留检索词。""" + assert MediaInteractionChain._resolve_action(text) == (action, query) + + +def test_message_routes_music_search_when_agent_is_disabled(): + """未启用智能助手时,消息渠道音乐搜索应创建音乐候选交互。""" + chain = MessageChain() + chain.runtime_config = replace(chain.runtime_config, ai_agent_enable=False) + candidates = [ + MusicInfo( + media_source=MediaSource.MusicBrainz, + media_id="recording-1", + title="晴天", + artists=["周杰伦"], + ), + MusicInfo( + media_source=MediaSource.MusicBrainz, + media_id="album-1", + music_type=MUSIC_ENTITY_ALBUM, + title="叶惠美", + artists=["周杰伦"], + ), + ] + + with ( + patch( + "app.chain.interaction.MediaChain.search_music", + return_value=candidates, + ) as search_music, + patch( + "app.chain.interaction.MediaInteractionChain._render_interaction" + ), + ): + chain._handle_message_core( + channel=NotificationChannel.Wechat, + source="wechat-test", + userid="music-user", + username="tester", + text="音乐 搜索 周杰伦 晴天", + ) + + search_music.assert_called_once_with( + query="周杰伦 晴天", + limit=20, + music_types=(MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM), + ) + request = media_interaction_manager.get_by_user("music-user") + assert request is not None + assert request.action == "MusicSearch" + assert request.keyword == "周杰伦 晴天" + assert request.items == candidates + + +def test_music_candidates_are_presented_as_text_for_plain_message_channel(): + """不支持媒体卡片的消息渠道应以编号文本展示单曲和专辑候选。""" + chain = MediaInteractionChain() + request = media_interaction_manager.create_or_replace( + user_id="music-user", + channel=NotificationChannel.Wechat, + source="wechat-test", + username="tester", + action="MusicSearch", + keyword="周杰伦 晴天", + title="周杰伦 晴天", + meta=MetaMusic.parse_query("周杰伦 晴天"), + items=[ + MusicInfo(title="晴天", artists=["周杰伦"]), + MusicInfo( + music_type=MUSIC_ENTITY_ALBUM, + title="叶惠美", + artists=["周杰伦"], + ), + ], + ) + + with patch.object(chain, "post_message") as post_message: + chain._post_medias_message( + request=request, + channel=NotificationChannel.Wechat, + source="wechat-test", + userid="music-user", + ) + + notification = post_message.call_args.args[0] + assert "请回复对应数字选择" in notification.title + assert notification.buttons is None + assert "1. [单曲] 晴天 — 周杰伦" in notification.text + assert "2. [专辑] 叶惠美 — 周杰伦" in notification.text + + +def test_music_subscription_selection_preserves_selected_music_type(): + """选择音乐专辑创建订阅时应将实体类型传给订阅链。""" + chain = MediaInteractionChain() + album = MusicInfo( + media_source=MediaSource.MusicBrainz, + media_id="album-1", + music_type=MUSIC_ENTITY_ALBUM, + title="叶惠美", + artists=["周杰伦"], + ) + request = media_interaction_manager.create_or_replace( + user_id="music-user", + channel=NotificationChannel.Wechat, + source="wechat-test", + username="tester", + action="MusicSubscribe", + keyword="周杰伦 叶惠美", + title="叶惠美", + meta=MetaMusic.parse_query("周杰伦 叶惠美"), + items=[album], + ) + + with ( + patch( + "app.chain.interaction.DownloadChain.get_no_exists_info", + return_value=(False, None), + ), + patch("app.chain.interaction.SubscribeChain.add") as add_subscribe, + patch.object(chain.user_repository, "find_name_by_bindings", return_value=None), + ): + chain.handle_text_interaction( + channel=NotificationChannel.Wechat, + source="wechat-test", + userid="music-user", + username="tester", + text="1", + ) + + assert request.current_media is album + assert add_subscribe.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM + + +def test_auto_download_fallback_subscription_preserves_music_type(): + """音乐自动下载未完成时补建订阅应保留单曲或专辑实体类型。""" + chain = MediaInteractionChain() + album = MusicInfo( + media_source=MediaSource.MusicBrainz, + media_id="album-1", + music_type=MUSIC_ENTITY_ALBUM, + title="叶惠美", + artists=["周杰伦"], + ) + request = media_interaction_manager.create_or_replace( + user_id="music-user", + channel=NotificationChannel.Wechat, + source="wechat-test", + username="tester", + action="MusicSearch", + keyword="周杰伦 叶惠美", + title="叶惠美", + meta=MetaMusic.parse_query("周杰伦 叶惠美"), + items=[album], + ) + request.current_media = album + + with ( + patch( + "app.chain.interaction.DownloadChain.get_no_exists_info", + return_value=(False, None), + ), + patch( + "app.chain.interaction.DownloadChain.batch_download", + return_value=([], [object()]), + ), + patch("app.chain.interaction.SubscribeChain.add") as add_subscribe, + patch.object(chain.user_repository, "find_name_by_bindings", return_value=None), + ): + chain._auto_download( + request=request, + cache_list=[], + channel=NotificationChannel.Wechat, + source="wechat-test", + userid="music-user", + username="tester", + ) + + assert add_subscribe.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM + + def _build_context(title: str = "星际穿越") -> Context: """构造可用于媒体交互下载测试的资源上下文。""" return Context(