1
0
mirror of https://github.com/hanxi/xiaomusic.git synced 2026-05-16 10:56:46 +08:00

feat: 新增小爱音箱语音反馈,及后端检测播放url有效性 (#863)

* 增加搜索及出错的语音反馈

* 语言提示mp3文件白名单。配合新增的语音反馈

* 新增的语音反馈mp3文件

* Delete xiaomusic/api/dependencies-bl.py

* 新增提示语言mp3白名单,配合语音反馈

* 新增后端url检测。失效跳过,播放下一首。连续5次失效,停止播放
This commit is contained in:
birdstudy-nj
2026-05-09 18:39:37 +08:00
committed by GitHub
parent 2e27b41ebe
commit 38583b63c8
6 changed files with 124 additions and 33 deletions

View File

@@ -2,18 +2,18 @@
import hashlib
import secrets
import time # 🚀 新增:用于生成 7 天免密 Cookie 的过期时间exp
import time # 用于生成 7 天免密 Cookie 的过期时间exp
from typing import (
TYPE_CHECKING,
Annotated,
)
import jwt # 🚀 新增:用于生成和验证 JWT Token
import jwt # 用于生成和验证 JWT Token
from fastapi import (
Depends,
HTTPException,
Request,
Response, # 🚀 新增:引入 Response 用于写入 Cookie
Response, # 引入 Response 用于写入 Cookie
status,
)
from fastapi.security import (
@@ -28,7 +28,7 @@ if TYPE_CHECKING:
from xiaomusic.config import Config
from xiaomusic.xiaomusic import XiaoMusic
# 🚀 修改:关闭基础认证的自动抛错,让我们接管验证流程
# 关闭基础认证的自动抛错,让我们接管验证流程
security = HTTPBasic(auto_error=False)
@@ -104,7 +104,7 @@ config: "Config" = _LazyProxy("_config") # type: ignore
log: "logging.Logger" = _LazyProxy("_log") # type: ignore
# 🚀 修改:增加了 request 和 response 参数以操作 Cookie并将 credentials 设为 Optional
# 增加了 request 和 response 参数以操作 Cookie并将 credentials 设为 Optional
def verification(
request: Request,
response: Response,
@@ -112,7 +112,7 @@ def verification(
):
"""HTTP Basic 认证"""
# ========================================================
# 🚀 新增:7天免密模块 开始 (API拦截层)
# 7天免密模块 开始 (API拦截层)
# ========================================================
if config.disable_httpauth:
return True
@@ -154,7 +154,7 @@ def verification(
)
# ========================================================
# 🚀 新增:验证成功后,在此处派发持久化 Cookie
# 验证成功后,在此处派发持久化 Cookie
# ========================================================
expire_time = time.time() + 60 * 60 * 24 * 7
payload = {"sub": credentials.username, "exp": expire_time}
@@ -216,9 +216,13 @@ class AuthStaticFiles(StaticFiles):
async def __call__(self, scope, receive, send) -> None:
request = Request(scope, receive)
# 系统提示音,不走任何校验,直接允许访问(修复启用安全验证后,无法播放系统提示音的问题)
if request.url.path.endswith(("/xiaomusic_ok.mp3", "/xiaomusic_error.mp3", "/silence.mp3", "/search.mp3")):
await super().__call__(scope, receive, send)
return
if not config.disable_httpauth:
# ========================================================
# 🚀 新增:7天免密模块 开始 (网页静态文件拦截层)
# 7天免密模块 开始 (网页静态文件拦截层)
# ========================================================
session_secret = hashlib.sha256(
config.httpauth_password.encode()

View File

@@ -11,6 +11,7 @@ import random
import time
from typing import TYPE_CHECKING
import aiohttp
from miservice import miio_command
from xiaomusic.config import Device
@@ -69,6 +70,7 @@ class XiaoMusicDevice:
self._duration = 0
self._paused_time = 0
self._play_failed_cnt = 0
self._url_failed_cnt = 0 # 新增URL探路失败专属计数器
self._play_list = []
@@ -348,25 +350,67 @@ class XiaoMusicDevice:
self.device.playlist2music[self.device.cur_playlist] = name
cur_playlist = self.device.cur_playlist
self.log.info(f"cur_music {self.get_cur_music()}")
# 把 cur_playlist 传过去,要求拿该歌单下的专属 URL
url, _ = await self.xiaomusic.music_library.get_music_url(name, cur_playlist)
# 代理 URL 极速探路与 5 次死链熔断
if url and url.startswith("http") and "/proxy/" in url:
self.log.info(f"极速探路启动,触发后端代理解析: {url}")
is_url_ok = False
try:
# 给了 3.0 秒,因为触发 JS 插件去目标平台请求真实 URL 需要一点时间
timeout = aiohttp.ClientTimeout(total=3.0)
async with aiohttp.ClientSession(timeout=timeout) as session:
# 只要拿到 200/206 状态码,瞬间挂断不下载!
async with session.get(url) as resp:
# 200是正常206是部分内容(也是正常)
if resp.status in (200, 206):
is_url_ok = True
self.log.info(f"探路成功!代理服务器存活,状态码: {resp.status}")
else:
self.log.warning(f"探路发现死链!代理服务器报错,状态码: {resp.status}")
except Exception as e:
self.log.warning(f"探路超时或网络异常(插件解析失败): {e}")
# --- 探路失败处理逻辑 ---
if not is_url_ok:
self._url_failed_cnt += 1
self.log.warning(f"当前连续死链次数: {self._url_failed_cnt}")
if self._url_failed_cnt >= 5:
self.log.error("连续 5 次获取歌曲死链,触发第一层终极熔断!")
self._url_failed_cnt = 0
# 调用大管家的万能报错机制!
await self.xiaomusic.handle_fatal_error(self.did, "连续多次获取歌曲失败,已为您停止")
return
# 没到 5 次,静默切下一首
if self.is_playing and self._last_cmd != "stop":
await asyncio.sleep(0.5)
await self._play_next()
return
await self.group_force_stop_xiaoai()
self.log.info(f"播放 {url}")
# 设备指令异常拦截 (完全保持原样)
results = await self.group_player_play(url, name)
if all(ele is None for ele in results):
self.log.info(f"播放 {name} 失败. 失败次数: {self._play_failed_cnt}")
await asyncio.sleep(1)
if (
self.is_playing
and self._last_cmd != "stop"
and self._play_failed_cnt < 10
self.is_playing
and self._last_cmd != "stop"
and self._play_failed_cnt < 10
):
self._play_failed_cnt = self._play_failed_cnt + 1
await self._play_next()
return
# 重置播放失败次数
self._play_failed_cnt = 0
self._url_failed_cnt = 0
self.log.info(f"{name}】已经开始播放了")
@@ -374,7 +418,7 @@ class XiaoMusicDevice:
self._start_time = time.time()
self._paused_time = 0
# e获取时长也传 cur_playlist
# 获取时长也传 cur_playlist
sec = await self.xiaomusic.music_library.get_music_duration(name, cur_playlist)
# 存储真实歌曲时长
self._duration = sec

View File

@@ -607,20 +607,33 @@ class OnlineMusicService:
async def online_play(self, did="", arg1="", **kwargs):
await self._before_play()
parts = arg1.split("|")
search_key = parts[0]
name = parts[1] if len(parts) > 1 else search_key
search_key = parts[0].strip() if parts[0] else ""
name = parts[1].strip() if len(parts) > 1 else search_key
if not name:
name = search_key
# 没有歌名
if not name:
return await self.xiaomusic.handle_fatal_error(did, "小Music没有听到搜索歌名哦请重试。")
self.reset_singer_add_page()
self.log.info(f"搜索关键字{search_key},提取的歌名{name}")
await self.search_top_one_play(did, search_key, name)
try:
res = await self.search_top_one_play(did, search_key, name)
# 接口明确返回了失败或者没找到歌曲
if res and isinstance(res, dict) and not res.get("success"):
return await self.xiaomusic.handle_fatal_error(did, f"小Music没找到关于{name}的歌曲")
return res
except Exception:
# 异常兜底,代码崩溃
return await self.xiaomusic.handle_fatal_error(did, "小Music搜歌过程中出了点小故障")
async def online_playlist_play(self, did="", arg1="", **kwargs):
"""执行语音搜歌单并播放"""
await self._before_play()
search_key = arg1.strip()
search_key = str(arg1).strip() if arg1 else ""
if not search_key:
return await self.xiaomusic.do_tts(did, "你想搜什么歌单呢?")
return await self.xiaomusic.handle_fatal_error(did, "小Music没有听到搜索歌单内容请重试。")
self.log.info(f"语音搜索歌单: {search_key}")
try:
@@ -648,8 +661,8 @@ class OnlineMusicService:
playlists = []
if not playlists:
return await self.xiaomusic.do_tts(did, f"没找到关于{search_key}的歌单")
# 直接要一个最优歌单,完全不操心底下用了什么策略
return await self.xiaomusic.handle_fatal_error(did, f"小Music没找到关于{search_key}的歌单")
# 选出最优歌单
best_playlist = self.js_plugin_manager.pick_best_playlist(playlists)
pl_name = best_playlist.get("title") or best_playlist.get("name") or "未知歌单"
pl_id = best_playlist.get("id")
@@ -664,24 +677,36 @@ class OnlineMusicService:
detail_res = await self.get_playlist_detail_online(id=target_id, plugin=pref, api_type=api_type)
songs = detail_res.get("data", [])
if not songs:
return await self.xiaomusic.do_tts(did, f"歌单{pl_name}一首歌都没有")
return await self.xiaomusic.handle_fatal_error(did, f"小Music获取歌单{pl_name},里面一首歌都没有")
# 推送到音箱 (归并到 _online_iwebplayer_search)
self.log.info(f"成功选中歌单【{pl_name}】,共 {len(songs)} 首歌")
return await self.push_music_list_play(did, songs, "_online_iwebplayer_search")
except Exception as e:
self.log.error(f"语音搜歌单失败: {e}")
return await self.xiaomusic.do_tts(did, "单过程中出了点小故障")
return await self.xiaomusic.handle_fatal_error(did, "小Music搜索歌单过程中出了点小故障")
async def singer_play(self, did="", arg1="", **kwargs):
await self._before_play()
parts = arg1.split("|")
search_key = parts[0]
name = parts[1] if len(parts) > 1 else search_key
search_key = parts[0].strip() if parts[0] else ""
name = parts[1].strip() if len(parts) > 1 else search_key
if not name:
name = search_key
if not name:
return await self.xiaomusic.handle_fatal_error(did, "小Music没有听到歌手名哦请重试。")
self.reset_singer_add_page()
self.log.info(f"搜索关键字{search_key},搜索歌手名{name}")
await self.search_singer_play(did, search_key, name)
try:
res = await self.search_singer_play(did, search_key, name)
# 没搜到这位歌手的歌
if res and isinstance(res, dict) and not res.get("success"):
return await self.xiaomusic.handle_fatal_error(did, f"小Music没找到歌手{name}的歌曲")
return res
except Exception as e:
self.log.error(f"语音搜歌手失败: {e}")
# 异常兜底:代码崩溃
return await self.xiaomusic.handle_fatal_error(did, "小Music搜歌手时出了点小故障")
# 处理推送的歌单并播放
async def push_music_list_play(
@@ -756,19 +781,19 @@ class OnlineMusicService:
self.log.error(f"searchKey {search_key} get media source failed: {e}")
return {"success": False, "error": str(e)}
def default_url(self):
# 先推送默认【搜索中】音频搜索到播放url后推送给小爱
def default_url(self, name="silence.mp3"):
"""获取静态音频文件的完整 URL"""
config = self.xiaomusic.config
if config and hasattr(config, "hostname") and hasattr(config, "public_port"):
proxy_base = f"{config.hostname}:{config.public_port}"
else:
proxy_base = "http://192.168.31.241:8090"
proxy_base = "http://192.168.31.241:8090" # 之前代码中就有的ip我保留。
# return proxy_base + "/static/search.mp3"
return proxy_base + "/static/silence.mp3"
return f"{proxy_base}/static/{name}"
async def _before_play(self):
async def _before_play(self, prompt_audio="xiaomusic_ok.mp3"):
# 先推送默认【搜索中】音频搜索到播放url后推送给小爱
before_url = self.default_url()
before_url = self.default_url(prompt_audio)
await self.xiaomusic.play_url(self.xiaomusic.get_cur_did(), before_url)
def _convert_song_list_to_music_items(self, song_list):
@@ -1116,7 +1141,6 @@ class OnlineMusicService:
enabled_plugins = self.js_plugin_manager.get_enabled_plugins()
if plugin_name not in enabled_plugins:
return {"success": False, "error": f"Plugin {plugin_name} not enabled"}
try:
# 调用插件方法,传递额外参数
result = getattr(self.js_plugin_manager, method_name)(

Binary file not shown.

Binary file not shown.

View File

@@ -367,9 +367,9 @@ class XiaoMusic:
# ===========================在线搜索函数================================
def default_url(self):
def default_url(self, name="silence.mp3"):
"""委托给 online_music_service"""
return self.online_music_service.default_url()
return self.online_music_service.default_url(name)
# 在线获取歌曲列表(委托给 online_music_service
async def get_music_list_online(
@@ -700,3 +700,22 @@ class XiaoMusic:
async def do_tts(self, did, value):
return await self.device_manager.devices[did].do_tts(value)
async def handle_fatal_error(self, did, tts_msg="小music发生错误请重试。"):
"""全局异常报错处理:支持 TTS 或 xiaomusic_error.mp3 音效"""
# 1. 如果开启了 TTS优先使用语音交互
if getattr(self.config, "edge_tts_voice", "disable") != "disable":
self.log.info(f"触发全局 TTS 报错: {tts_msg}")
await self.do_tts(did, tts_msg)
return
# 2. 如果关闭了 TTS触发 error.mp3 报错音效
self.log.info("TTS 已关闭,触发全局 xiaomusic_error.mp3 报错音效")
error_url = self.default_url("xiaomusic_error.mp3")
# 播放报错音效
await self.play_url(did, error_url)
# 在第 3 秒时提前下发 stop把硬件断流的咔声藏在静音里
import asyncio
await asyncio.sleep(3)
# 停止
await self.stop(did, "notts")