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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)(
|
||||
|
||||
BIN
xiaomusic/static/xiaomusic_error.mp3
Normal file
BIN
xiaomusic/static/xiaomusic_error.mp3
Normal file
Binary file not shown.
BIN
xiaomusic/static/xiaomusic_ok.mp3
Normal file
BIN
xiaomusic/static/xiaomusic_ok.mp3
Normal file
Binary file not shown.
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user