1
0
mirror of https://github.com/hanxi/xiaomusic.git synced 2025-12-06 14:52:50 +08:00

Compare commits

...

19 Commits

Author SHA1 Message Date
涵曦
6a583119d0 new version v0.1.93 2024-07-05 15:28:51 +00:00
涵曦
9349070e8b Update README.md 2024-07-05 23:21:43 +08:00
涵曦
01d99dc699 feat: 访问账号密码默认为空 2024-07-05 15:15:31 +00:00
涵曦
f4d9a6c1fd feat: 支持下载的目录与本地音乐目录分开 see #98 2024-07-05 15:12:36 +00:00
涵曦
1919bc84d9 feat: 新增m4a文件格式支持 2024-07-05 14:55:47 +00:00
涵曦
4c1761468f fix: 修复设置页面没成功初始化设置问题 2024-07-05 14:52:14 +00:00
涵曦
c75230a67d Update ci.yml 2024-07-05 22:15:14 +08:00
涵曦
ee7ffa55cb fix: 修复镜像缺少文件问题 2024-07-05 13:45:44 +00:00
涵曦
45bbc8af42 Update ci.yml 2024-07-05 21:22:36 +08:00
涵曦
ab8bf8fa62 fix: 尝试解决插件路径问题 2024-07-05 13:12:03 +00:00
涵曦
493cad080e Update README.md 2024-07-05 18:43:06 +08:00
涵曦
eaa159c5cb feat: 设置页面支持配置多设备 2024-07-05 10:34:38 +00:00
涵曦
96e3b8c2ff feat: 默认用空的后台账号和密码 2024-07-05 09:10:48 +00:00
涵曦
794e8dcd06 feat: 支持多个设备同时播放 see #65 2024-07-05 09:10:01 +00:00
涵曦
f2675e4340 Update README.md 2024-07-05 13:08:18 +08:00
涵曦
e5059840fb fix: 设置页面日志路径写错了 2024-07-05 04:57:20 +00:00
涵曦
f3e57789fa feat: 新增自定义口令功能 #105 2024-07-05 04:26:21 +00:00
涵曦
c151b826f7 Update README.md 2024-07-04 23:31:43 +08:00
涵曦
1b3ed3b35a fix: 修复口令导致异常关闭的问题 2024-07-04 13:07:34 +00:00
18 changed files with 471 additions and 178 deletions

View File

@@ -28,3 +28,9 @@ jobs:
platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/xiaomusic:${{ github.ref_name }}
- name: Docker Hub Description
uses: peter-evans/dockerhub-description@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: hanxi/xiaomusic

View File

@@ -11,6 +11,7 @@ WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/ffmpeg /app/ffmpeg
COPY xiaomusic/ ./xiaomusic/
COPY plugins/ ./plugins/
COPY xiaomusic.py .
ENV XDG_CONFIG_HOME=/config
ENV XIAOMUSIC_HOSTNAME=192.168.2.5

View File

@@ -137,7 +137,7 @@ pdm run xiaomusic.py
| ---- | ---------------------------------------------------------------------------------------------- |
| L06A | [小爱音箱](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.l06a) |
| L07A | [Redmi小爱音箱 Play](https://home.mi.com/webapp/content/baike/product/index.html?model=xiaomi.wifispeaker.l7a) |
| S12/S12A | [小米AI音箱](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.s12) |
| S12/S12A/MDZ-25-DA | [小米AI音箱](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.s12) |
| LX5A | [小爱音箱 万能遥控版](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.lx5a) |
| LX05 | [小爱音箱Play2019款](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.lx05) |
| L16A | [Xiaomi Sound](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.l16a) |
@@ -159,6 +159,7 @@ pdm run xiaomusic.py
- wav
- ape
- ogg
- m4a
> 本地音乐会搜索目录下上面格式的文件,下载的歌曲是 mp3 格式的。
> 已知 L05B L05C 不支持 flac 格式。
@@ -315,13 +316,19 @@ services:
- XIAOMUSIC_HTTPAUTH_PASSWORD 配置 web 控制台密码
- XIAOMUSIC_CONF_PATH 用来存放配置文件的目录记得把目录映射到主机默认情况会把配置存放在music目录具体见 <https://github.com/hanxi/xiaomusic/issues/74>
- XIAOMUSIC_DISABLE_DOWNLOAD 设为 true 时关闭下载功能,见 <https://github.com/hanxi/xiaomusic/issues/82>
- XIAOMUSIC_USE_MUSIC_API 设为 true 时使用 player_play_music 接口播放音乐,用于兼容不能播放的型号,v0.1.86 之后的版本可以不用设置
- XIAOMUSIC_USE_MUSIC_API 设为 true 时使用 player_play_music 接口播放音乐,用于兼容不能播放的型号,触屏版目前还需要设置这个为 true
- XIAOMUSIC_KEYWORDS_PLAY 用来播放歌曲的口令前缀,默认是 "播放歌曲,放歌曲" ,可以用英文逗号分割配置多个
- XIAOMUSIC_KEYWORDS_STOP 用来关机的口令,默认是 "关机,暂停,停止" ,可以用英文逗号分割配置多个。
- XIAOMUSIC_KEYWORDS_PLAYLOCAL 用来播放本地歌曲的口令前缀,本地找不到时不会下载歌曲,默认是 "播放本地歌曲,本地播放歌曲" ,可以用英文逗号分割配置多个。
- XIAOMUSIC_ENABLE_FUZZY_MATCH 设为 true 时开启模糊匹配(默认),设为 false 时关闭模糊匹配,支持模糊匹配歌名和歌单名。 具体见 <https://github.com/hanxi/xiaomusic/issues/52>
- XIAOMUSIC_FUZZY_MATCH_CUTOFF 设置模糊搜索匹配的最低相似度阈值默认0.6可以配0到1直接的小数越小越模糊越大越精准。具体见 <https://github.com/hanxi/xiaomusic/issues/52>
- XIAOMUSIC_PUBLIC_PORT 用于设置外网端口当使用反向代理时可以设置为外网端口XIAOMUSIC_HOSTNAME 设为外网IP或者域名即可。
- XIAOMUSIC_DOWNLOAD_PATH 变量可以配置下载目录,默认为空,表示使用 music 目录为下载目录。设置这个目录必须是 music 的子目录,否则刷新列表后会找不到歌曲。具体见 <https://github.com/hanxi/xiaomusic/issues/98>
## 高级篇
- 自定义口令功能 <https://github.com/hanxi/xiaomusic/issues/105>
- [ ] 缺少一篇教程 [如何写自定义插件](https://github.com/hanxi/xiaomusic/issues/105)
## 讨论区

View File

@@ -9,8 +9,9 @@
"conf_path": null,
"hostname": "192.168.2.5",
"port": 8090,
"public_port": 0,
"proxy": null,
"search_prefix": "ytsearch:",
"search_prefix": "bilisearch:",
"ffmpeg_location": "./ffmpeg/bin",
"active_cmd": "play,random_play,playlocal,play_music_list,stop",
"exclude_dirs": "@eaDir",
@@ -21,12 +22,60 @@
"music_list_url": "",
"music_list_json": "",
"disable_download": false,
"key_word_dict": {
"播放歌曲": "play",
"播放本地歌曲": "playlocal",
"关机": "stop",
"下一首": "play_next",
"单曲循环": "set_play_type_one",
"全部循环": "set_play_type_all",
"随机播放": "random_play",
"分钟后关机": "stop_after_minute",
"播放列表": "play_music_list",
"刷新列表": "gen_music_list",
"set_volume#": "set_volume",
"get_volume#": "get_volume",
"本地播放歌曲": "playlocal",
"放歌曲": "play",
"暂停": "stop",
"停止": "stop",
"停止播放": "stop",
"测试自定义口令": "exec#code1(\"hello\")",
"测试链接": "exec#httpget(\"https://github.com/hanxi/xiaomusic\")"
},
"key_match_order": [
"set_volume#",
"get_volume#",
"分钟后关机",
"播放歌曲",
"下一首",
"单曲循环",
"全部循环",
"随机播放",
"关机",
"刷新列表",
"播放列表",
"播放本地歌曲",
"本地播放歌曲",
"放歌曲",
"暂停",
"停止",
"停止播放",
"测试自定义口令",
"测试链接"
],
"use_music_api": false,
"use_music_audio_id": "1582971365183456177",
"use_music_id": "355454500",
"log_file": "/tmp/xiaomusic.txt",
"fuzzy_match_cutoff": 0.6,
"enable_fuzzy_match": true,
"stop_tts_msg": "收到,再见",
"keywords_playlocal": "播放本地歌曲,本地播放歌曲",
"keywords_play": "播放歌曲,放歌曲",
"keywords_stop": "关机,暂停,停止,停止播放"
"keywords_stop": "关机,暂停,停止,停止播放",
"user_key_word_dict": {
"测试自定义口令": "exec#code1(\"hello\")",
"测试链接": "exec#httpget(\"https://github.com/hanxi/xiaomusic\")"
}
}

0
plugins/__init__.py Normal file
View File

4
plugins/code1.py Normal file
View File

@@ -0,0 +1,4 @@
async def code1(arg1):
global log, xiaomusic
log.info(f"code1:{arg1}")
await xiaomusic.do_tts("你好,我是自定义的测试口令")

10
plugins/httpget.py Normal file
View File

@@ -0,0 +1,10 @@
import requests
def httpget(url):
global log
# 发起请求
response = requests.get(url, timeout=5) # 增加超时以避免长时间挂起
response.raise_for_status() # 如果响应不是200引发HTTPError异常
log.info(f"httpget url:{url} response:{response.text}")

View File

@@ -1,6 +1,6 @@
[project]
name = "xiaomusic"
version = "0.1.92"
version = "0.1.93"
description = "Play Music with xiaomi AI speaker"
authors = [
{name = "涵曦", email = "im.hanxi@gmail.com"},

View File

@@ -1 +1 @@
__version__ = "0.1.92"
__version__ = "0.1.93"

View File

@@ -61,6 +61,12 @@ def main():
dest="ffmpeg_location",
help="ffmpeg bin path",
)
parser.add_argument(
"--enable_config_example",
dest="enable_config_example",
help="是否输出示例配置文件",
action="store_true",
)
print(LOGO.format(f"XiaoMusic v{__version__} by: github.com/hanxi"))

View File

@@ -3,57 +3,72 @@ from __future__ import annotations
import argparse
import json
import os
from dataclasses import dataclass
from dataclasses import asdict, dataclass, field
from typing import get_type_hints
from xiaomusic.utils import validate_proxy
# 默认口令
DEFAULT_KEY_WORD_DICT = {
"播放歌曲": "play",
"播放本地歌曲": "playlocal",
"关机": "stop",
"下一首": "play_next",
"单曲循环": "set_play_type_one",
"全部循环": "set_play_type_all",
"随机播放": "random_play",
"分钟后关机": "stop_after_minute",
"播放列表": "play_music_list",
"刷新列表": "gen_music_list",
"set_volume#": "set_volume",
"get_volume#": "get_volume",
}
def default_key_word_dict():
return {
"播放歌曲": "play",
"播放本地歌曲": "playlocal",
"关机": "stop",
"下一首": "play_next",
"单曲循环": "set_play_type_one",
"全部循环": "set_play_type_all",
"随机播放": "random_play",
"分钟后关机": "stop_after_minute",
"播放列表": "play_music_list",
"刷新列表": "gen_music_list",
"set_volume#": "set_volume",
"get_volume#": "get_volume",
}
def default_user_key_word_dict():
return {
"测试自定义口令": 'exec#code1("hello")',
"测试链接": 'exec#httpget("https://github.com/hanxi/xiaomusic")',
}
# 命令参数在前面
KEY_WORD_ARG_BEFORE_DICT = {
"分钟后关机": True,
}
# 口令匹配优先级
DEFAULT_KEY_MATCH_ORDER = [
"set_volume#",
"get_volume#",
"分钟后关机",
"播放歌曲",
"下一首",
"单曲循环",
"全部循环",
"随机播放",
"关机",
"刷新列表",
"播放列表",
]
def default_key_match_order():
return [
"set_volume#",
"get_volume#",
"分钟后关机",
"播放歌曲",
"下一首",
"单曲循环",
"全部循环",
"随机播放",
"关机",
"刷新列表",
"播放列表",
]
@dataclass
class Config:
hardware: str = os.getenv("MI_HARDWARE", "L07A")
account: str = os.getenv("MI_USER", "")
password: str = os.getenv("MI_PASS", "")
mi_did: str = os.getenv("MI_DID", "")
mi_did: str = os.getenv("MI_DID", "") # 逗号分割支持多设备
hardware: str = os.getenv("MI_HARDWARE", "L07A") # 逗号分割支持多设备
cookie: str = ""
verbose: bool = os.getenv("XIAOMUSIC_VERBOSE", "").lower() == "true"
music_path: str = os.getenv("XIAOMUSIC_MUSIC_PATH", "music")
music_path: str = os.getenv(
"XIAOMUSIC_MUSIC_PATH", "music"
) # 只能是music目录下的子目录
download_path: str = os.getenv("XIAOMUSIC_DOWNLOAD_PATH", "")
conf_path: str = os.getenv("XIAOMUSIC_CONF_PATH", None)
hostname: str = os.getenv("XIAOMUSIC_HOSTNAME", "192.168.2.5")
port: int = int(os.getenv("XIAOMUSIC_PORT", "8090")) # 监听端口
@@ -71,15 +86,15 @@ class Config:
disable_httpauth: bool = (
os.getenv("XIAOMUSIC_DISABLE_HTTPAUTH", "true").lower() == "true"
)
httpauth_username: str = os.getenv("XIAOMUSIC_HTTPAUTH_USERNAME", "admin")
httpauth_password: str = os.getenv("XIAOMUSIC_HTTPAUTH_PASSWORD", "admin")
httpauth_username: str = os.getenv("XIAOMUSIC_HTTPAUTH_USERNAME", "")
httpauth_password: str = os.getenv("XIAOMUSIC_HTTPAUTH_PASSWORD", "")
music_list_url: str = os.getenv("XIAOMUSIC_MUSIC_LIST_URL", "")
music_list_json: str = os.getenv("XIAOMUSIC_MUSIC_LIST_JSON", "")
disable_download: bool = (
os.getenv("XIAOMUSIC_DISABLE_DOWNLOAD", "false").lower() == "true"
)
key_word_dict = DEFAULT_KEY_WORD_DICT.copy()
key_match_order = DEFAULT_KEY_MATCH_ORDER.copy()
key_word_dict: dict[str, str] = field(default_factory=default_key_word_dict)
key_match_order: list[str] = field(default_factory=default_key_match_order)
use_music_api: bool = (
os.getenv("XIAOMUSIC_USE_MUSIC_API", "false").lower() == "true"
)
@@ -95,12 +110,16 @@ class Config:
os.getenv("XIAOMUSIC_ENABLE_FUZZY_MATCH", "true").lower() == "true"
)
stop_tts_msg: str = os.getenv("XIAOMUSIC_STOP_TTS_MSG", "收到,再见")
enable_config_example: bool = False
keywords_playlocal: str = os.getenv(
"XIAOMUSIC_KEYWORDS_PLAYLOCAL", "播放本地歌曲,本地播放歌曲"
)
keywords_play: str = os.getenv("XIAOMUSIC_KEYWORDS_PLAY", "播放歌曲,放歌曲")
keywords_stop: str = os.getenv("XIAOMUSIC_KEYWORDS_STOP", "关机,暂停,停止,停止播放")
user_key_word_dict: dict[str, str] = field(
default_factory=default_user_key_word_dict
)
def append_keyword(self, keys, action):
for key in keys.split(","):
@@ -108,17 +127,26 @@ class Config:
if key not in self.key_match_order:
self.key_match_order.append(key)
def append_user_keyword(self):
for k, v in self.user_key_word_dict.items():
self.key_word_dict[k] = v
self.key_match_order.append(k)
def __post_init__(self) -> None:
if self.proxy:
validate_proxy(self.proxy)
self.append_keyword(self.keywords_playlocal, "playlocal")
self.append_keyword(self.keywords_play, "play")
self.append_keyword(self.keywords_stop, "stop")
self.append_user_keyword()
# 保存配置到 config-example.json 文件
# with open("config-example.json", "w") as f:
# data = asdict(self)
# json.dump(data, f, ensure_ascii=False, indent=4)
if self.enable_config_example:
with open("config-example.json", "w") as f:
data = asdict(self)
json.dump(data, f, ensure_ascii=False, indent=4)
@classmethod
def from_options(cls, options: argparse.Namespace) -> Config:

View File

@@ -4,6 +4,7 @@ SUPPORT_MUSIC_TYPE = [
".wav",
".ape",
".ogg",
".m4a",
]
LATEST_ASK_API = "https://userprofile.mina.mi.com/device_profile/v2/conversation?source=dialogu&hardware={hardware}&timestamp={timestamp}&limit=2"

69
xiaomusic/plugin.py Normal file
View File

@@ -0,0 +1,69 @@
import importlib
import inspect
import pkgutil
class PluginManager:
def __init__(self, xiaomusic, plugin_dir="plugins"):
self.xiaomusic = xiaomusic
self.log = xiaomusic.log
self._funcs = {}
self._load_plugins(plugin_dir)
def _load_plugins(self, plugin_dir):
# 假设 plugins 已经在搜索路径上
package_name = plugin_dir
package = importlib.import_module(package_name)
# 遍历 package 中所有模块并动态导入它们
for _, modname, _ in pkgutil.iter_modules(package.__path__, package_name + "."):
# 跳过__init__文件
if modname.endswith("__init__"):
continue
module = importlib.import_module(modname)
# 将 log 和 xiaomusic 注入模块的命名空间
module.log = self.log
module.xiaomusic = self.xiaomusic
# 动态获取模块中与文件名同名的函数
function_name = modname.split(".")[-1] # 从模块全名提取函数名
if hasattr(module, function_name):
self._funcs[function_name] = getattr(module, function_name)
else:
self.log.error(
f"No function named '{function_name}' found in module {modname}"
)
def get_func(self, plugin_name):
"""根据插件名获取插件函数"""
return self._funcs.get(plugin_name)
def get_local_namespace(self):
"""返回包含所有插件函数的字典,可以用作 exec 要执行的代码的命名空间"""
return self._funcs.copy()
async def execute_plugin(self, code):
"""
执行指定的插件代码。插件函数可以是同步或异步。
:param code: 需要执行的插件函数代码(例如 'plugin1("hello")'
"""
# 分解代码字符串以获取函数名
func_name = code.split("(")[0]
# 根据解析出的函数名从插件字典中获取函数
plugin_func = self.get_func(func_name)
if not plugin_func:
raise ValueError(f"No plugin function named '{func_name}' found.")
# 检查函数是否是异步函数
global_namespace = globals().copy()
local_namespace = self.get_local_namespace()
if inspect.iscoroutinefunction(plugin_func):
# 如果是异步函数,构建执行用的协程对象
coroutine = eval(code, global_namespace, local_namespace)
# 等待协程执行
await coroutine
else:
# 如果是普通函数,直接执行代码
eval(code, global_namespace, local_namespace)

View File

@@ -27,13 +27,33 @@ function postJSON() {
}
});
}
</script>
function sendDebugCmd() {
var cmd = $("#cmd").val();
$.ajax({
type: "POST",
url: "/cmd",
contentType: "application/json; charset=utf-8",
data: JSON.stringify({cmd: cmd}),
success: () => {
},
error: () => {
// 请求失败时执行的操作
}
});
}
</script>
</head>
<body>
<h1>Debug For XiaoMusic</h1>
<textarea id="post-input" rows="10" cols="50" placeholder="粘贴json数据..."></textarea><br>
<button onclick="postJSON()">提交</button><br>
<hr>
<input id="cmd" type="text"></input>
<button onclick="sendDebugCmd()">测试自定义口令</button><br>
</body>
<footer>
<p>Powered by <a href="https://github.com/hanxi/xiaomusic" target="_blank">xiaomusic</a></p>
</footer>

View File

@@ -6,6 +6,14 @@
<script src="/static/jquery-3.7.1.min.js"></script>
<script src="/static/setting.js"></script>
<link rel="stylesheet" type="text/css" href="/static/style.css">
<!--
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
<script>
var vConsole = new window.VConsole();
</script>
-->
</head>
<body>
<h2>小爱音箱设置面板
@@ -14,6 +22,14 @@
</a>)
</h2>
<hr>
<div class="rows">
<label for="mi_did_hardware">*勾选设备(至少勾选1个):</label>
<div id="mi_did_hardware">
</div>
</div>
<br>
<div id="setting" class="rows">
<label for="account">*小米账号:</label>
<input id="account" type="text" placeholder="填写小米登录账号"></input>
@@ -21,12 +37,6 @@
<label for="password">*小米密码:</label>
<input id="password" type="password" placeholder="填写小米登录密码"></input>
<label for="mi_did">*MI_DID:</label>
<select id="mi_did"></select>
<label for="hardware">*MI_HARDWARE(型号):</label>
<select id="hardware" disabled></select>
<label for="hostname">*XIAOMUSIC_HOSTNAME(IP或域名):</label>
<input id="hostname" type="text"></input>
@@ -39,13 +49,16 @@
<label for="music_path">音乐目录:</label>
<input id="music_path" type="text" value="music"></input>
<label for="download_path">音乐下载目录(必须是music的子目录):</label>
<input id="download_path" type="text"></input>
<label for="conf_path">配置文件目录:</label>
<input id="conf_path" type="text"></input>
<label for="ffmpeg_location">ffmpeg路径:</label>
<input id="ffmpeg_location" type="text" value="./ffmpeg/bin"></input>
<label for="log_file">ffmpeg路径:</label>
<label for="log_file">日志路径:</label>
<input id="log_file" type="text" value="/tmp/xiaomusic.txt"></input>
<label for="active_cmd">允许唤醒的命令:</label>
@@ -72,9 +85,9 @@
<option value="false">false</option>
</select>
<label for="httpauth_username">web控制台账户:</label>
<input id="httpauth_username" type="text" value="admin"></input>
<input id="httpauth_username" type="text" value=""></input>
<label for="httpauth_password">web控制台密码:</label>
<input id="httpauth_password" type="password" value="admin"></input>
<input id="httpauth_password" type="password" value=""></input>
<label for="disable_download">关闭下载功能:</label>
<select id="disable_download">

View File

@@ -16,55 +16,76 @@ $(function(){
});
};
const updateSelectOptions = (selectId, optionsList, selectedOption) => {
const select = $(selectId);
select.empty();
optionsList.forEach(option => {
select.append(new Option(option, option));
function updateCheckbox(selector, mi_did_list, mi_did, mi_hardware_list) {
// 清除现有的内容
$(selector).empty();
// 将 mi_did 字符串通过逗号分割转换为数组,以便于判断默认选中项
var selected_dids = mi_did.split(',');
// 遍历传入的 mi_did_list 和 mi_hardware_list
$.each(mi_did_list, function(index, did) {
// 获取硬件标识,假定列表是一一对应的
var hardware = mi_hardware_list[index];
// 创建复选框元素
var checkbox = $('<input>', {
type: 'checkbox',
id: did,
value: `${did}|${hardware}`,
class: 'custom-checkbox', // 添加样式类
// 如果mi_did中包含了该did则默认选中
checked: selected_dids.indexOf(did) !== -1
});
// 创建标签元素
var label = $('<label>', {
for: did,
class: 'checkbox-label', // 添加样式类
text: `${hardware}${did}` // 设定标签内容为did和hardware的拼接
});
// 将复选框和标签添加到目标选择器元素中
$(selector).append(checkbox).append(label);
});
select.val(selectedOption);
};
}
let isChanging = false;
// 更新下拉菜单的函数
const updateSelect = (selectId, value) => {
if (!isChanging) {
isChanging = true;
$(selectId).val(value);
isChanging = false;
}
};
function getSelectedDidsAndHardware(containerSelector) {
var selectedDids = [];
var selectedHardware = [];
// 联动逻辑
const linkSelects = (sourceSelect, sourceList, targetSelect, targetList) => {
$(sourceSelect).change(function() {
if (!isChanging) {
const selectedValue = $(this).val();
const selectedIndex = sourceList.indexOf(selectedValue);
console.log(selectedIndex, selectedValue,sourceList,targetList)
if (selectedIndex !== -1) {
updateSelect(targetSelect, targetList[selectedIndex]);
}
}
// 仅选择给定容器中选中的复选框
$(containerSelector + ' .custom-checkbox:checked').each(function() {
// 解析当前复选框的值(值中包含了 did 和 hardware使用 '|' 分割)
var parts = this.value.split('|');
selectedDids.push(parts[0]);
selectedHardware.push(parts[1]);
});
};
// 返回包含 did_list 和 hardware_list 的对象
return {
did_list: selectedDids.join(','),
hardware_list: selectedHardware.join(',')
};
}
// 拉取现有配置
$.get("/getsetting", function(data, status) {
console.log(data, status);
updateSelectOptions("#mi_did", data.mi_did_list, data.mi_did);
updateSelectOptions("#hardware", data.mi_hardware_list, data.hardware);
// 初始化联动
linkSelects('#mi_did', data.mi_did_list, '#hardware', data.mi_hardware_list);
updateCheckbox("#mi_did_hardware", data.mi_did_list, data.mi_did, data.mi_hardware_list);
// 初始化显示
for (const key in data) {
if (data.hasOwnProperty(key) && data[key] != "") {
if (data.hasOwnProperty(key)) {
const $element = $("#" + key);
if ($element.length) {
$element.val(data[key]);
if (data[key] === true) {
$element.val('true');
} else if (data[key] === false) {
$element.val('false');
} else {
$element.val(data[key]);
}
}
}
}
@@ -82,6 +103,9 @@ $(function(){
data[id] = $(this).val();
}
});
var selectedData = getSelectedDidsAndHardware("#mi_did_hardware");
data["mi_did"] = selectedData.did_list;
data["hardware"] = selectedData.hardware_list;
console.log(data)
$.ajax({

View File

@@ -77,3 +77,41 @@ footer {
width: 300px;
height: 200px;
}
/* 隐藏原生复选框 */
.custom-checkbox {
display: none;
}
/* 修改后的自定义复选框外观 */
.checkbox-label {
display: inline-block;
width: 200px; /* 宽度 */
height: 20px; /* 高度 */
background-color: #fff; /* 背景颜色 */
border: 0px solid #ccc; /* 边框 */
border-radius: 3px; /* 圆角边框 */
position: relative; /* 设置相对定位 */
cursor: pointer; /* 鼠标形状 */
padding-left: 40px; /* 给左边的复选框图标留下空位 */
}
/* 对勾的样式 */
.custom-checkbox:checked + .checkbox-label::after {
content: '✔';
position: absolute;
left: 10px; /* 对勾图标靠左侧位置 */
color: #000; /* 对勾颜色 */
font-size: 18px; /* 对勾字体大小,视清晰度需调整 */
}
/* 标签文本样式,使用 ::before 伪元素表示复选框未选中时的样式 */
.custom-checkbox + .checkbox-label::before {
content: '⬜'; /* 表示未选中时的复选框样式,这里用了白色方块 */
position: absolute;
left: 8px; /* 方块图标靠左侧位置 */
top: 1px; /* 方块图标顶部位置 */
color: #000; /* 方块颜色 */
font-size: 18px; /* 方块字体大小,视清晰度需调整 */
}

View File

@@ -13,7 +13,7 @@ from logging.handlers import RotatingFileHandler
from pathlib import Path
from aiohttp import ClientSession, ClientTimeout
from miservice import MiAccount, MiIOService, MiNAService
from miservice import MiAccount, MiNAService
from xiaomusic import (
__version__,
@@ -28,6 +28,7 @@ from xiaomusic.const import (
SUPPORT_MUSIC_TYPE,
)
from xiaomusic.httpserver import StartHTTPServer
from xiaomusic.plugin import PluginManager
from xiaomusic.utils import (
custom_sort_key,
deepcopy_data_no_sensitive_info,
@@ -54,12 +55,12 @@ class XiaoMusic:
self.last_timestamp = int(time.time() * 1000) # timestamp last call mi speaker
self.last_record = None
self.cookie_jar = None
self.device_id = ""
self.mina_service = None
self.miio_service = None
self.polling_event = asyncio.Event()
self.new_record_event = asyncio.Event()
self.queue = queue.Queue()
self.device2hardware = {}
self.did2device = {}
# 下载对象
self.download_proc = None
@@ -94,6 +95,9 @@ class XiaoMusic:
# 启动时初始化获取声音
self.set_last_record("get_volume#")
# 初始化插件
self.plugin_manager = PluginManager(self)
debug_config = deepcopy_data_no_sensitive_info(self.config)
self.log.info(f"Startup OK. {debug_config}")
@@ -102,6 +106,12 @@ class XiaoMusic:
self.conf_path = self.config.conf_path
if not self.conf_path:
self.conf_path = self.config.music_path
self.download_path = self.config.download_path
if not self.download_path:
self.download_path = self.music_path
if not os.path.exists(self.download_path):
os.makedirs(self.download_path)
self.hostname = self.config.hostname
self.port = self.config.port
@@ -141,12 +151,14 @@ class XiaoMusic:
async def poll_latest_ask(self):
async with ClientSession() as session:
session._cookie_jar = self.cookie_jar
while True:
self.log.debug(
"Listening new message, timestamp: %s", self.last_timestamp
)
await self.get_latest_ask_from_xiaoai(session)
session._cookie_jar = self.cookie_jar
# 拉取所有音箱的对话记录
for device_id in self.device2hardware:
await self.get_latest_ask_from_xiaoai(session, device_id)
start = time.perf_counter()
self.log.debug("Polling_event, timestamp: %s", self.last_timestamp)
await self.polling_event.wait()
@@ -157,7 +169,7 @@ class XiaoMusic:
async def init_all_data(self, session):
await self.login_miboy(session)
await self._init_data_hardware()
await self.try_update_device_id()
cookie_jar = self.get_cookie()
if cookie_jar:
session.cookie_jar.update_cookies(cookie_jar)
@@ -173,61 +185,26 @@ class XiaoMusic:
# Forced login to refresh to refresh token
await account.login("micoapi")
self.mina_service = MiNAService(account)
self.miio_service = MiIOService(account)
async def try_update_device_id(self):
# fix multi xiaoai problems we check did first
# why we use this way to fix?
# some videos and articles already in the Internet
# we do not want to change old way, so we check if miotDID in `env` first
# to set device id
try:
mi_dids = self.config.mi_did.split(",")
hardware_data = await self.mina_service.device_list()
self.device2hardware = {}
self.did2device = {}
for h in hardware_data:
if did := self.config.mi_did:
if h.get("miotDID", "") == str(did):
self.device_id = h.get("deviceID")
break
else:
continue
if h.get("hardware", "") == self.config.hardware:
self.device_id = h.get("deviceID")
break
else:
self.log.error(
f"we have no hardware: {self.config.hardware} please use `micli mina` to check"
)
device = h.get("deviceID", "")
hardware = h.get("hardware", "")
did = h.get("miotDID", "")
if device and hardware and did and (did in mi_dids):
self.device2hardware[device] = hardware
self.did2device[did] = device
except Exception as e:
self.log.error(f"Execption {e}")
async def _init_data_hardware(self):
if self.config.cookie:
# if use cookie do not need init
return
await self.try_update_device_id()
if not self.config.mi_did:
devices = await self.miio_service.device_list()
try:
self.config.mi_did = next(
d["did"]
for d in devices
if d["model"].endswith(self.config.hardware.lower())
)
except StopIteration:
self.log.error(
f"cannot find did for hardware: {self.config.hardware} "
"please set it via MI_DID env"
)
except Exception as e:
self.log.error(f"Execption init hardware {e}")
def get_cookie(self):
if self.config.cookie:
cookie_jar = parse_cookie_string(self.config.cookie)
# set attr from cookie fix #134
cookie_dict = cookie_jar.get_dict()
self.device_id = cookie_dict["deviceId"]
return cookie_jar
if not os.path.exists(self.mi_token_home):
@@ -238,12 +215,18 @@ class XiaoMusic:
user_data = json.loads(f.read())
user_id = user_data.get("userId")
service_token = user_data.get("micoapi")[1]
device_id = self.get_one_device()
cookie_string = COOKIE_TEMPLATE.format(
device_id=self.device_id, service_token=service_token, user_id=user_id
device_id=device_id, service_token=service_token, user_id=user_id
)
return parse_cookie_string(cookie_string)
async def get_latest_ask_from_xiaoai(self, session):
def get_one_device(self):
device_id = next(iter(self.device2hardware), "")
return device_id
async def get_latest_ask_from_xiaoai(self, session, device_id):
cookies = {"deviceId": device_id}
retries = 3
for i in range(retries):
try:
@@ -253,7 +236,7 @@ class XiaoMusic:
timestamp=str(int(time.time() * 1000)),
)
self.log.debug(f"url:{url}")
r = await session.get(url, timeout=timeout)
r = await session.get(url, timeout=timeout, cookies=cookies)
except Exception as e:
self.log.warning(
"Execption when get latest ask from xiaoai: %s", str(e)
@@ -298,16 +281,21 @@ class XiaoMusic:
return
await self.force_stop_xiaoai()
try:
await self.mina_service.text_to_speech(self.device_id, value)
except Exception as e:
self.log.error(f"Execption {e}")
await self.text_to_speech(value)
# 最大等8秒
sec = min(8, int(len(value) / 3))
await asyncio.sleep(sec)
self.log.info(f"do_tts ok. cur_music:{self.cur_music}")
await self.check_replay()
async def text_to_speech(self, value):
try:
for device_id in self.device2hardware:
await self.mina_service.text_to_speech(device_id, value)
except Exception as e:
self.log.error(f"Execption {e}")
# 继续播放被打断的歌曲
async def check_replay(self):
if self.isplaying() and not self.isdownloading():
@@ -323,13 +311,17 @@ class XiaoMusic:
value = int(value)
self._volume = value
self.log.info(f"声音设置为{value}")
await self.player_set_volume(value)
async def player_set_volume(self, value):
try:
await self.mina_service.player_set_volume(self.device_id, value)
for device_id in self.device2hardware:
await self.mina_service.player_set_volume(device_id, value)
except Exception as e:
self.log.error(f"Execption {e}")
async def get_if_xiaoai_is_playing(self):
playing_info = await self.mina_service.player_get_status(self.device_id)
async def get_if_xiaoai_is_playing(self, device_id):
playing_info = await self.mina_service.player_get_status(device_id)
self.log.info(playing_info)
# WTF xiaomi api
is_playing = (
@@ -338,17 +330,25 @@ class XiaoMusic:
)
return is_playing
async def stop_if_xiaoai_is_playing(self):
is_playing = await self.get_if_xiaoai_is_playing()
async def stop_if_xiaoai_is_playing(self, device_id):
is_playing = await self.get_if_xiaoai_is_playing(device_id)
if is_playing:
# stop it
ret = await self.mina_service.player_stop(self.device_id)
self.log.info(f"force_stop_xiaoai player_stop ret:{ret}")
ret = await self.mina_service.player_stop(device_id)
self.log.info(
f"force_stop_xiaoai player_stop device_id:{device_id} ret:{ret}"
)
async def force_stop_xiaoai(self):
ret = await self.mina_service.player_pause(self.device_id)
self.log.info(f"force_stop_xiaoai player_pause ret:{ret}")
await self.stop_if_xiaoai_is_playing()
try:
for device_id in self.device2hardware:
ret = await self.mina_service.player_pause(device_id)
self.log.info(
f"force_stop_xiaoai player_pause device_id:{device_id} ret:{ret}"
)
await self.stop_if_xiaoai_is_playing(device_id)
except Exception as e:
self.log.error(f"Execption {e}")
# 是否在下载中
def isdownloading(self):
@@ -379,7 +379,7 @@ class XiaoMusic:
"--audio-format",
"mp3",
"--paths",
self.music_path,
self.download_path,
"-o",
f"{name}.mp3",
"--ffmpeg-location",
@@ -557,7 +557,7 @@ class XiaoMusic:
# 把下载的音乐加入播放列表
def add_download_music(self, name):
self._all_music[name] = os.path.join(self.music_path, f"{name}.mp3")
self._all_music[name] = os.path.join(self.download_path, f"{name}.mp3")
if name not in self._play_list:
self._play_list.append(name)
self.log.debug("add_music %s", name)
@@ -640,13 +640,13 @@ class XiaoMusic:
self.log.info("收到消息:%s 控制面板:%s", query, ctrl_panel)
# 匹配命令
opvalue, oparg = self.match_cmd(query, ctrl_panel)
if not opvalue:
await asyncio.sleep(1)
await self.check_replay()
continue
try:
opvalue, oparg = self.match_cmd(query, ctrl_panel)
if not opvalue:
await asyncio.sleep(1)
await self.check_replay()
continue
func = getattr(self, opvalue)
await func(arg1=oparg)
except Exception as e:
@@ -670,6 +670,10 @@ class XiaoMusic:
opvalue = self.check_full_match_cmd(query, ctrl_panel)
if opvalue:
self.log.info(f"完全匹配指令. query:{query} opvalue:{opvalue}")
# 自定义口令
if opvalue.startswith("exec#"):
code = opvalue.split("#", 1)[1]
return ("exec", code)
return (opvalue, "")
for opkey in self.config.key_match_order:
@@ -714,17 +718,23 @@ class XiaoMusic:
async def play_url(self, **kwargs):
url = kwargs.get("arg1", "")
if self.config.use_music_api:
ret = await self.play_by_music_url(self.device_id, url)
self.log.info(
f"play_url play_by_music_url {self.config.hardware}. ret:{ret} url:{url}"
)
else:
ret = await self.mina_service.play_by_url(self.device_id, url)
self.log.info(
f"play_url play_by_url {self.config.hardware}. ret:{ret} url:{url}"
)
return ret
await self.all_player_play(url)
async def all_player_play(self, url):
try:
for device_id in self.device2hardware:
if self.config.use_music_api:
ret = await self.play_by_music_url(device_id, url)
self.log.info(
f"player_play play_by_music_url device_id:{device_id} ret:{ret} url:{url}"
)
else:
ret = await self.mina_service.play_by_url(device_id, url)
self.log.info(
f"player_play play_by_url device_id:{device_id} ret:{ret} url:{url}"
)
except Exception as e:
self.log.error(f"Execption {e}")
def find_real_music_name(self, name):
if not self.config.enable_fuzzy_match:
@@ -919,7 +929,9 @@ class XiaoMusic:
await self.do_set_volume(value)
async def get_volume(self, **kwargs):
playing_info = await self.mina_service.player_get_status(self.device_id)
# 取一个音箱的声音
device_id = self.get_one_device()
playing_info = await self.mina_service.player_get_status(device_id)
self.log.debug("get_volume. playing_info:%s", playing_info)
self._volume = json.loads(playing_info.get("data", {}).get("info", "{}")).get(
"volume", 0
@@ -1089,10 +1101,15 @@ class XiaoMusic:
if arg1 is None:
arg1 = {}
data = arg1
self.log.info(f"debug_play_by_music_url: {data}")
device_id = self.get_one_device()
self.log.info(f"debug_play_by_music_url: {data} {device_id}")
return await self.mina_service.ubus_request(
self.device_id,
device_id,
"player_play_music",
"mediaplayer",
data,
)
async def exec(self, arg1=None):
code = arg1 if arg1 else 'code1("hello")'
await self.plugin_manager.execute_plugin(code)