feat(messaging): support music interactions without agent

This commit is contained in:
jxxghp
2026-09-26 13:21:56 +08:00
parent a698d5504a
commit 2cfb94d886
9 changed files with 651 additions and 132 deletions

View File

@@ -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

View File

@@ -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),
)

View File

@@ -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<music_first>搜索|下载|订阅|洗版)|"
r"(?P<action_first>搜索|下载|订阅|洗版)\s*音乐)\s*[::]?\s*(?P<content>.*)$",
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<content>.*)$",
normalized,
)
if match:
return "MusicSearch", match.group("content").strip()
match = re.match(r"^音乐\s*[::]\s*(?P<content>.*)$", 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,
)
)

View File

@@ -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]:

View File

@@ -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` 再执行快照测试及一次

View File

@@ -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 未进入包级覆盖率门禁 |

View File

@@ -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",

View File

@@ -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,

View File

@@ -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(