mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-04 06:15:14 +08:00
feat(messaging): support music interactions without agent
This commit is contained in:
@@ -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
|
||||
|
||||
87
app/chain/_interaction_subscribe.py
Normal file
87
app/chain/_interaction_subscribe.py
Normal 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),
|
||||
)
|
||||
216
app/chain/_music_interaction.py
Normal file
216
app/chain/_music_interaction.py
Normal 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,
|
||||
)
|
||||
)
|
||||
@@ -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]:
|
||||
|
||||
@@ -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` 再执行快照测试及一次
|
||||
|
||||
@@ -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 未进入包级覆盖率门禁 |
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user