1
0
mirror of https://github.com/hanxi/xiaomusic.git synced 2025-12-07 15:02:55 +08:00

Compare commits

...

91 Commits

Author SHA1 Message Date
涵曦
d6ba656641 new version v0.1.94 2024-07-06 11:25:33 +00:00
涵曦
742cae0543 style: 调整调试日志输出 2024-07-06 11:19:50 +00:00
涵曦
a4ab1af160 fix: 新增参数配置强制打断小爱说话 2024-07-06 11:16:42 +00:00
涵曦
86f158532a fix: 修复多设备获取对话记录的问题 2024-07-06 11:00:47 +00:00
Zhang Tinmix
9ea7935cfb fix: 修复windows下路径分隔符被视为转移符导致音箱无法播放音乐的问题
由于Windows路径中使用反斜杠(\)作为目录分隔符,而在URL编码中反斜杠被视为特殊字符所导致windows下拼接得到的url不符合预期
示例
filename:music\outside.mp3
预期:
url: /music/outside.mp3
实际:
url:/music%5Coutside.mp3

需要一个办法来处理windows下的\\路径分隔符,我给出了一种简单的解法,作者可以考虑看看怎么处理会完全一点
2024-07-06 11:00:40 +00:00
涵曦
20c7e14076 fix: 修复播放链接报错 2024-07-06 03:50:47 +00:00
涵曦
e10f5b89b6 fix: 修复配置页面默认配置被置空的问题 2024-07-06 00:32:10 +00:00
涵曦
cb0bae5ae7 feat: 优化多设备接口执行效果,尽量做到同时执行 2024-07-05 17:06:13 +00:00
涵曦
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
涵曦
77a37a9438 new version v0.1.92 2024-07-04 11:12:00 +00:00
涵曦
f22de9f906 Update README.md 2024-07-04 19:11:03 +08:00
涵曦
090e8c3f4c feat: 启动参数新增 --port 配置监听端口 2024-07-04 10:42:31 +00:00
涵曦
aef51fb65d Update README.md 2024-07-04 18:32:15 +08:00
涵曦
a5a3a2dc62 Update README.md 2024-07-04 18:26:38 +08:00
涵曦
ce9adcee7f feat: 外网访问端口可独立配置 2024-07-04 10:15:48 +00:00
涵曦
485a42a9a0 feat: 优化设置页面,新增更多配置项 2024-07-04 09:36:06 +00:00
涵曦
3754970c84 feat: 首次保存设置后不需要重启容器 2024-07-04 04:42:19 +00:00
涵曦
924fbc208b fix: 日志文件配置的环境变量写错了 2024-07-04 04:03:23 +00:00
涵曦
c1a2ee4577 new version v0.1.91 2024-07-03 16:08:00 +00:00
涵曦
e84ee5de1e feat:触屏版显示界面的歌曲id支持配置 2024-07-03 13:48:52 +00:00
涵曦
0414830539 fix: 尝试解决触屏版不能播放的问题 2024-07-03 13:48:52 +00:00
涵曦
385f23842d Update README.md 2024-07-03 12:05:01 +08:00
涵曦
1deceaa5a5 Update README.md 2024-07-03 01:02:26 +08:00
涵曦
b8f1157e27 new version v0.1.90 2024-07-02 16:33:30 +00:00
涵曦
06558c24b7 feat: 优化触屏版播放页面显示歌曲 2024-07-02 16:33:06 +00:00
涵曦
6bd399b654 new version v0.1.89 2024-07-02 16:12:14 +00:00
涵曦
228d89f1f8 fix: 播放歌曲写成固定的了 2024-07-02 15:55:02 +00:00
涵曦
e97639302f feat: 尝试解决触屏版无法播放的问题 2024-07-02 13:40:56 +00:00
涵曦
7f4e51be08 fix: 播放歌曲时被其他指令打断后没有继续播放 2024-07-02 13:25:38 +00:00
涵曦
cdab5fc92d new version v0.1.88 2024-07-02 09:40:22 +00:00
涵曦
6efe498f2a feat: 日志里不要输出敏感信息 2024-07-02 05:27:17 +00:00
涵曦
0f3f2e47f5 feat: 优化下载 ffmpeg 脚本,尝试解决 armv7 环境问题 2024-07-02 05:24:29 +00:00
涵曦
3b720b7367 fix: 是否下载中判断错误导致播放无法自动重新开始播放 2024-07-02 03:29:01 +00:00
涵曦
9a3e513b6c fix: 升级yt-dlp到2024.07.01 2024-07-02 03:28:31 +00:00
涵曦
5a8e5dfa82 feat: 优化日志输出信息 2024-07-02 03:10:33 +00:00
涵曦
70d9ad93cb fix: 修复部分型号关机失败的问题 2024-07-01 16:57:04 +00:00
涵曦
87b3411f5e feat: 尝试解决触屏版无法播放的问题 2024-07-01 15:28:57 +00:00
涵曦
5c88c79ac6 new version v0.1.87 2024-07-01 14:43:59 +00:00
涵曦
5df91e7a59 fix: 修复XIAOMUSIC_USE_MUSIC_API=true时播放不了的问题 2024-07-01 14:42:20 +00:00
涵曦
71e9c15b5d new version v0.1.86 2024-07-01 13:34:38 +00:00
涵曦
c151144a5a Update README.md 2024-07-01 20:28:45 +08:00
涵曦
82a3373e72 Update README.md 2024-07-01 20:24:43 +08:00
涵曦
29ef5f238f feat: 优化 ffmpeg 安装脚本 2024-07-01 11:47:31 +00:00
涵曦
1809a2ab54 fix: 尝试修复 armv7 的 ffmpeg 问题 2024-07-01 11:14:55 +00:00
涵曦
3b1684f553 feat: 新增调试工具用来调试 player_play_music 接口 2024-07-01 11:08:54 +00:00
涵曦
d088374333 Update README.md 2024-07-01 09:15:07 +08:00
涵曦
80da6bd1e6 fix: 尝试修复关机失败的问题 2024-07-01 01:11:35 +00:00
涵曦
619bb9c853 fix: 修复口令不能播放的问题 2024-07-01 01:11:35 +00:00
涵曦
5add7b7a5c feat: 升级依赖库 MiService 2024-07-01 01:11:35 +00:00
涵曦
f61f14e16c Update README.md 2024-07-01 05:30:21 +08:00
leic4u
125421db22 为支持的设备增加产品名称和官方产品百科链接
为支持的设备增加产品名称和官方产品百科链接
2024-07-01 05:23:47 +08:00
涵曦
98b73f72df new version v0.1.85 2024-06-30 10:20:06 +00:00
涵曦
e68bc3b937 fix: 修复电台删除后没有从电台列表中删除的问题 2024-06-30 10:19:53 +00:00
涵曦
ab447a4633 feat: 版本号链接到github的release页面,方便查看版本更新日志 2024-06-30 10:16:58 +00:00
涵曦
23d321a722 new version v0.1.84 2024-06-30 09:44:25 +00:00
涵曦
20945954b1 feat: config.json 支持更多配置选项 2024-06-30 09:43:51 +00:00
涵曦
d6c2078917 docs: 文档更新 2024-06-30 09:43:28 +00:00
涵曦
a5b8dc639c feat: 新增 XIAOMUSIC_STOP_TTS_MSG 配置关机提示音 2024-06-30 07:32:33 +00:00
涵曦
84751e0d68 new version v0.1.83 2024-06-30 06:38:09 +00:00
涵曦
e759658481 bugfix: pip安装运行名字错误 2024-06-30 06:38:04 +00:00
涵曦
d83100588f new version v0.1.82 2024-06-30 06:34:03 +00:00
涵曦
83d0e02eb4 feat: 优化指令匹配规则 2024-06-30 06:33:53 +00:00
涵曦
20f1f33b6c update readme 2024-06-30 05:53:54 +00:00
涵曦
fbb5d26c28 new version v0.1.81 2024-06-30 05:42:26 +00:00
涵曦
2c21778675 update project 2024-06-30 05:40:08 +00:00
涵曦
959acd8fb7 优化关机提示 2024-06-30 05:27:44 +00:00
涵曦
148c5b7621 命令行新增LOGO 2024-06-30 05:19:38 +00:00
涵曦
d7a2afba48 Update README.md 2024-06-30 12:06:01 +08:00
涵曦
559ed23214 Update README.md 2024-06-30 12:05:10 +08:00
涵曦
1d1e63df8a Update README.md 2024-06-30 12:04:29 +08:00
涵曦
27e9d92a0a 提交config.json模板文件 2024-06-30 03:54:39 +00:00
涵曦
69573f3fa4 new version v0.1.80 2024-06-30 01:07:29 +00:00
涵曦
edafd79140 fix: #91 修复下载歌曲报错 2024-06-30 01:07:25 +00:00
25 changed files with 1122 additions and 374 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

@@ -1,4 +1,5 @@
FROM python:3.10 AS builder
ENV DEBIAN_FRONTEND=noninteractive
WORKDIR /app
COPY requirements.txt .
RUN python3 -m venv .venv && .venv/bin/pip install --no-cache-dir -r requirements.txt
@@ -10,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

102
README.md
View File

@@ -13,6 +13,8 @@
<https://github.com/hanxi/xiaomusic>
> 初次安装遇到问题请查阅 <https://github.com/hanxi/xiaomusic/issues/99> 上是否已经有解决办法。
## 最简配置运行
已经支持在 web 页面配置其他参数docker compose 配置如下:
@@ -27,28 +29,22 @@ services:
- 8090:8090
volumes:
- ./music:/app/music
environment:
MI_USER: '小米账号'
MI_PASS: '小米密码'
XIAOMUSIC_VERBOSE: 'true'
XIAOMUSIC_HOSTNAME: 'docker 主机 ip'
```
对应的 docker 启动命令如下:
```yaml
docker run -e MI_USER='小米账号' \
-e MI_PASS='小米密码' \
-e XIAOMUSIC_VERBOSE='true' \
-e XIAOMUSIC_HOSTNAME='docker 主机 ip' \
-p 8090:8090 \
docker run -p 8090:8090 \
-v ./music:/app/music \
hanxi/xiaomusic
```
启动成功后,在 web 页面可以配置 MI_DID, MI_HARDWARE, XIAOMUSIC_SEARCH, XIAOMUSIC_PROXY 参数
启动成功后,在 web 页面可以配置其他参数,带有 `*` 号的配置是必须要配置的,其他的用不上时不用修改
### ✨ 修改8090端口
如果需要修改 8090 端口为其他端口,比如 5678需要这样配3个数字都需要是 5678 。见 <https://github.com/hanxi/xiaomusic/issues/19>
如果需要修改 8090 端口为其他端口,比如 5678需要这样配3个数字都需要是 5678
```yaml
services:
xiaomusic:
@@ -60,14 +56,46 @@ services:
volumes:
- ./music:/app/music
environment:
MI_USER: '小米账号'
MI_PASS: '小米密码'
XIAOMUSIC_VERBOSE: 'true'
XIAOMUSIC_HOSTNAME: 'docker 主机 ip'
XIAOMUSIC_PORT: 5678
```
其中 XIAOMUSIC_VERBOSE 设置为 'true' 时表示开启 debug 日志,遇到问题可以去 web 设置页面底部【下载日志文件】按钮,然后搜索一下日志文件内容确保里面没有账号密码信息后(有就删除这些敏感信息),然后在提 issues 反馈问题时把下载的日志文件带上。
遇到问题可以去 web 设置页面底部点击【下载日志文件】按钮,然后搜索一下日志文件内容确保里面没有账号密码信息后(有就删除这些敏感信息),然后在提 issues 反馈问题时把下载的日志文件带上。
> 目前除了 XIAOMUSIC_PORT 只能在启动前配置,其他参数都可以在 web 网页里配置。
## pip 方式安装运行
```shell
> pip install xiaomusic
> xiaomusic --help
__ __ _ __ __ _
\ \/ / (_) __ _ ___ | \/ | _ _ ___ (_) ___
\ / | | / _` | / _ \ | |\/| | | | | | / __| | | / __|
/ \ | | | (_| | | (_) | | | | | | |_| | \__ \ | | | (__
/_/\_\ |_| \__,_| \___/ |_| |_| \__,_| |___/ |_| \___|
XiaoMusic v0.1.92 by: github.com/hanxi
usage: xiaomusic [-h] [--port PORT] [--hardware HARDWARE] [--account ACCOUNT]
[--password PASSWORD] [--cookie COOKIE] [--verbose]
[--config CONFIG] [--ffmpeg_location FFMPEG_LOCATION]
options:
-h, --help show this help message and exit
--port PORT 监听端口
--hardware HARDWARE 小爱音箱型号
--account ACCOUNT xiaomi account
--password PASSWORD xiaomi password
--cookie COOKIE xiaomi cookie
--verbose show info
--config CONFIG config file path
--ffmpeg_location FFMPEG_LOCATION
ffmpeg bin path
> xiaomusic --config config.json
```
其中 `config.json` 文件可以参考 `config-example.json` 文件配置。见 <https://github.com/hanxi/xiaomusic/issues/94>
不修改默认端口 8090 的情况下,只需要执行 `xiaomusic` 即可启动。
## 开发环境运行
@@ -105,24 +133,24 @@ pdm run xiaomusic.py
## 已测试支持的设备
```txt
- L06A
- L07A
- S12
- S12A
- LX5A
- LX05
- L16A
- L17A
- LX06
- LX01
- L05B
- L05C
````
| 型号 | 名称 |
| ---- | ---------------------------------------------------------------------------------------------- |
| 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/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) |
| L17A | [Xiaomi Sound Pro](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.l17a) |
| LX06 | [小爱音箱Pro](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.lx06) |
| LX01 | [小爱音箱mini](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.lx01) |
| L05B | [小爱音箱Play](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.l05b) |
| L05C | [小米小爱音箱Play 增强版](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.l05c) |
型号与产品名称对照可以在这里查询 <https://home.miot-spec.com/s/xiaomi.wifispeaker>
> 如果你的设备支持播放,请反馈给我添加到支持列表里,谢谢。
> 目前应该所有设备类型都已经支持播放,有问题随时反馈。
## 支持音乐格式
@@ -131,6 +159,7 @@ pdm run xiaomusic.py
- wav
- ape
- ogg
- m4a
> 本地音乐会搜索目录下上面格式的文件,下载的歌曲是 mp3 格式的。
> 已知 L05B L05C 不支持 flac 格式。
@@ -287,12 +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 接口播放音乐,用于兼容不能播放的型号
- 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)
## 讨论区
@@ -311,6 +347,8 @@ services:
- [NAS部署教程](https://post.m.smzdm.com/p/avpe7n99/)
- [群晖部署教程](https://post.m.smzdm.com/p/a7px7dol/)
- [QNAS部署教程](https://post.smzdm.com/p/a5xz5x63/)
- 所有帮忙调试和测试的朋友
- 所有反馈问题和建议的朋友
## Star History

81
config-example.json Normal file
View File

@@ -0,0 +1,81 @@
{
"hardware": "L07A",
"account": "",
"password": "",
"mi_did": "",
"cookie": "",
"verbose": false,
"music_path": "music",
"conf_path": null,
"hostname": "192.168.2.5",
"port": 8090,
"public_port": 0,
"proxy": null,
"search_prefix": "bilisearch:",
"ffmpeg_location": "./ffmpeg/bin",
"active_cmd": "play,random_play,playlocal,play_music_list,stop",
"exclude_dirs": "@eaDir",
"music_path_depth": 10,
"disable_httpauth": true,
"httpauth_username": "admin",
"httpauth_password": "admin",
"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": "关机,暂停,停止,停止播放",
"user_key_word_dict": {
"测试自定义口令": "exec#code1(\"hello\")",
"测试链接": "exec#httpget(\"https://github.com/hanxi/xiaomusic\")"
}
}

View File

@@ -4,14 +4,47 @@
# https://github.com/yt-dlp/yt-dlp#dependencies
# 判断系统架构
arch=$(arch)
arch=$(uname -m)
pkg=ffmpeg-master-latest-linuxarm64-gpl
if [[ "${arch}" == "x86_64" ]]; then
pkg=ffmpeg-master-latest-linux64-gpl
fi
# 输出架构信息
echo "当前系统架构是:$arch"
#export ALL_PROXY=http://192.168.2.5:8080
wget https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/$pkg.tar.xz
tar -xvJf $pkg.tar.xz
mv $pkg ffmpeg
install_from_github() {
pkg=$1
wget https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/$pkg.tar.xz
tar -xvJf $pkg.tar.xz
mkdir -p ffmpeg/bin
mv $pkg/bin/ffmpeg ffmpeg/bin/
mv $pkg/bin/ffprobe ffmpeg/bin/
}
install_from_ffmpeg() {
pkg=$1
wget https://johnvansickle.com/ffmpeg/builds/$pkg.tar.xz
mkdir -p $pkg
tar -xvJf $pkg.tar.xz -C $pkg
mkdir -p ffmpeg/bin
mv $pkg/*/ffmpeg ffmpeg/bin/
mv $pkg/*/ffprobe ffmpeg/bin/
}
# 基于架构执行不同的操作
case "$arch" in
x86_64)
echo "64位 x86 架构"
install_from_github ffmpeg-master-latest-linux64-gpl
#install_from_ffmpeg ffmpeg-git-amd64-static
;;
arm64 | aarch64)
echo "64位 ARM 架构"
install_from_github ffmpeg-master-latest-linuxarm64-gpl
#install_from_ffmpeg ffmpeg-git-arm64-static
;;
armv7l)
echo "armv7l 架构"
install_from_ffmpeg ffmpeg-git-armhf-static
;;
*)
echo "未知架构 $arch"
;;
esac

14
pdm.lock generated
View File

@@ -5,7 +5,7 @@
groups = ["default", "lint"]
strategy = ["cross_platform"]
lock_version = "4.4.1"
content_hash = "sha256:813253734c7d7835a76cd87fe8fe0329e02ad067f535aee6a9e11cb106569dd2"
content_hash = "sha256:ac53cf6421de7aded8475907adc40a716a3e5c6429c614b93e5cfbddea36d048"
[[package]]
name = "aiohttp"
@@ -519,7 +519,7 @@ files = [
[[package]]
name = "miservice-fork"
version = "2.6.0"
version = "2.6.1"
requires_python = ">=3.8"
summary = "XiaoMi Cloud Service fork from https://github.com/Yonsm/MiService"
dependencies = [
@@ -528,8 +528,8 @@ dependencies = [
"rich",
]
files = [
{file = "miservice_fork-2.6.0-py3-none-any.whl", hash = "sha256:98169a77ea41a7b9392e1b1fab8cb80a4165fed8a9e882d9ada9a16dd1120347"},
{file = "miservice_fork-2.6.0.tar.gz", hash = "sha256:a59d337d1f7a92566aa147e96595a8d2f5bf3f7000ae5e7dd9ed451f18d6e2fd"},
{file = "miservice_fork-2.6.1-py3-none-any.whl", hash = "sha256:9b2cc4208486bbbf788d1bde6e2cbc70f241ce10db4dca6f918076a2d2942a39"},
{file = "miservice_fork-2.6.1.tar.gz", hash = "sha256:1702281e1e9827958eb3e82bc3242cd013c018e9aa1de8509b4805b5ccf5e60c"},
]
[[package]]
@@ -832,7 +832,7 @@ files = [
[[package]]
name = "yt-dlp"
version = "2024.6.24.232830.dev0"
version = "2024.7.1.232715.dev0"
requires_python = ">=3.8"
summary = "A feature-rich command-line audio/video downloader"
dependencies = [
@@ -846,6 +846,6 @@ dependencies = [
"websockets>=12.0",
]
files = [
{file = "yt_dlp-2024.6.24.232830.dev0-py3-none-any.whl", hash = "sha256:efffecef44ce688e9ee3c02226eb1ba4ad64b37744726e9e4df5c2bd04ea93c5"},
{file = "yt_dlp-2024.6.24.232830.dev0.tar.gz", hash = "sha256:0e89b46958984954393692a8c41e0f6d76a773be2df381c3d3a4ff24ce89aa32"},
{file = "yt_dlp-2024.7.1.232715.dev0-py3-none-any.whl", hash = "sha256:e9ab443353da0c8f01587b031fb84b2cc42eae82aeaa03a9ce5ed6edc301b503"},
{file = "yt_dlp-2024.7.1.232715.dev0.tar.gz", hash = "sha256:4f1ab25318c9156cca0b7308bdd2aeb3e7f01e8d9fb83916b4719010038170c8"},
]

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,16 +1,15 @@
[project]
name = "xiaomusic"
version = "0.1.79"
version = "0.1.94"
description = "Play Music with xiaomi AI speaker"
authors = [
{name = "涵曦", email = "im.hanxi@gmail.com"},
]
dependencies = [
"requests>=2.31.0",
"aiohttp>=3.8.6",
"miservice-fork>=2.5.0",
"mutagen>=1.47.0",
"yt-dlp>=2024.04.09",
"yt-dlp>=2024.07.01",
"flask[async]>=3.0.1",
"waitress>=3.0.0",
"flask-HTTPAuth>=4.8.0",
@@ -19,6 +18,12 @@ requires-python = ">=3.10"
readme = "README.md"
license = {text = "MIT"}
[project.urls]
Homepage = "https://github.com/hanxi/xiaomusic"
[project.scripts]
xiaomusic = "xiaomusic.cli:main"
[build-system]
requires = ["pdm-backend"]
build-backend = "pdm.backend"

View File

@@ -305,9 +305,9 @@ MarkupSafe==2.1.4 \
mdurl==0.1.2 \
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
miservice-fork==2.6.0 \
--hash=sha256:98169a77ea41a7b9392e1b1fab8cb80a4165fed8a9e882d9ada9a16dd1120347 \
--hash=sha256:a59d337d1f7a92566aa147e96595a8d2f5bf3f7000ae5e7dd9ed451f18d6e2fd
miservice-fork==2.6.1 \
--hash=sha256:1702281e1e9827958eb3e82bc3242cd013c018e9aa1de8509b4805b5ccf5e60c \
--hash=sha256:9b2cc4208486bbbf788d1bde6e2cbc70f241ce10db4dca6f918076a2d2942a39
multidict==6.0.4 \
--hash=sha256:01a3a55bd90018c9c080fbb0b9f4891db37d148a0a18722b42f94694f8b6d4c9 \
--hash=sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8 \
@@ -472,6 +472,6 @@ yarl==1.9.2 \
--hash=sha256:d4e2c6d555e77b37288eaf45b8f60f0737c9efa3452c6c44626a5455aeb250b9 \
--hash=sha256:e9fdc7ac0d42bc3ea78818557fab03af6181e076a2944f43c38684b4b6bed8e3 \
--hash=sha256:ee4afac41415d52d53a9833ebae7e32b344be72835bbb589018c9e938045a560
yt-dlp==2024.6.24.232830.dev0 \
--hash=sha256:0e89b46958984954393692a8c41e0f6d76a773be2df381c3d3a4ff24ce89aa32 \
--hash=sha256:efffecef44ce688e9ee3c02226eb1ba4ad64b37744726e9e4df5c2bd04ea93c5
yt-dlp==2024.7.1.232715.dev0 \
--hash=sha256:4f1ab25318c9156cca0b7308bdd2aeb3e7f01e8d9fb83916b4719010038170c8 \
--hash=sha256:e9ab443353da0c8f01587b031fb84b2cc42eae82aeaa03a9ce5ed6edc301b503

View File

@@ -1 +1 @@
__version__ = "0.1.79"
__version__ = "0.1.94"

View File

@@ -1,16 +1,33 @@
import argparse
import asyncio
from xiaomusic import (
__version__,
)
from xiaomusic.config import Config
from xiaomusic.xiaomusic import XiaoMusic
LOGO = r"""
__ __ _ __ __ _
\ \/ / (_) __ _ ___ | \/ | _ _ ___ (_) ___
\ / | | / _` | / _ \ | |\/| | | | | | / __| | | / __|
/ \ | | | (_| | | (_) | | | | | | |_| | \__ \ | | | (__
/_/\_\ |_| \__,_| \___/ |_| |_| \__,_| |___/ |_| \___|
{}
"""
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"--port",
dest="port",
help="监听端口",
)
parser.add_argument(
"--hardware",
dest="hardware",
help="小爱 hardware",
help="小爱音箱型号",
)
parser.add_argument(
"--account",
@@ -44,6 +61,14 @@ 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"))
options = parser.parse_args()
config = Config.from_options(options)

View File

@@ -3,62 +3,79 @@ 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"))
port: int = int(os.getenv("XIAOMUSIC_PORT", "8090")) # 监听端口
public_port: int = int(os.getenv("XIAOMUSIC_PUBLIC_PORT", 0)) # 歌曲访问端口
proxy: str | None = os.getenv("XIAOMUSIC_PROXY", None)
search_prefix: str = os.getenv(
"XIAOMUSIC_SEARCH", "ytsearch:"
"XIAOMUSIC_SEARCH", "bilisearch:"
) # "bilisearch:" or "ytsearch:"
ffmpeg_location: str = os.getenv("XIAOMUSIC_FFMPEG_LOCATION", "./ffmpeg/bin")
active_cmd: str = os.getenv(
@@ -69,25 +86,43 @@ 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"
)
log_file: str = os.getenv("XIAOMUSIC_MUSIC_LOG_FILE", "/tmp/xiaomusic.txt")
use_music_audio_id: str = os.getenv(
"XIAOMUSIC_USE_MUSIC_AUDIO_ID", "1582971365183456177"
)
use_music_id: str = os.getenv("XIAOMUSIC_USE_MUSIC_ID", "355454500")
log_file: str = os.getenv("XIAOMUSIC_LOG_FILE", "/tmp/xiaomusic.txt")
# 模糊搜索匹配的最低相似度阈值
fuzzy_match_cutoff: float = float(os.getenv("XIAOMUSIC_FUZZY_MATCH_CUTOFF", "0.6"))
# 开启模糊搜索
enable_fuzzy_match: bool = (
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
)
enable_force_stop: bool = (
os.getenv("XIAOMUSIC_ENABLE_FORCE_STOP", "false").lower() == "true"
)
def append_keyword(self, keys, action):
for key in keys.split(","):
@@ -95,17 +130,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)
keywords_playlocal = os.getenv(
"XIAOMUSIC_KEYWORDS_PLAYLOCAL", "播放本地歌曲,本地播放歌曲"
)
self.append_keyword(keywords_playlocal, "playlocal")
keywords_play = os.getenv("XIAOMUSIC_KEYWORDS_PLAY", "播放歌曲,放歌曲")
self.append_keyword(keywords_play, "play")
keywords_stop = os.getenv("XIAOMUSIC_KEYWORDS_STOP", "关机,暂停,停止")
self.append_keyword(keywords_stop, "stop")
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 文件
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:
@@ -126,3 +170,34 @@ class Config:
if value is not None and key in cls.__dataclass_fields__:
result[key] = value
return result
def update_config(self, data):
# 获取类型提示
type_hints = get_type_hints(self)
for k, v in data.items():
if v and k in type_hints:
# 获取字段的类型
expected_type = type_hints[k]
# 根据期望的类型进行转换
if isinstance(v, expected_type):
# 如果v已经是正确的类型则直接赋值
setattr(self, k, v)
else:
# 尝试转换类型
try:
# 特殊情况处理(例如对布尔值的转换)
if expected_type is bool:
converted_value = False
if v and v.lower() == "true":
converted_value = True
else:
# 使用期望类型的构造函数进行转换
converted_value = expected_type(v)
except (ValueError, TypeError) as e:
print(f"Error converting {v} to {expected_type}: {e}")
continue
# 设置转换后的值
setattr(self, k, converted_value)

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"

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env python3
import os
from dataclasses import asdict
from threading import Thread
from flask import Flask, request, send_file, send_from_directory
@@ -10,6 +11,7 @@ from xiaomusic import (
__version__,
)
from xiaomusic.utils import (
deepcopy_data_no_sensitive_info,
downloadfile,
)
@@ -98,20 +100,11 @@ async def do_cmd():
@auth.login_required
async def getsetting():
config = xiaomusic.getconfig()
log.debug(config)
data = asdict(config)
alldevices = await xiaomusic.call_main_thread_function(xiaomusic.getalldevices)
log.info(alldevices)
data = {
"mi_did": config.mi_did,
"mi_did_list": alldevices["did_list"],
"mi_hardware": config.hardware,
"mi_hardware_list": alldevices["hardware_list"],
"xiaomusic_search": config.search_prefix,
"xiaomusic_proxy": config.proxy,
"xiaomusic_music_list_url": config.music_list_url,
"xiaomusic_music_list_json": config.music_list_json,
}
log.info(f"getsetting alldevices: {alldevices}")
data["mi_did_list"] = alldevices["did_list"]
data["mi_hardware_list"] = alldevices["hardware_list"]
return data
@@ -119,7 +112,8 @@ async def getsetting():
@auth.login_required
async def savesetting():
data = request.get_json()
log.info(data)
debug_data = deepcopy_data_no_sensitive_info(data)
log.info(f"saveconfig: {debug_data}")
await xiaomusic.saveconfig(data)
return "save success"
@@ -177,6 +171,16 @@ async def playurl():
return await xiaomusic.call_main_thread_function(xiaomusic.play_url, arg1=url)
@app.route("/debug_play_by_music_url", methods=["POST"])
@auth.login_required
async def debug_play_by_music_url():
data = request.get_json()
log.info(f"data:{data}")
return await xiaomusic.call_main_thread_function(
xiaomusic.debug_play_by_music_url, arg1=data
)
def static_path_handler(filename):
log.debug(filename)
log.debug(static_path)

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

@@ -23,7 +23,7 @@ $(function(){
// 拉取版本
$.get("/getversion", function(data, status) {
console.log(data, status, data["version"]);
$("#version").text(`(${data.version})`);
$("#version").text(`${data.version}`);
});
// 拉取播放列表

View File

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width">
<title>Debug For XiaoMusic</title>
<link rel="stylesheet" type="text/css" href="/static/style.css">
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
<script src="/static/jquery-3.7.1.min.js"></script>
<script>
var vConsole = new window.VConsole();
function postJSON() {
var data = $('#post-input').val();
$.ajax({
type: 'POST',
url: '/debug_play_by_music_url',
data: data,
contentType: "application/json; charset=utf-8",
success: (err) => {
console.log("succ", res);
},
error: (res) => {
console.log("error", res);
}
});
}
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>
</html>

View File

@@ -8,7 +8,11 @@
<link rel="stylesheet" type="text/css" href="/static/style.css">
</head>
<body>
<h2>小爱音箱操控面板<span id="version">(版本未知)</span></h2>
<h2>小爱音箱操控面板
(<a id="version" href="https://github.com/hanxi/xiaomusic/releases">
版本未知
</a>)
</h2>
<hr>
<div id="cmds">
</div>

View File

@@ -6,26 +6,127 @@
<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>小爱音箱设置面板<span id="version">(版本未知)</span></h2>
<h2>小爱音箱设置面板
(<a id="version" href="https://github.com/hanxi/xiaomusic/releases">
版本未知
</a>)
</h2>
<hr>
<div class="rows">
<label for="mi_did">MI_DID:</label>
<select id="mi_did"></select>
<label for="mi_hardware">MI_HARDWARE(型号):</label>
<select id="mi_hardware" disabled></select>
<label for="xiaomusic_search">XIAOMUSIC_SEARCH:</label>
<select id="xiaomusic_search">
<option value="ytsearch:">ytsearch:</option>
<option value="bilisearch:">bilisearch:</option>
<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>
<label for="password">*小米密码:</label>
<input id="password" type="password" placeholder="填写小米登录密码"></input>
<label for="hostname">*XIAOMUSIC_HOSTNAME(IP或域名):</label>
<input id="hostname" type="text"></input>
<label for="verbose">是否开启调试日志:</label>
<select id="verbose">
<option value="true" selected>true</option>
<option value="false">false</option>
</select>
<label for="xiaomusic_proxy">XIAOMUSIC_PROXY(ytsearch需要):</label>
<input id="xiaomusic_proxy" type="text" placeholder="http://192.168.2.5:8080"></input>
<label for="xiaomusic_music_list_url">歌单地址:</label>
<input id="xiaomusic_music_list_url" type="text" value="https://gist.githubusercontent.com/hanxi/dda82d964a28f8110f8fba81c3ff8314/raw/example.json"></input>
<label for="xiaomusic_music_list_json">歌单内容:</label>
<textarea id="xiaomusic_music_list_json" type="text"></textarea>
<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" value='music/download'></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">日志路径:</label>
<input id="log_file" type="text" value="/tmp/xiaomusic.txt"></input>
<label for="active_cmd">允许唤醒的命令:</label>
<input id="active_cmd" type="text" value="play,random_play,playlocal,play_music_list,stop"></input>
<label for="exclude_dirs">忽略目录(逗号分割):</label>
<input id="exclude_dirs" type="text" value="@eaDir"></input>
<label for="music_path_depth">目录深度:</label>
<input id="music_path_depth" type="number" value="10"></input>
<label for="search_prefix">XIAOMUSIC_SEARCH(歌曲下载方式):</label>
<select id="search_prefix">
<option value="bilisearch:">bilisearch:</option>
<option value="ytsearch:">ytsearch:</option>
</select>
<label for="proxy">XIAOMUSIC_PROXY(ytsearch需要):</label>
<input id="proxy" type="text" placeholder="http://192.168.2.5:8080"></input>
<label for="disable_httpauth">关闭密码验证:</label>
<select id="disable_httpauth">
<option value="true" selected>true</option>
<option value="false">false</option>
</select>
<label for="httpauth_username">web控制台账户:</label>
<input id="httpauth_username" type="text" value=""></input>
<label for="httpauth_password">web控制台密码:</label>
<input id="httpauth_password" type="password" value=""></input>
<label for="disable_download">关闭下载功能:</label>
<select id="disable_download">
<option value="true">true</option>
<option value="false" selected>false</option>
</select>
<label for="use_music_audio_id">触屏版显示歌曲ID:</label>
<input id="use_music_audio_id" type="text" value="1582971365183456177"></input>
<label for="use_music_id">触屏版显示歌曲分段ID:</label>
<input id="use_music_id" type="text" value="355454500"></input>
<label for="fuzzy_match_cutoff">模糊匹配阈值(0.1~0.9):</label>
<input id="fuzzy_match_cutoff" type="number" value="0.6"></input>
<label for="enable_fuzzy_match">开启模糊搜索:</label>
<select id="enable_fuzzy_match">
<option value="true" selected>true</option>
<option value="false">false</option>
</select>
<label for="public_port">外网访问端口(0表示跟监听端口一致):</label>
<input id="public_port" type="number" value="0"></input>
<label for="stop_tts_msg">停止提示音:</label>
<input id="stop_tts_msg" type="text" value="收到,再见"></input>
<label for="keywords_playlocal">播放本地歌曲口令:</label>
<input id="keywords_playlocal" type="text" value="播放本地歌曲,本地播放歌曲"></input>
<label for="keywords_play">播放歌曲口令:</label>
<input id="keywords_play" type="text" value="播放歌曲,放歌曲"></input>
<label for="keywords_stop">停止口令:</label>
<input id="keywords_stop" type="text" value="关机,暂停,停止,停止播放"></input>
<label for="music_list_url">歌单地址:</label>
<input id="music_list_url" type="text" value="https://gist.githubusercontent.com/hanxi/dda82d964a28f8110f8fba81c3ff8314/raw/example.json"></input>
<label for="music_list_json">歌单内容:</label>
<textarea id="music_list_json" type="text"></textarea>
</div>
<hr>
<button onclick="location.href='/';">返回首页</button>
@@ -35,6 +136,8 @@
<hr>
<a href="/static/m3u.html" target="_blank">m3u文件转换工具</a>
<hr>
<a href="/static/debug.html" target="_blank">调试工具</a>
<footer>
<p>Powered by <a href="https://github.com/hanxi/xiaomusic" target="_blank">xiaomusic</a></p>

View File

@@ -2,91 +2,112 @@ $(function(){
// 拉取版本
$.get("/getversion", function(data, status) {
console.log(data, status, data["version"]);
$("#version").text(`(${data.version})`);
$("#version").text(`${data.version}`);
});
const updateSelectOptions = (selectId, optionsList, selectedOption) => {
const select = $(selectId);
select.empty();
optionsList.forEach(option => {
select.append(new Option(option, option));
});
select.val(selectedOption);
};
let isChanging = false;
// 更新下拉菜单的函数
const updateSelect = (selectId, value) => {
if (!isChanging) {
isChanging = true;
$(selectId).val(value);
isChanging = false;
}
};
// 联动逻辑
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]);
}
// 遍历所有的select元素默认选中只有1个选项的
const autoSelectOne = () => {
$('select').each(function() {
// 如果select元素仅有一个option子元素
if ($(this).children('option').length === 1) {
// 选中这个option
$(this).find('option').prop('selected', true);
}
});
};
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);
});
}
function getSelectedDidsAndHardware(containerSelector) {
var selectedDids = [];
var selectedHardware = [];
// 仅选择给定容器中选中的复选框
$(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);
updateCheckbox("#mi_did_hardware", data.mi_did_list, data.mi_did, data.mi_hardware_list);
updateSelectOptions("#mi_did", data.mi_did_list, data.mi_did);
updateSelectOptions("#mi_hardware", data.mi_hardware_list, data.mi_hardware);
// 初始化联动
linkSelects('#mi_did', data.mi_did_list, '#mi_hardware', data.mi_hardware_list);
if (data.xiaomusic_search != "") {
$("#xiaomusic_search").val(data.xiaomusic_search);
// 初始化显示
for (const key in data) {
if (data.hasOwnProperty(key)) {
const $element = $("#" + key);
if ($element.length && data[key] !== '') {
if (data[key] === true) {
$element.val('true');
} else if (data[key] === false) {
$element.val('false');
} else {
$element.val(data[key]);
}
}
}
}
if (data.xiaomusic_proxy != "") {
$("#xiaomusic_proxy").val(data.xiaomusic_proxy);
}
if (data.xiaomusic_music_list_url != "") {
$("#xiaomusic_music_list_url").val(data.xiaomusic_music_list_url);
}
if (data.xiaomusic_music_list_json != "") {
$("#xiaomusic_music_list_json").val(data.xiaomusic_music_list_json);
}
autoSelectOne();
});
$("#save").on("click", () => {
var mi_did = $("#mi_did").val();
var mi_hardware = $("#mi_hardware").val();
var xiaomusic_search = $("#xiaomusic_search").val();
var xiaomusic_proxy = $("#xiaomusic_proxy").val();
var xiaomusic_music_list_url = $("#xiaomusic_music_list_url").val();
var xiaomusic_music_list_json = $("#xiaomusic_music_list_json").val();
console.log("mi_did", mi_did);
console.log("mi_hardware", mi_hardware);
console.log("xiaomusic_search", xiaomusic_search);
console.log("xiaomusic_proxy", xiaomusic_proxy);
console.log("xiaomusic_music_list_url", xiaomusic_music_list_url);
console.log("xiaomusic_music_list_json", xiaomusic_music_list_json);
var data = {
mi_did: mi_did,
mi_hardware: mi_hardware,
xiaomusic_search: xiaomusic_search,
xiaomusic_proxy: xiaomusic_proxy,
xiaomusic_music_list_url: xiaomusic_music_list_url,
xiaomusic_music_list_json: xiaomusic_music_list_json,
};
var setting = $('#setting');
var inputs = setting.find('input, select, textarea');
var data = {};
inputs.each(function() {
var id = this.id;
if (id) {
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({
type: "POST",
url: "/savesetting",
@@ -102,10 +123,10 @@ $(function(){
});
$("#get_music_list").on("click", () => {
var xiaomusic_music_list_url = $("#xiaomusic_music_list_url").val();
console.log("xiaomusic_music_list_url", xiaomusic_music_list_url);
var music_list_url = $("#music_list_url").val();
console.log("music_list_url", music_list_url);
var data = {
url: xiaomusic_music_list_url,
url: music_list_url,
};
$.ajax({
type: "POST",
@@ -114,7 +135,7 @@ $(function(){
data: JSON.stringify(data),
success: (res) => {
if (res.ret == "OK") {
$("#xiaomusic_music_list_json").val(res.content);
$("#music_list_json").val(res.content);
} else {
console.log(res);
alert(res.ret);

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

@@ -1,6 +1,7 @@
#!/usr/bin/env python3
from __future__ import annotations
import copy
import difflib
import os
import random
@@ -240,3 +241,30 @@ def get_local_music_duration(filename):
def get_random(length):
return "".join(random.sample(string.ascii_letters + string.digits, length))
# 深拷贝把敏感数据设置位*
def deepcopy_data_no_sensitive_info(data, fields_to_anonymize=None):
if fields_to_anonymize is None:
fields_to_anonymize = [
"account",
"password",
"httpauth_username",
"httpauth_password",
]
copy_data = copy.deepcopy(data)
# 检查copy_data是否是字典或具有属性的对象
if isinstance(copy_data, dict):
# 对字典进行处理
for field in fields_to_anonymize:
if field in copy_data:
copy_data[field] = "******"
else:
# 对对象进行处理
for field in fields_to_anonymize:
if hasattr(copy_data, field):
setattr(copy_data, field, "******")
return copy_data

View File

@@ -1,6 +1,5 @@
#!/usr/bin/env python3
import asyncio
import copy
import json
import logging
import os
@@ -14,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__,
@@ -29,12 +28,13 @@ 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,
find_best_match,
fuzzyfinder,
get_local_music_duration,
get_random,
get_web_music_duration,
parse_cookie_string,
walk_to_depth,
@@ -52,29 +52,15 @@ class XiaoMusic:
self.config = config
self.mi_token_home = Path.home() / ".mi.token"
self.last_timestamp = int(time.time() * 1000) # timestamp last call mi speaker
self.last_timestamp = {} # 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.music_path = config.music_path
self.conf_path = config.conf_path
if not self.conf_path:
self.conf_path = config.music_path
self.hostname = config.hostname
self.port = config.port
self.proxy = config.proxy
self.search_prefix = config.search_prefix
self.ffmpeg_location = config.ffmpeg_location
self.active_cmd = config.active_cmd.split(",")
self.exclude_dirs = set(config.exclude_dirs.split(","))
self.music_path_depth = config.music_path_depth
self.device2hardware = {}
self.did2device = {}
# 下载对象
self.download_proc = None
@@ -94,6 +80,9 @@ class XiaoMusic:
# 关机定时器
self._stop_timer = None
# 初始化配置
self.init_config()
# 初始化日志
self.setup_logger()
@@ -106,10 +95,44 @@ 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}")
def init_config(self):
self.music_path = self.config.music_path
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
self.public_port = self.config.public_port
if self.public_port == 0:
self.public_port = self.port
self.proxy = self.config.proxy
self.search_prefix = self.config.search_prefix
self.ffmpeg_location = self.config.ffmpeg_location
self.active_cmd = self.config.active_cmd.split(",")
self.exclude_dirs = set(self.config.exclude_dirs.split(","))
self.music_path_depth = self.config.music_path_depth
def setup_logger(self):
log_format = f"%(asctime)s [{__version__}] [%(levelname)s] %(message)s"
date_format = "[%X]"
formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
logging.basicConfig(
format=f"%(asctime)s [{__version__}] [%(levelname)s] %(message)s",
datefmt="[%X]",
format=log_format,
datefmt=date_format,
)
log_file = self.config.log_file
@@ -121,35 +144,37 @@ class XiaoMusic:
handler = RotatingFileHandler(
self.config.log_file, maxBytes=10 * 1024 * 1024, backupCount=1
)
handler.setFormatter(formatter)
self.log = logging.getLogger("xiaomusic")
self.log.addHandler(handler)
self.log.setLevel(logging.DEBUG if self.config.verbose else logging.INFO)
debug_config = copy.deepcopy(self.config)
debug_config.account = "******"
debug_config.password = "******"
debug_config.httpauth_username = "******"
debug_config.httpauth_password = "******"
self.log.info(debug_config)
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
f"Listening new message, timestamp: {self.last_timestamp}"
)
await self.get_latest_ask_from_xiaoai(session)
session._cookie_jar = self.cookie_jar
# 拉取所有音箱的对话记录
tasks = [
self.get_latest_ask_from_xiaoai(session, device_id)
for device_id in self.device2hardware
]
await asyncio.gather(*tasks)
start = time.perf_counter()
self.log.debug("Polling_event, timestamp: %s", self.last_timestamp)
self.log.debug(f"Polling_event, timestamp: {self.last_timestamp}")
await self.polling_event.wait()
if (d := time.perf_counter() - start) < 1:
# sleep to avoid too many request
self.log.debug("Sleep %f, timestamp: %s", d, self.last_timestamp)
self.log.debug(f"Sleep {d}, timestamp: {self.last_timestamp}")
await asyncio.sleep(1 - d)
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)
@@ -165,61 +190,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):
@@ -230,12 +220,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:
@@ -245,7 +241,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)
@@ -260,18 +256,21 @@ class XiaoMusic:
self.log.info("Maybe outof date trying to re init it")
await self.init_all_data(self.session)
else:
return self._get_last_query(data)
return self._get_last_query(device_id, data)
def _get_last_query(self, data):
self.log.debug(f"_get_last_query:{data}")
def _get_last_query(self, device_id, data):
self.log.debug(f"_get_last_query device_id:{device_id} data:{data}")
if d := data.get("data"):
records = json.loads(d).get("records")
if not records:
return
last_record = records[0]
timestamp = last_record.get("time")
if timestamp > self.last_timestamp:
self.last_timestamp = timestamp
# 首次用当前时间初始化
if device_id not in self.last_timestamp:
self.last_timestamp[device_id] = int(time.time() * 1000)
if timestamp > self.last_timestamp[device_id]:
self.last_timestamp[device_id] = timestamp
self.last_record = last_record
self.new_record_event.set()
@@ -284,45 +283,104 @@ class XiaoMusic:
self.new_record_event.set()
async def do_tts(self, value):
self.log.info("do_tts: %s", value)
self.log.info(f"try do_tts value:{value}")
if not value:
self.log.info("do_tts no value")
return
await self.force_stop_xiaoai()
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_one(self, device_id, value):
try:
await self.mina_service.text_to_speech(self.device_id, value)
await self.mina_service.text_to_speech(device_id, value)
except Exception as e:
self.log.error(f"Execption {e}")
# 最大等8秒
sec = min(8, int(len(value) / 3.3))
await asyncio.sleep(sec)
self.log.debug(f"do_tts. cur_music:{self.cur_music}")
if self._playing and not self.is_downloading():
async def text_to_speech(self, value):
tasks = [
self.text_to_speech_one(device_id, value)
for device_id in self.device2hardware
]
await asyncio.gather(*tasks)
# 继续播放被打断的歌曲
async def check_replay(self):
if self.isplaying() and not self.isdownloading():
# 继续播放歌曲
self.log.info("继续播放歌曲")
self.log.info("现在继续播放歌曲")
await self.play()
else:
self.log.info(
f"不会继续播放歌曲. isplaying:{self.isplaying()} isdownloading:{self.isdownloading()}"
)
async def do_set_volume(self, value):
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, device_id):
playing_info = await self.mina_service.player_get_status(device_id)
self.log.info(playing_info)
# WTF xiaomi api
is_playing = (
json.loads(playing_info.get("data", {}).get("info", "{}")).get("status", -1)
== 1
)
return 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 or self.config.enable_force_stop:
# stop it
ret = await self.mina_service.player_stop(device_id)
self.log.info(
f"stop_if_xiaoai_is_playing player_stop device_id:{device_id} enable_force_stop:{self.config.enable_force_stop} ret:{ret}"
)
async def force_stop_one_xiaoai(self, device_id):
try:
ret = await self.mina_service.player_pause(device_id)
self.log.info(
f"force_stop_one_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}")
async def force_stop_xiaoai(self):
ret = await self.mina_service.player_pause(self.device_id)
self.log.debug(f"force_stop_xiaoai player_pause ret:{ret}")
#ret = await self.mina_service.player_stop(self.device_id)
#self.log.debug(f"force_stop_xiaoai player_stop ret:{ret}")
tasks = [
self.force_stop_one_xiaoai(device_id) for device_id in self.device2hardware
]
await asyncio.gather(*tasks)
# 是否在下载中
def is_downloading(self):
def isdownloading(self):
if not self.download_proc:
return False
if (
self.download_proc.returncode is not None
and self.download_proc.returncode < 0
):
if self.download_proc.returncode is not None:
self.log.info(
f"Process exited with returncode:{self.download_proc.returncode}"
)
return False
self.log.info("Download Process is still running.")
return True
# 下载歌曲
@@ -340,7 +398,7 @@ class XiaoMusic:
"--audio-format",
"mp3",
"--paths",
self.music_path,
self.download_path,
"-o",
f"{name}.mp3",
"--ffmpeg-location",
@@ -351,7 +409,8 @@ class XiaoMusic:
if self.proxy:
sbp_args += ("--proxy", f"{self.proxy}")
self.log.info(f"download: {sbp_args}")
cmd = " ".join(sbp_args)
self.log.info(f"download cmd: {cmd}")
self.download_proc = await asyncio.create_subprocess_exec(*sbp_args)
await self.do_tts(f"正在下载歌曲{search_key}")
@@ -415,12 +474,12 @@ class XiaoMusic:
self.log.debug("get_music_url web music. name:%s, url:%s", name, url)
return url
filename = self.get_filename(name)
filename = self.get_filename(name).replace("\\", "/")
self.log.debug(
"get_music_url local music. name:%s, filename:%s", name, filename
)
encoded_name = urllib.parse.quote(filename)
return f"http://{self.hostname}:{self.port}/{encoded_name}"
return f"http://{self.hostname}:{self.public_port}/{encoded_name}"
# 递归获取目录下所有歌曲,生成随机播放列表
def _gen_all_music_list(self):
@@ -476,6 +535,7 @@ class XiaoMusic:
if not self.config.music_list_json:
return
self._all_radio = {}
music_list = json.loads(self.config.music_list_json)
try:
for item in music_list:
@@ -516,7 +576,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)
@@ -551,7 +611,7 @@ class XiaoMusic:
if self._next_timer:
self._next_timer.cancel()
self.log.info("定时器已取消")
self.log.info("定时器已取消")
self._timeout = sec
@@ -599,19 +659,42 @@ 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)
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:
self.log.warning(f"执行出错 {str(e)}\n{traceback.format_exc()}")
# 检查是否匹配到完全一样的指令
def check_full_match_cmd(self, query, ctrl_panel):
if query in self.config.key_match_order:
opkey = query
opvalue = self.config.key_word_dict.get(opkey)
if ctrl_panel or self.isplaying():
return opvalue
else:
if not self.active_cmd or opvalue in self.active_cmd:
return opvalue
return None
# 匹配命令
def match_cmd(self, query, ctrl_panel):
# 优先处理完全匹配
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:
patternarg = rf"(.*){opkey}(.*)"
# 匹配参数
@@ -629,20 +712,16 @@ class XiaoMusic:
argafter,
)
oparg = argafter
opvalue = self.config.key_word_dict.get(opkey)
if not ctrl_panel and not self._playing:
if self.active_cmd and opvalue not in self.active_cmd:
self.log.debug(f"不在激活命令中 {opvalue}")
continue
if opkey in KEY_WORD_ARG_BEFORE_DICT:
oparg = argpre
self.log.info(
"匹配到指令. opkey:%s opvalue:%s oparg:%s", opkey, opvalue, oparg
)
opvalue = self.config.key_word_dict.get(opkey)
if not ctrl_panel and not self.isplaying():
if self.active_cmd and opvalue not in self.active_cmd:
self.log.ifno(f"不在激活命令中 {opvalue}")
continue
self.log.info(f"匹配到指令. opkey:{opkey} opvalue:{opvalue} oparg:{oparg}")
return (opvalue, oparg)
if self._playing:
self.log.info("未匹配到指令,自动停止")
return ("stop", "notts")
self.log.info(f"未匹配到指令 {query} {ctrl_panel}")
return (None, None)
# 判断是否播放下一首歌曲
@@ -656,40 +735,34 @@ class XiaoMusic:
return True
return False
async def _play_by_music_url(self, device_id, url):
audio_id = get_random(30)
audio_type = ""
if self.config.hardware in ["LX04", "X10A", "X08A"]:
audio_type = "MUSIC"
music = {
"payload": {
"audio_items": [
{"item_id": {"audio_id": audio_id}, "stream": {"url": url}}
],
"audio_type": audio_type,
}
}
return await self.mina_service.ubus_request(
device_id,
"player_play_music",
"mediaplayer",
{"startaudioid": audio_id, "music": json.dumps(music)},
)
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.debug(
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.debug(
f"play_url play_by_url {self.config.hardware}. ret:{ret} url:{url}"
)
return await self.all_player_play(url)
async def play_one_url(self, device_id, url):
try:
if self.config.use_music_api:
ret = await self.play_by_music_url(device_id, url)
self.log.info(
f"play_one_url 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"play_one_url play_by_url device_id:{device_id} ret:{ret} url:{url}"
)
except Exception as e:
self.log.error(f"Execption {e}")
return ret
async def all_player_play(self, url):
tasks = [
self.play_one_url(device_id, url) for device_id in self.device2hardware
]
results = await asyncio.gather(*tasks)
self.log.info(f"all_player_play {url} {results}")
return results
def find_real_music_name(self, name):
if not self.config.enable_fuzzy_match:
self.log.debug("没开启模糊匹配")
@@ -703,6 +776,7 @@ class XiaoMusic:
self.log.info(f"根据【{name}】找到歌曲【{real_name}")
return real_name
self.log.info(f"没找到歌曲【{name}")
return name
# 播放本地歌曲
async def playlocal(self, **kwargs):
@@ -728,8 +802,8 @@ class XiaoMusic:
self.cur_music = name
self.log.info(f"cur_music {self.cur_music}")
sec, url = await self.get_music_sec_url(name)
self.log.info(f"播放 {url}")
await self.force_stop_xiaoai()
self.log.info(f"播放 {url}")
await self.play_url(arg1=url)
self.log.info("已经开始播放了")
# 设置下一首歌曲的播放定时器
@@ -759,7 +833,7 @@ class XiaoMusic:
await self.do_tts(f"本地不存在歌曲{name}")
return
await self.download(search_key, name)
self.log.info("正在下载中 %s", search_key + ":" + name)
self.log.info(f"正在下载中 {search_key} {name}")
await self.download_proc.wait()
# 把文件插入到播放列表里
self.add_download_music(name)
@@ -854,11 +928,12 @@ class XiaoMusic:
async def stop(self, **kwargs):
self._playing = False
if kwargs.get("arg1", "") != "notts":
await self.do_tts("收到关机口令,再见")
await self.do_tts(self.config.stop_tts_msg)
if self._next_timer:
self._next_timer.cancel()
self.log.info("定时器已取消")
await self.force_stop_xiaoai()
self.log.info("stop now")
async def stop_after_minute(self, **kwargs):
if self._stop_timer:
@@ -881,7 +956,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
@@ -949,20 +1026,24 @@ class XiaoMusic:
await self.call_main_thread_function(self.reinit)
def update_config_from_setting(self, data):
self.config.mi_did = data.get("mi_did")
# 兼容旧配置:一段时间后清理这里的旧代码
self.config.hardware = data.get("mi_hardware")
self.config.search_prefix = data.get("xiaomusic_search")
self.config.proxy = data.get("xiaomusic_proxy")
self.config.music_list_url = data.get("xiaomusic_music_list_url")
self.config.music_list_json = data.get("xiaomusic_music_list_json")
self.search_prefix = self.config.search_prefix
self.proxy = self.config.proxy
self.log.debug("update_config_from_setting ok. data:%s", data)
# 自动赋值相同字段的配置
self.config.update_config(data)
self.init_config()
debug_config = deepcopy_data_no_sensitive_info(self.config)
self.log.info("update_config_from_setting ok. data:%s", debug_config)
# 重新初始化
async def reinit(self, **kwargs):
await self.try_update_device_id()
self.setup_logger()
await self.init_all_data(self.session)
self._gen_all_music_list()
self.log.info("reinit success")
@@ -999,3 +1080,63 @@ class XiaoMusic:
self.new_record_event.set()
result = await future
return result
async def play_by_music_url(self, deviceId, url, _type=2):
self.log.info(f"play_by_music_url url:{url}, type:{_type}")
audio_type = ""
if _type == 1:
# If set to MUSIC, the light will be on
audio_type = "MUSIC"
audio_id = self.config.use_music_audio_id
id = self.config.use_music_id
music = {
"payload": {
"audio_type": audio_type,
"audio_items": [
{
"item_id": {
"audio_id": audio_id,
"cp": {
"album_id": "-1",
"episode_index": 0,
"id": id,
"name": "xiaowei",
},
},
"stream": {"url": url},
}
],
"list_params": {
"listId": "-1",
"loadmore_offset": 0,
"origin": "xiaowei",
"type": "MUSIC",
},
},
"play_behavior": "REPLACE_ALL",
}
data = {"startaudioid": audio_id, "music": json.dumps(music)}
self.log.info(json.dumps(data))
return await self.mina_service.ubus_request(
deviceId,
"player_play_music",
"mediaplayer",
data,
)
async def debug_play_by_music_url(self, arg1=None):
if arg1 is None:
arg1 = {}
data = arg1
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(
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)