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

Compare commits

...

98 Commits

Author SHA1 Message Date
涵曦
44df1134a8 new version v0.1.64 2024-06-26 01:24:30 +00:00
涵曦
243c1673db new version v0.1.63 2024-06-26 01:24:27 +00:00
涵曦
82eab4810c 修复自动播放的问题 2024-06-26 01:24:27 +00:00
涵曦
e0a59b5729 修复自动播放的问题 2024-06-26 01:24:27 +00:00
涵曦
ad3bad85db 优化日志输出 2024-06-26 01:24:27 +00:00
涵曦
743d85de32 优化不定长参数arg1的用法 2024-06-26 01:24:27 +00:00
涵曦
8bd32f878f Update README.md 2024-06-26 02:03:38 +08:00
涵曦
eccb52c197 new version v0.1.62 2024-06-25 17:34:47 +00:00
涵曦
603d60d8b8 优化图标大小 2024-06-25 17:30:50 +00:00
涵曦
3ef04f4159 优化获取音乐时长接口 2024-06-25 17:27:32 +00:00
涵曦
7888ee7938 new version v0.1.61 2024-06-25 11:13:57 +00:00
涵曦
b2a3cda7b5 #78 支持配置自定义网络歌单 2024-06-25 11:13:46 +00:00
涵曦
80c6d29079 new version v0.1.60 2024-06-25 06:20:28 +00:00
涵曦
0b020deaef new version v0.1.59 2024-06-25 06:17:41 +00:00
涵曦
74c8bea756 cue不是音乐文件,排除下 2024-06-25 06:17:41 +00:00
涵曦
af10d6261f Update README.md 2024-06-25 10:50:32 +08:00
涵曦
a178278576 new version v0.1.58 2024-06-25 01:03:13 +00:00
涵曦
474fea8434 优化播放被打断的问题 2024-06-25 01:03:01 +00:00
涵曦
d271f7b0f7 代码优化 2024-06-24 16:03:10 +00:00
涵曦
7e2af515ed fix: 登陆失败不阻塞启动 2024-06-24 15:38:41 +00:00
涵曦
2d403ff18c new version v0.1.57 2024-06-24 14:28:08 +00:00
涵曦
f08244a990 update readme 2024-06-24 14:28:05 +00:00
涵曦
4869e5cf80 新增ape和cue格式文件 2024-06-24 14:27:31 +00:00
涵曦
b887504f9f Update README.md 2024-06-24 12:24:47 +08:00
涵曦
ad43a4f732 new version v0.1.56 2024-06-24 01:04:13 +00:00
涵曦
8699938b61 删除用不上的配置参数 2024-06-24 00:45:41 +00:00
涵曦
40bd099153 支持wav格式文件 2024-06-24 00:19:49 +00:00
涵曦
750923d5ca Update README.md 2024-06-24 00:38:33 +08:00
涵曦
6d99b30e2d Update README.md 2024-06-24 00:35:02 +08:00
涵曦
a155f16560 Update README.md 2024-06-24 00:30:42 +08:00
涵曦
d4aa045487 Update README.md 2024-06-23 20:05:20 +08:00
涵曦
8080dd9822 Update Dockerfile 2024-06-23 19:05:13 +08:00
涵曦
2eab8d8113 update dockerfile 2024-06-23 10:23:46 +00:00
涵曦
b51e56718a update dockerfile 2024-06-23 10:18:07 +00:00
涵曦
088d448e10 优化 Dockerfile 2024-06-23 17:40:28 +08:00
涵曦
4a89b4bce5 Update README.md 2024-06-23 16:27:22 +08:00
涵曦
b351b4bcd4 new version v0.1.55 2024-06-23 07:09:42 +00:00
涵曦
b2edaf48e4 fix: #47 支持配置基础的BaseAuth登录 2024-06-23 07:07:59 +00:00
涵曦
33e02cee82 new version v0.1.54 2024-06-23 04:17:03 +00:00
涵曦
91a8c9eb50 fix: #76 新增XIAOMUSIC_MUSIC_PATH_DEPTH配置生成播放列表的目录深度,默认10 2024-06-23 03:30:32 +00:00
涵曦
50da8a0554 fix: #74 配置目录可以和下载目录分开配置, 新增XIAOMUSIC_CONF_PATH用来设置配置目录,不配置时使用下载目录 2024-06-23 02:49:13 +00:00
涵曦
42b5978d89 new version v0.1.53 2024-06-23 01:52:42 +00:00
涵曦
bf29fc67b4 增加调试日志 2024-06-23 01:52:38 +00:00
涵曦
dbc68d6b56 new version v0.1.52 2024-06-21 14:16:52 +00:00
涵曦
5dabf66e7c 增加日志 2024-06-21 14:16:28 +00:00
涵曦
d6e4478eb6 new version v0.1.51 2024-06-20 23:10:21 +00:00
涵曦
23ef4719ba update log 2024-06-20 15:45:23 +00:00
涵曦
d799a85ab9 new version v0.1.49 2024-06-20 04:12:33 +00:00
涵曦
4ad6bcc636 播放列表排序显示,修复顺序播放问题 2024-06-20 04:12:23 +00:00
涵曦
d2473ec7e8 全部循环为顺序播放,和随机播放区分开 2024-06-18 07:06:16 +00:00
涵曦
28797edc7c new version v0.1.48 2024-06-16 06:14:37 +00:00
涵曦
be1a643071 忽略目录默认值修改 2024-06-16 06:14:33 +00:00
涵曦
ee6b9778ac new version v0.1.47 2024-06-16 05:40:38 +00:00
涵曦
881c34bcb5 新增忽略目录的环境变量 2024-06-16 05:40:30 +00:00
涵曦
c22fc99235 new version v0.1.46 2024-06-15 15:56:22 +00:00
涵曦
0874efe58b 播放歌曲指令默认播放最后一次播放的歌曲 2024-06-15 15:56:15 +00:00
涵曦
f01665c998 new version v0.1.45 2024-06-15 15:04:07 +00:00
涵曦
ac23080f6a 播放列表歌曲前打乱顺序 2024-06-15 15:03:56 +00:00
涵曦
15ee6c4dd1 new version v0.1.44 2024-06-14 15:47:02 +00:00
涵曦
aeaa8f8925 fmt 2024-06-14 15:46:47 +00:00
涵曦
59d7e056c4 new version v0.1.43 2024-06-14 15:14:56 +00:00
涵曦
e79afa46b3 新增删除歌曲按钮 2024-06-14 15:14:34 +00:00
涵曦
9714f3d064 new version v0.1.41 2024-06-14 14:11:00 +00:00
涵曦
2c35c6cfd6 add XIAOMUSIC_VERBOSE env 2024-06-14 14:10:56 +00:00
涵曦
88f0ce7e51 use ruff lint and fmt code 2024-06-14 01:58:10 +00:00
涵曦
e484164fad 修复刷新列表问题 2024-06-13 14:49:36 +00:00
涵曦
aa6bce75cd update readme 2024-06-12 17:26:00 +00:00
涵曦
512efb595a new version v0.1.40 2024-06-12 17:21:13 +00:00
涵曦
e5dea8e693 新增刷新列表指令 2024-06-12 17:21:09 +00:00
涵曦
a704f8003c new version v0.1.39 2024-06-12 17:13:00 +00:00
涵曦
349a25ad58 新增播放列表功能 #51 2024-06-12 17:12:07 +00:00
涵曦
746f46edb3 new version v0.1.38 2024-06-12 15:39:23 +00:00
涵曦
4a29c7a124 fix: #70 下一首歌曲不存在时从播放列表中删除并继续找下一首 2024-06-12 01:18:08 +00:00
涵曦
0e1e412ee9 new version v0.1.37 2024-06-04 12:17:50 +00:00
涵曦
2e84f7c830 Update ci.yml 2024-06-04 18:46:17 +08:00
涵曦
61a0d68b6a Update release.yml 2024-06-04 18:45:53 +08:00
涵曦
ae90029d8e Update README.md 2024-06-04 15:14:14 +08:00
涵曦
ccc83a518c new version v0.1.36 2024-05-30 14:42:32 +00:00
涵曦
7884a5769f 继续修复启动失败问题 2024-05-30 14:42:12 +00:00
涵曦
a663bb330e new version v0.1.35 2024-05-30 13:49:52 +00:00
涵曦
346f0af543 fix: #67 没配置did时也允许启动 http 服务 2024-05-27 14:47:40 +00:00
涵曦
49ec1bb7c0 Update README.md 2024-05-20 00:03:27 +08:00
涵曦
fc0cc75dea new version v0.1.34 2024-05-19 15:53:50 +00:00
涵曦
6fc2be5d31 消除flask启动告警 2024-05-19 15:53:31 +00:00
涵曦
db680bf1ba update readme 2024-05-19 15:22:52 +00:00
涵曦
1c2b97c0d2 new version v0.1.33 2024-05-19 15:19:19 +00:00
涵曦
59cfbb06a4 优化页面 2024-05-19 15:18:28 +00:00
涵曦
9291676543 fix: #50 新增配置页面 2024-05-19 15:11:43 +00:00
涵曦
0d2ba60728 删除不用的配置 2024-05-18 10:01:51 +00:00
firstuanl
cd1461df6d Update config.py
# 第一代小爱,型号MDZ-25-DA
2024-05-18 17:41:15 +08:00
涵曦
37abfd9ce2 fix: #62 2024-05-18 00:12:34 +00:00
涵曦
c6de3dfd00 new version v0.1.32 2024-05-17 12:27:55 +00:00
涵曦
ae297c780a update miservice 2024-05-17 12:27:47 +00:00
涵曦
e07a06c8e4 new version v0.1.31 2024-05-16 23:43:44 +00:00
涵曦
1c91f39417 日志里输出版本号 2024-05-16 23:43:39 +00:00
涵曦
13d26be0a2 new version v0.1.30 2024-05-16 23:19:11 +00:00
涵曦
c2740533a8 fix: 控制台显示版本号 #59 2024-05-16 23:19:05 +00:00
涵曦
abbc2f25bb 修复音量获取 2024-05-16 23:06:30 +00:00
21 changed files with 1237 additions and 307 deletions

View File

@@ -25,6 +25,6 @@ jobs:
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/xiaomusic:${{ github.ref_name }}

View File

@@ -61,6 +61,6 @@ jobs:
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/xiaomusic:${{ github.ref_name }}, ${{ secrets.DOCKERHUB_USERNAME }}/xiaomusic:latest, ${{ secrets.DOCKERHUB_USERNAME }}/xiaomusic:stable

1
.gitignore vendored
View File

@@ -164,3 +164,4 @@ cython_debug/
ffmpeg
music
test.sh
conf

View File

@@ -6,7 +6,6 @@ COPY install_dependencies.sh .
RUN bash install_dependencies.sh
FROM python:3.10-slim
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/ffmpeg /app/ffmpeg

View File

@@ -1,8 +1,29 @@
# xiaomusic
使用小爱/红米音箱播放音乐,音乐使用 yt-dlp 下载。
使用小爱音箱播放音乐,音乐使用 yt-dlp 下载。
## 运行
## 最简配置运行
已经支持在 web 页面配置其他参数docker compose 配置如下:
```yaml
services:
xiaomusic:
image: hanxi/xiaomusic
container_name: xiaomusic
restart: unless-stopped
ports:
- 8090:8090
volumes:
- ./music:/app/music
environment:
MI_USER: '小米账号'
MI_PASS: '小米密码'
XIAOMUSIC_HOSTNAME: 'docker 主机 ip'
```
启动成功后,在 web 页面可以配置 MI_DID, MI_HARDWARE, XIAOMUSIC_SEARCH, XIAOMUSIC_PROXY 参数。
## 开发环境运行
- 使用 install_dependencies.sh 下载依赖
- 使用 pdm 安装环境
@@ -28,6 +49,11 @@ pdm run xiaomusic.py
- 下一首
- 单曲循环
- 全部循环
- 随机播放
- 关机
- 停止播放
- 刷新列表
- 播放列表+列表名 比如:播放列表其他
> 隐藏玩法: 对小爱同学说播放歌曲小猪佩奇的故事,会播放小猪佩奇的故事。
@@ -50,8 +76,17 @@ pdm run xiaomusic.py
## 在 Docker 里使用
```shell
docker run -e MI_USER=<your-xiaomi-account> -e MI_PASS=<your-xiaomi-password> -e MI_DID=<your-xiaomi-speaker-mid> -e MI_HARDWARE='L07A' -e XIAOMUSIC_PROXY=<proxy-for-yt-dlp> -e XIAOMUSIC_HOSTNAME=192.168.2.5 -e XIAOMUSIC_SEARCH='bilisearch:' -p 8090:8090 -v ./music:/app/music hanxi/xiaomusic
docker run -e MI_USER='your-xiaomi-account' \
-e MI_PASS='your-xiaomi-password' \
-e MI_DID='your-xiaomi-speaker-mid' \
-e MI_HARDWARE='L07A' \
-e XIAOMUSIC_PROXY='proxy-for-yt-dlp' \
-e XIAOMUSIC_HOSTNAME=192.168.2.5 \
-e XIAOMUSIC_SEARCH='bilisearch:' \
-p 8090:8090 \
-v ./music:/app/music hanxi/xiaomusic
```
- XIAOMUSIC_SEARCH 可以配置为 'bilisearch:' 表示歌曲从哔哩哔哩下载;
- 配置为 'ytsearch:' 表示歌曲从 youtube 下载。
- XIAOMUSIC_PROXY 用于配置代理,默认为空;
@@ -137,6 +172,34 @@ services:
- 新功能
- 显示正在播放的歌曲
- 模糊搜索本地歌曲
- 设置页面
采用新的设置页面之后,必须在启动前配置的环境变量只剩下:
- MI_USER
- MI_PASS
- XIAOMUSIC_HOSTNAME
其他的这些可以在网页里配置:
- MI_DID
- MI_HARDWARE
- XIAOMUSIC_SEARCH
- XIAOMUSIC_PROXY
## 网络歌单功能
可以配置一个 json 格式的歌单,支持电台和歌曲,也可以直接用别人分享的链接,具体用法见 <https://github.com/hanxi/xiaomusic/issues/78>
## 更多其他可选配置
- XIAOMUSIC_ACTIVE_CMD 配置唤醒命令,具体见 <https://github.com/hanxi/xiaomusic/pull/43>
- XIAOMUSIC_EXCLUDE_DIRS 配置歌曲目录里需要忽略的目录
- XIAOMUSIC_MUSIC_PATH_DEPTH 配置歌曲目录搜索深度,具体见 <https://github.com/hanxi/xiaomusic/issues/76>
- XIAOMUSIC_DISABLE_HTTPAUTH 配置成 false 表示开启密码访问web控制台具体见 <https://github.com/hanxi/xiaomusic/issues/47>
- XIAOMUSIC_HTTPAUTH_USERNAME 配置 web 控制台用户
- XIAOMUSIC_HTTPAUTH_PASSWORD 配置 web 控制台密码
- XIAOMUSIC_CONF_PATH 用来存放配置文件的目录记得把目录映射到主机默认情况会把配置存放在music目录具体见 <https://github.com/hanxi/xiaomusic/issues/74>
- XIAOMUSIC_VERBOSE 设置为 true 时开启 debug 日志,用于排查问题
## 讨论区
@@ -158,3 +221,6 @@ services:
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=hanxi/xiaomusic&type=Date)](https://star-history.com/#hanxi/xiaomusic&Date)
## 赞赏
谢谢就够了

View File

@@ -3,6 +3,7 @@
set -e
version_file=./pyproject.toml
init_file=./xiaomusic/__init__.py
# 获取当前版本号
current_version=$(grep -oE "version = \"[0-9]+\.[0-9]+\.[0-9]+\"" $version_file | cut -d'"' -f2)
echo "当前版本号: "$current_version
@@ -24,11 +25,13 @@ new_version="$major.$minor.$patch"
# 将新版本号写入文件
sed -i "s/version.*/version = \"$new_version\"/g" $version_file
sed -i "s/__version__.*/__version__ = \"$new_version\"/g" $init_file
echo "新版本号:$new_version"
git diff
git add $version_file
git add $init_file
git commit -m "new version v$new_version"
git tag v$new_version
git push -u origin main --tags

75
pdm.lock generated
View File

@@ -2,10 +2,10 @@
# It is not intended for manual editing.
[metadata]
groups = ["default"]
groups = ["default", "lint"]
strategy = ["cross_platform"]
lock_version = "4.4.1"
content_hash = "sha256:ef2667ea2d19174a0be9e4d6442b8c749f0ac3be48bbccf5f604ee436aaf4d22"
content_hash = "sha256:813253734c7d7835a76cd87fe8fe0329e02ad067f535aee6a9e11cb106569dd2"
[[package]]
name = "aiohttp"
@@ -356,6 +356,18 @@ files = [
{file = "flask-3.0.3.tar.gz", hash = "sha256:ceb27b0af3823ea2737928a4d99d125a06175b8512c445cbd9a9ce200ef76842"},
]
[[package]]
name = "flask-httpauth"
version = "4.8.0"
summary = "HTTP authentication for Flask routes"
dependencies = [
"flask",
]
files = [
{file = "Flask-HTTPAuth-4.8.0.tar.gz", hash = "sha256:66568a05bc73942c65f1e2201ae746295816dc009edd84b482c44c758d75097a"},
{file = "Flask_HTTPAuth-4.8.0-py3-none-any.whl", hash = "sha256:a58fedd09989b9975448eef04806b096a3964a7feeebc0a78831ff55685b62b0"},
]
[[package]]
name = "flask"
version = "3.0.3"
@@ -507,8 +519,8 @@ files = [
[[package]]
name = "miservice-fork"
version = "2.4.4"
requires_python = ">=3.7"
version = "2.5.0"
requires_python = ">=3.8"
summary = "XiaoMi Cloud Service fork from https://github.com/Yonsm/MiService"
dependencies = [
"aiohttp",
@@ -516,8 +528,8 @@ dependencies = [
"rich",
]
files = [
{file = "miservice_fork-2.4.4-py3-none-any.whl", hash = "sha256:72aa5c13df290e1582104848743a2bda1bce8165e86f089c3a41d99835c70f13"},
{file = "miservice_fork-2.4.4.tar.gz", hash = "sha256:381838c11c7f9a36fb358ad5bb9e2554975f91716897fa9395a5edf2014f6587"},
{file = "miservice_fork-2.5.0-py3-none-any.whl", hash = "sha256:97b6360ea53c34fe035ac9d94e8705f305b8fa7fc2b44a7aea182449a76cb622"},
{file = "miservice_fork-2.5.0.tar.gz", hash = "sha256:8ca2d370d5b32f7e330add38aa1912d734aefa7880f16cef9eac110a5a3029e2"},
]
[[package]]
@@ -620,8 +632,8 @@ files = [
[[package]]
name = "requests"
version = "2.31.0"
requires_python = ">=3.7"
version = "2.32.3"
requires_python = ">=3.8"
summary = "Python HTTP for Humans."
dependencies = [
"certifi>=2017.4.17",
@@ -630,8 +642,8 @@ dependencies = [
"urllib3<3,>=1.21.1",
]
files = [
{file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"},
{file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"},
{file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
{file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
]
[[package]]
@@ -648,6 +660,31 @@ files = [
{file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"},
]
[[package]]
name = "ruff"
version = "0.4.9"
requires_python = ">=3.7"
summary = "An extremely fast Python linter and code formatter, written in Rust."
files = [
{file = "ruff-0.4.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b262ed08d036ebe162123170b35703aaf9daffecb698cd367a8d585157732991"},
{file = "ruff-0.4.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:98ec2775fd2d856dc405635e5ee4ff177920f2141b8e2d9eb5bd6efd50e80317"},
{file = "ruff-0.4.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4555056049d46d8a381f746680db1c46e67ac3b00d714606304077682832998e"},
{file = "ruff-0.4.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e91175fbe48f8a2174c9aad70438fe9cb0a5732c4159b2a10a3565fea2d94cde"},
{file = "ruff-0.4.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e8e7b95673f22e0efd3571fb5b0cf71a5eaaa3cc8a776584f3b2cc878e46bff"},
{file = "ruff-0.4.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2d45ddc6d82e1190ea737341326ecbc9a61447ba331b0a8962869fcada758505"},
{file = "ruff-0.4.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:78de3fdb95c4af084087628132336772b1c5044f6e710739d440fc0bccf4d321"},
{file = "ruff-0.4.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:06b60f91bfa5514bb689b500a25ba48e897d18fea14dce14b48a0c40d1635893"},
{file = "ruff-0.4.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88bffe9c6a454bf8529f9ab9091c99490578a593cc9f9822b7fc065ee0712a06"},
{file = "ruff-0.4.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:673bddb893f21ab47a8334c8e0ea7fd6598ecc8e698da75bcd12a7b9d0a3206e"},
{file = "ruff-0.4.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8c1aff58c31948cc66d0b22951aa19edb5af0a3af40c936340cd32a8b1ab7438"},
{file = "ruff-0.4.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:784d3ec9bd6493c3b720a0b76f741e6c2d7d44f6b2be87f5eef1ae8cc1d54c84"},
{file = "ruff-0.4.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:732dd550bfa5d85af8c3c6cbc47ba5b67c6aed8a89e2f011b908fc88f87649db"},
{file = "ruff-0.4.9-py3-none-win32.whl", hash = "sha256:8064590fd1a50dcf4909c268b0e7c2498253273309ad3d97e4a752bb9df4f521"},
{file = "ruff-0.4.9-py3-none-win_amd64.whl", hash = "sha256:e0a22c4157e53d006530c902107c7f550b9233e9706313ab57b892d7197d8e52"},
{file = "ruff-0.4.9-py3-none-win_arm64.whl", hash = "sha256:5d5460f789ccf4efd43f265a58538a2c24dbce15dbf560676e430375f20a8198"},
{file = "ruff-0.4.9.tar.gz", hash = "sha256:f1cb0828ac9533ba0135d148d214e284711ede33640465e706772645483427e3"},
]
[[package]]
name = "typing-extensions"
version = "4.9.0"
@@ -668,6 +705,16 @@ files = [
{file = "urllib3-2.0.6.tar.gz", hash = "sha256:b19e1a85d206b56d7df1d5e683df4a7725252a964e3993648dd0fb5a1c157564"},
]
[[package]]
name = "waitress"
version = "3.0.0"
requires_python = ">=3.8.0"
summary = "Waitress WSGI server"
files = [
{file = "waitress-3.0.0-py3-none-any.whl", hash = "sha256:2a06f242f4ba0cc563444ca3d1998959447477363a2d7e9b8b4d75d35cfd1669"},
{file = "waitress-3.0.0.tar.gz", hash = "sha256:005da479b04134cdd9dd602d1ee7c49d79de0537610d653674cc6cbde222b8a1"},
]
[[package]]
name = "websockets"
version = "12.0"
@@ -784,7 +831,7 @@ files = [
[[package]]
name = "yt-dlp"
version = "2024.5.13.232704.dev0"
version = "2024.6.17.232743.dev0"
requires_python = ">=3.8"
summary = "A feature-rich command-line audio/video downloader"
dependencies = [
@@ -793,11 +840,11 @@ dependencies = [
"certifi",
"mutagen",
"pycryptodomex",
"requests<3,>=2.31.0",
"requests<3,>=2.32.2",
"urllib3<3,>=1.26.17",
"websockets>=12.0",
]
files = [
{file = "yt_dlp-2024.5.13.232704.dev0-py3-none-any.whl", hash = "sha256:3fa43b489ce80f1d065c6d15a389e4e7d553252069c54eb21e739a6921de5fe1"},
{file = "yt_dlp-2024.5.13.232704.dev0.tar.gz", hash = "sha256:67f615650843a6663d1dc127f6367f0cc08ef515ea4fe96bbd8a00772743a8ca"},
{file = "yt_dlp-2024.6.17.232743.dev0-py3-none-any.whl", hash = "sha256:dd6e7e194b96e778691f58a0cb6b42956cf956b22f6bb1a12bdef5ab3ac0c9ad"},
{file = "yt_dlp-2024.6.17.232743.dev0.tar.gz", hash = "sha256:2f6f44eff755a7b051cdcd3c4375771033dbeb64d6164351022efdc67cce0c52"},
]

View File

@@ -1,18 +1,19 @@
[project]
name = "xiaomusic"
version = "0.1.29"
version = "0.1.64"
description = "Play Music with xiaomi AI speaker"
authors = [
{name = "涵曦", email = "im.hanxi@gmail.com"},
]
dependencies = [
"rich>=13.6.0",
"requests>=2.31.0",
"aiohttp>=3.8.6",
"miservice-fork>=2.4.4",
"miservice-fork>=2.5.0",
"mutagen>=1.47.0",
"yt-dlp>=2024.04.09",
"flask[async]>=3.0.1",
"waitress>=3.0.0",
"flask-HTTPAuth>=4.8.0",
]
requires-python = ">=3.10"
readme = "README.md"
@@ -21,3 +22,31 @@ license = {text = "MIT"}
[build-system]
requires = ["pdm-backend"]
build-backend = "pdm.backend"
[tool.pdm]
[tool.pdm.dev-dependencies]
lint = [
"ruff>=0.4.8",
]
[tool.ruff]
lint.select = [
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"E", # pycodestyle - Error
"F", # Pyflakes
"I", # isort
"W", # pycodestyle - Warning
"UP", # pyupgrade
]
lint.ignore = [
"E501", # line-too-long
"W191", # tab-indentation
]
include = ["**/*.py", "**/*.pyi", "**/pyproject.toml"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.pdm.scripts]
lint = "ruff check ."
fmt = "ruff format ."

View File

@@ -223,6 +223,9 @@ colorama==0.4.6; platform_system == "Windows" \
flask==3.0.3 \
--hash=sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3 \
--hash=sha256:ceb27b0af3823ea2737928a4d99d125a06175b8512c445cbd9a9ce200ef76842
flask-HTTPAuth==4.8.0 \
--hash=sha256:66568a05bc73942c65f1e2201ae746295816dc009edd84b482c44c758d75097a \
--hash=sha256:a58fedd09989b9975448eef04806b096a3964a7feeebc0a78831ff55685b62b0
frozenlist==1.4.0 \
--hash=sha256:008eb8b31b3ea6896da16c38c1b136cb9fec9e249e77f6211d479db79a4eaf01 \
--hash=sha256:09163bdf0b2907454042edb19f887c6d33806adc71fbd54afc14908bfdc22251 \
@@ -302,9 +305,9 @@ MarkupSafe==2.1.4 \
mdurl==0.1.2 \
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
miservice-fork==2.4.4 \
--hash=sha256:381838c11c7f9a36fb358ad5bb9e2554975f91716897fa9395a5edf2014f6587 \
--hash=sha256:72aa5c13df290e1582104848743a2bda1bce8165e86f089c3a41d99835c70f13
miservice-fork==2.5.0 \
--hash=sha256:8ca2d370d5b32f7e330add38aa1912d734aefa7880f16cef9eac110a5a3029e2 \
--hash=sha256:97b6360ea53c34fe035ac9d94e8705f305b8fa7fc2b44a7aea182449a76cb622
multidict==6.0.4 \
--hash=sha256:01a3a55bd90018c9c080fbb0b9f4891db37d148a0a18722b42f94694f8b6d4c9 \
--hash=sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8 \
@@ -368,9 +371,9 @@ pycryptodomex==3.19.0 \
pygments==2.16.1 \
--hash=sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692 \
--hash=sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29
requests==2.31.0 \
--hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f \
--hash=sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1
requests==2.32.3 \
--hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \
--hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6
rich==13.7.1 \
--hash=sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222 \
--hash=sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432
@@ -380,6 +383,9 @@ typing-extensions==4.9.0; python_version < "3.11" \
urllib3==2.0.6 \
--hash=sha256:7a7c7003b000adf9e7ca2a377c9688bbc54ed41b985789ed576570342a375cd2 \
--hash=sha256:b19e1a85d206b56d7df1d5e683df4a7725252a964e3993648dd0fb5a1c157564
waitress==3.0.0 \
--hash=sha256:005da479b04134cdd9dd602d1ee7c49d79de0537610d653674cc6cbde222b8a1 \
--hash=sha256:2a06f242f4ba0cc563444ca3d1998959447477363a2d7e9b8b4d75d35cfd1669
websockets==12.0 \
--hash=sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b \
--hash=sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6 \
@@ -466,6 +472,6 @@ yarl==1.9.2 \
--hash=sha256:d4e2c6d555e77b37288eaf45b8f60f0737c9efa3452c6c44626a5455aeb250b9 \
--hash=sha256:e9fdc7ac0d42bc3ea78818557fab03af6181e076a2944f43c38684b4b6bed8e3 \
--hash=sha256:ee4afac41415d52d53a9833ebae7e32b344be72835bbb589018c9e938045a560
yt-dlp==2024.5.13.232704.dev0 \
--hash=sha256:3fa43b489ce80f1d065c6d15a389e4e7d553252069c54eb21e739a6921de5fe1 \
--hash=sha256:67f615650843a6663d1dc127f6367f0cc08ef515ea4fe96bbd8a00772743a8ca
yt-dlp==2024.6.17.232743.dev0 \
--hash=sha256:2f6f44eff755a7b051cdcd3c4375771033dbeb64d6164351022efdc67cce0c52 \
--hash=sha256:dd6e7e194b96e778691f58a0cb6b42956cf956b22f6bb1a12bdef5ab3ac0c9ad

View File

@@ -1 +1 @@
pdm export -o requirements.txt
pdm export --prod -o requirements.txt

View File

@@ -0,0 +1 @@
__version__ = "0.1.64"

View File

@@ -27,20 +27,6 @@ def main():
dest="cookie",
help="xiaomi cookie",
)
parser.add_argument(
"--use_command",
dest="use_command",
action="store_true",
default=None,
help="use command to tts",
)
parser.add_argument(
"--mute_xiaoai",
dest="mute_xiaoai",
action="store_true",
default=None,
help="try to mute xiaoai answer",
)
parser.add_argument(
"--verbose",
dest="verbose",

View File

@@ -3,36 +3,13 @@ from __future__ import annotations
import argparse
import json
import os
from dataclasses import dataclass, field
from typing import Any, Iterable
from dataclasses import dataclass
from xiaomusic.utils import validate_proxy
LATEST_ASK_API = "https://userprofile.mina.mi.com/device_profile/v2/conversation?source=dialogu&hardware={hardware}&timestamp={timestamp}&limit=2"
COOKIE_TEMPLATE = "deviceId={device_id}; serviceToken={service_token}; userId={user_id}"
HARDWARE_COMMAND_DICT = {
# hardware: (tts_command, wakeup_command, volume_command)
"LX06": ("5-1", "5-5", "2-1"),
"L05B": ("5-3", "5-4", "2-1"),
"S12A": ("5-1", "5-5", "2-1"),
"LX01": ("5-1", "5-5", "2-1"),
"L06A": ("5-1", "5-5", "2-1"),
"LX04": ("5-1", "5-4", "2-1"),
"L05C": ("5-3", "5-4", "2-1"),
"L17A": ("7-3", "7-4", "2-1"),
"X08E": ("7-3", "7-4", "2-1"),
"LX05A": ("5-1", "5-5", "2-1"), # 小爱红外版
"LX5A": ("5-1", "5-5", "2-1"), # 小爱红外版
"L07A": ("5-1", "5-5", "2-1"), # Redmi小爱音箱Play(l7a)
"L15A": ("7-3", "7-4", "2-1"),
"X6A": ("7-3", "7-4", "2-1"), # 小米智能家庭屏6
"X10A": ("7-3", "7-4", "2-1"), # 小米智能家庭屏10
# add more here
}
DEFAULT_COMMAND = ("5-1", "5-5", "2-1")
KEY_WORD_DICT = {
"播放歌曲": "play",
"放歌曲": "play",
@@ -43,6 +20,8 @@ KEY_WORD_DICT = {
"关机": "stop",
"停止播放": "stop",
"分钟后关机": "stop_after_minute",
"播放列表": "play_music_list",
"刷新列表": "gen_music_list",
"set_volume#": "set_volume",
"get_volume#": "get_volume",
}
@@ -65,11 +44,15 @@ KEY_MATCH_ORDER = [
"随机播放",
"关机",
"停止播放",
"刷新列表",
"播放列表",
]
SUPPORT_MUSIC_TYPE = [
".mp3",
".flac",
".wav",
".ape",
]
@@ -79,11 +62,10 @@ class Config:
account: str = os.getenv("MI_USER", "")
password: str = os.getenv("MI_PASS", "")
mi_did: str = os.getenv("MI_DID", "")
mute_xiaoai: bool = True
cookie: str = ""
use_command: bool = False
verbose: bool = False
verbose: bool = os.getenv("XIAOMUSIC_VERBOSE", "").lower() == "true"
music_path: str = os.getenv("XIAOMUSIC_MUSIC_PATH", "music")
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"))
proxy: str | None = os.getenv("XIAOMUSIC_PROXY", None)
@@ -92,23 +74,20 @@ class Config:
) # "bilisearch:" or "ytsearch:"
ffmpeg_location: str = os.getenv("XIAOMUSIC_FFMPEG_LOCATION", "./ffmpeg/bin")
active_cmd: str = os.getenv("XIAOMUSIC_ACTIVE_CMD", "play,random_play")
exclude_dirs: str = os.getenv("XIAOMUSIC_EXCLUDE_DIRS", "@eaDir")
music_path_depth: int = int(os.getenv("XIAOMUSIC_MUSIC_PATH_DEPTH", "10"))
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")
music_list_url: str = os.getenv("XIAOMUSIC_MUSIC_LIST_URL", "")
music_list_json: str = os.getenv("XIAOMUSIC_MUSIC_LIST_JSON", "")
def __post_init__(self) -> None:
if self.proxy:
validate_proxy(self.proxy)
@property
def tts_command(self) -> str:
return HARDWARE_COMMAND_DICT.get(self.hardware, DEFAULT_COMMAND)[0]
@property
def wakeup_command(self) -> str:
return HARDWARE_COMMAND_DICT.get(self.hardware, DEFAULT_COMMAND)[1]
@property
def volume_command(self) -> str:
return HARDWARE_COMMAND_DICT.get(self.hardware, DEFAULT_COMMAND)[2]
@classmethod
def from_options(cls, options: argparse.Namespace) -> Config:
config = {}

View File

@@ -1,15 +1,24 @@
#!/usr/bin/env python3
import os
import traceback
from flask import Flask, request, send_from_directory
from threading import Thread
from flask import Flask, request, send_from_directory
from flask_httpauth import HTTPBasicAuth
from waitress import serve
from xiaomusic import (
__version__,
)
from xiaomusic.config import (
KEY_WORD_DICT,
)
from xiaomusic.utils import (
downloadfile,
)
app = Flask(__name__)
auth = HTTPBasicAuth()
host = "0.0.0.0"
port = 8090
static_path = "music"
@@ -17,33 +26,67 @@ xiaomusic = None
log = None
@auth.verify_password
def verify_password(username, password):
if xiaomusic.config.disable_httpauth:
return True
if (
xiaomusic.config.httpauth_username == username
and xiaomusic.config.httpauth_password == password
):
return username
@app.route("/allcmds")
@auth.login_required
def allcmds():
return KEY_WORD_DICT
@app.route("/getversion", methods=["GET"])
def getversion():
log.debug("getversion %s", __version__)
return {
"version": __version__,
}
@app.route("/getvolume", methods=["GET"])
@auth.login_required
def getvolume():
volume = xiaomusic.get_volume_ret()
return {
"volume": volume,
}
@app.route("/searchmusic", methods=["GET"])
@auth.login_required
def searchmusic():
name = request.args.get('name')
name = request.args.get("name")
return xiaomusic.searchmusic(name)
@app.route("/playingmusic", methods=["GET"])
@auth.login_required
def playingmusic():
return xiaomusic.playingmusic()
@app.route("/isplaying", methods=["GET"])
@auth.login_required
def isplaying():
return xiaomusic.isplaying()
@app.route("/", methods=["GET"])
def redirect_to_index():
def index():
return send_from_directory("static", "index.html")
@app.route("/cmd", methods=["POST"])
@auth.login_required
async def do_cmd():
data = request.get_json()
cmd = data.get("cmd")
@@ -54,6 +97,69 @@ async def do_cmd():
return {"ret": "Unknow cmd"}
@app.route("/getsetting", methods=["GET"])
@auth.login_required
async def getsetting():
config = xiaomusic.getconfig()
log.debug(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,
}
return data
@app.route("/savesetting", methods=["POST"])
@auth.login_required
async def savesetting():
data = request.get_json()
log.info(data)
await xiaomusic.saveconfig(data)
return "save success"
@app.route("/musiclist", methods=["GET"])
@auth.login_required
async def musiclist():
return xiaomusic.get_music_list()
@app.route("/curplaylist", methods=["GET"])
@auth.login_required
async def curplaylist():
return xiaomusic.get_cur_play_list()
@app.route("/delmusic", methods=["POST"])
@auth.login_required
def delmusic():
data = request.get_json()
log.info(data)
xiaomusic.del_music(data["name"])
return "success"
@app.route("/downloadjson", methods=["POST"])
@auth.login_required
def downloadjson():
data = request.get_json()
log.info(data)
ret, content = downloadfile(data["url"])
return {
"ret": ret,
"content": content,
}
def static_path_handler(filename):
log.debug(filename)
log.debug(static_path)
@@ -63,7 +169,7 @@ def static_path_handler(filename):
def run_app():
app.run(host=host, port=port)
serve(app, host=host, port=port)
def StartHTTPServer(_port, _static_path, _xiaomusic):

View File

@@ -1,11 +1,11 @@
$(function(){
$container=$("#cmds");
append_op_button_name("下一首");
append_op_button_name("全部循环");
append_op_button_name("关机");
append_op_button_name("单曲循环");
append_op_button_name("播放歌曲");
append_op_button_name("随机播放");
append_op_button_name("刷新列表");
append_op_button_name("下一首");
append_op_button_name("关机");
$container.append($("<hr>"));
@@ -14,11 +14,74 @@ $(function(){
append_op_button_name("60分钟后关机");
// 拉取声音
sendcmd("get_volume#");
$.get("/getvolume", function(data, status) {
console.log(data, status, data["volume"]);
$("#volume").val(data.volume);
});
// 拉取版本
$.get("/getversion", function(data, status) {
console.log(data, status, data["version"]);
$("#version").text(`(${data.version})`);
});
// 拉取播放列表
function refresh_music_list() {
$('#music_list').empty();
$.get("/musiclist", function(data, status) {
console.log(data, status);
$.each(data, function(key, value) {
$('#music_list').append($('<option></option>').val(key).text(key));
});
$('#music_list').change(function() {
const selectedValue = $(this).val();
$('#music_name').empty();
const sorted_musics = data[selectedValue].sort(custom_sort_key);
$.each(sorted_musics, function(index, item) {
$('#music_name').append($('<option></option>').val(item).text(item));
});
});
$('#music_list').trigger('change');
// 获取当前播放列表
$.get("curplaylist", function(data, status) {
$('#music_list').val(data);
$('#music_list').trigger('change');
})
})
}
refresh_music_list();
$("#play_music_list").on("click", () => {
var music_list = $("#music_list").val();
var music_name = $("#music_name").val();
let cmd = "播放列表" + music_list + "|" + music_name;
sendcmd(cmd);
});
$("#del_music").on("click", () => {
var del_music_name = $("#music_name").val();
if (confirm(`确定删除歌曲 ${del_music_name} 吗?`)) {
console.log(`删除歌曲 ${del_music_name}`);
$.ajax({
type: 'POST',
url: '/delmusic',
data: JSON.stringify({"name": del_music_name}),
contentType: "application/json; charset=utf-8",
success: () => {
alert(`删除 ${del_music_name} 成功`);
refresh_music_list();
},
error: () => {
alert(`删除 ${del_music_name} 失败`);
}
});
}
});
function append_op_button_name(name) {
append_op_button(name, name);
}
@@ -40,8 +103,8 @@ $(function(){
$("#play").on("click", () => {
var search_key = $("#music-name").val();
var filename=$("#music-filename").val();
let cmd = "播放歌曲"+search_key+"|"+filename;
var filename = $("#music-filename").val();
let cmd = "播放歌曲" + search_key + "|" + filename;
sendcmd(cmd);
});
@@ -54,10 +117,12 @@ $(function(){
$.ajax({
type: "POST",
url: "/cmd",
contentType: "application/json",
contentType: "application/json; charset=utf-8",
data: JSON.stringify({cmd: cmd}),
success: () => {
// 请求成功时执行的操作
if (cmd == "刷新列表") {
setTimeout(refresh_music_list, 3000);
}
},
error: () => {
// 请求失败时执行的操作
@@ -99,4 +164,23 @@ $(function(){
setInterval(() => {
get_playing_music();
}, 3000);
function custom_sort_key(a, b) {
// 使用正则表达式提取数字前缀
const numericPrefixA = a.match(/^(\d+)/) ? parseInt(a.match(/^(\d+)/)[1], 10) : null;
const numericPrefixB = b.match(/^(\d+)/) ? parseInt(b.match(/^(\d+)/)[1], 10) : null;
// 如果两个键都有数字前缀,则按数字大小排序
if (numericPrefixA !== null && numericPrefixB !== null) {
return numericPrefixA - numericPrefixB;
}
// 如果一个键有数字前缀而另一个没有,则有数字前缀的键排在前面
if (numericPrefixA !== null) return -1;
if (numericPrefixB !== null) return 1;
// 如果两个键都没有数字前缀,则按照常规字符串排序
return a.localeCompare(b);
}
});

View File

@@ -1,74 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<head>
<meta name="viewport" content="width=device-width">
<title>小爱音箱操控面板</title>
<script src="/static/jquery-3.7.1.min.js"></script>
<script src="/static/app.js"></script>
<style>
button {
margin: 10px;
width: 100px;
height: 50px;
border: none;
color: white;
text-align: center;
text-decoration: none;
display: inline-block;
border-radius: 10px;
background-color: #008CBA;
}
button:active {
font-weight:bold;
background-color: #007CBA;
transform: translateY(2px);
}
input {
margin: 10px;
width: 300px;
height: 40px;
}
.container{
width: 280px;
overflow: hidden;
display: inline-block;
}
@keyframes text-scroll {
0% {
left: 100%;
}
25% {
left: 50%;
}
50% {
left: 0%;
}
75% {
left: -50%;
}
100% {
left: -100%;
}
}
.text {
white-space: nowrap;
font-weight: bold;
position: relative;
animation: text-scroll 10s linear infinite;
}
</style>
</head>
<body>
<h2>小爱音箱操控面板</h2>
<link rel="stylesheet" type="text/css" href="/static/style.css">
</head>
<body>
<h2>小爱音箱操控面板<span id="version">(版本未知)</span></h2>
<hr>
<div id="cmds">
</div>
<hr>
<div style="margin-left: 20px;">
<div style="margin: 20px;">
<div style="display: flex; align-items: center;">
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" fill="#8e43e7" style="height: 48px; width: 48px;"><path d="M550.826667 154.666667a47.786667 47.786667 0 0 0-19.84 4.48L298.666667 298.666667H186.453333A80 80 0 0 0 106.666667 378.453333v267.093334A80 80 0 0 0 186.453333 725.333333H298.666667l232.32 139.52a47.786667 47.786667 0 0 0 19.84 4.48A46.506667 46.506667 0 0 0 597.333333 822.826667V201.173333a46.506667 46.506667 0 0 0-46.506666-46.506666zM554.666667 822.826667c0 3.413333-3.84 3.84-3.84 3.84L320 688.853333l-9.6-6.186666H186.453333A37.12 37.12 0 0 1 149.333333 645.546667V378.453333A37.12 37.12 0 0 1 186.453333 341.333333h123.946667l10.24-6.186666 229.546667-137.6s3.84 0 3.84 3.84zM667.52 346.026667a21.333333 21.333333 0 0 0 0 30.293333 192 192 0 0 1 0 271.36 21.333333 21.333333 0 0 0 0 30.293333 21.333333 21.333333 0 0 0 30.293333 0 234.666667 234.666667 0 0 0 0-331.946666 21.333333 21.333333 0 0 0-30.293333 0z"></path><path d="M804.48 219.52a21.333333 21.333333 0 0 0-30.293333 30.293333 370.986667 370.986667 0 0 1 0 524.373334 21.333333 21.333333 0 0 0 0 30.293333 21.333333 21.333333 0 0 0 30.293333 0 414.08 414.08 0 0 0 0-584.96z"></path></svg>
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" fill="#8e43e7" style="height: 48px; width: 48px;"><path d="M550.826667 154.666667a47.786667 47.786667 0 0 0-19.84 4.48L298.666667 298.666667H186.453333A80 80 0 0 0 106.666667 378.453333v267.093334A80 80 0 0 0 186.453333 725.333333H298.666667l232.32 139.52a47.786667 47.786667 0 0 0 19.84 4.48A46.506667 46.506667 0 0 0 597.333333 822.826667V201.173333a46.506667 46.506667 0 0 0-46.506666-46.506666zM554.666667 822.826667c0 3.413333-3.84 3.84-3.84 3.84L320 688.853333l-9.6-6.186666H186.453333A37.12 37.12 0 0 1 149.333333 645.546667V378.453333A37.12 37.12 0 0 1 186.453333 341.333333h123.946667l10.24-6.186666 229.546667-137.6s3.84 0 3.84 3.84zM667.52 346.026667a21.333333 21.333333 0 0 0 0 30.293333 192 192 0 0 1 0 271.36 21.333333 21.333333 0 0 0 0 30.293333 21.333333 21.333333 0 0 0 30.293333 0 234.666667 234.666667 0 0 0 0-331.946666 21.333333 21.333333 0 0 0-30.293333 0z"></path><path d="M804.48 219.52a21.333333 21.333333 0 0 0-30.293333 30.293333 370.986667 370.986667 0 0 1 0 524.373334 21.333333 21.333333 0 0 0 0 30.293333 21.333333 21.333333 0 0 0 30.293333 0 414.08 414.08 0 0 0 0-584.96z"></path></svg>
<input id="volume" type="range"></input>
<a href="/static/setting.html">
<svg fill="#8e43e7" height="48px" width="48px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="-11.88 -11.88 77.76 77.76" xml:space="preserve" stroke="#8e43e7" transform="rotate(0)matrix(1, 0, 0, 1, 0, 0)" stroke-width="0.00054"><g id="SVGRepo_bgCarrier" stroke-width="0" transform="translate(0,0), scale(1)"><rect x="-11.88" y="-11.88" width="77.76" height="77.76" rx="18.6624" fill="#addcff" strokewidth="0"></rect></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="1.512"></g><g id="SVGRepo_iconCarrier"> <g> <path d="M51.22,21h-5.052c-0.812,0-1.481-0.447-1.792-1.197s-0.153-1.54,0.42-2.114l3.572-3.571 c0.525-0.525,0.814-1.224,0.814-1.966c0-0.743-0.289-1.441-0.814-1.967l-4.553-4.553c-1.05-1.05-2.881-1.052-3.933,0l-3.571,3.571 c-0.574,0.573-1.366,0.733-2.114,0.421C33.447,9.313,33,8.644,33,7.832V2.78C33,1.247,31.753,0,30.22,0H23.78 C22.247,0,21,1.247,21,2.78v5.052c0,0.812-0.447,1.481-1.197,1.792c-0.748,0.313-1.54,0.152-2.114-0.421l-3.571-3.571 c-1.052-1.052-2.883-1.05-3.933,0l-4.553,4.553c-0.525,0.525-0.814,1.224-0.814,1.967c0,0.742,0.289,1.44,0.814,1.966l3.572,3.571 c0.573,0.574,0.73,1.364,0.42,2.114S8.644,21,7.832,21H2.78C1.247,21,0,22.247,0,23.78v6.439C0,31.753,1.247,33,2.78,33h5.052 c0.812,0,1.481,0.447,1.792,1.197s0.153,1.54-0.42,2.114l-3.572,3.571c-0.525,0.525-0.814,1.224-0.814,1.966 c0,0.743,0.289,1.441,0.814,1.967l4.553,4.553c1.051,1.051,2.881,1.053,3.933,0l3.571-3.572c0.574-0.573,1.363-0.731,2.114-0.42 c0.75,0.311,1.197,0.98,1.197,1.792v5.052c0,1.533,1.247,2.78,2.78,2.78h6.439c1.533,0,2.78-1.247,2.78-2.78v-5.052 c0-0.812,0.447-1.481,1.197-1.792c0.751-0.312,1.54-0.153,2.114,0.42l3.571,3.572c1.052,1.052,2.883,1.05,3.933,0l4.553-4.553 c0.525-0.525,0.814-1.224,0.814-1.967c0-0.742-0.289-1.44-0.814-1.966l-3.572-3.571c-0.573-0.574-0.73-1.364-0.42-2.114 S45.356,33,46.168,33h5.052c1.533,0,2.78-1.247,2.78-2.78V23.78C54,22.247,52.753,21,51.22,21z M52,30.22 C52,30.65,51.65,31,51.22,31h-5.052c-1.624,0-3.019,0.932-3.64,2.432c-0.622,1.5-0.295,3.146,0.854,4.294l3.572,3.571 c0.305,0.305,0.305,0.8,0,1.104l-4.553,4.553c-0.304,0.304-0.799,0.306-1.104,0l-3.571-3.572c-1.149-1.149-2.794-1.474-4.294-0.854 c-1.5,0.621-2.432,2.016-2.432,3.64v5.052C31,51.65,30.65,52,30.22,52H23.78C23.35,52,23,51.65,23,51.22v-5.052 c0-1.624-0.932-3.019-2.432-3.64c-0.503-0.209-1.021-0.311-1.533-0.311c-1.014,0-1.997,0.4-2.761,1.164l-3.571,3.572 c-0.306,0.306-0.801,0.304-1.104,0l-4.553-4.553c-0.305-0.305-0.305-0.8,0-1.104l3.572-3.571c1.148-1.148,1.476-2.794,0.854-4.294 C10.851,31.932,9.456,31,7.832,31H2.78C2.35,31,2,30.65,2,30.22V23.78C2,23.35,2.35,23,2.78,23h5.052 c1.624,0,3.019-0.932,3.64-2.432c0.622-1.5,0.295-3.146-0.854-4.294l-3.572-3.571c-0.305-0.305-0.305-0.8,0-1.104l4.553-4.553 c0.304-0.305,0.799-0.305,1.104,0l3.571,3.571c1.147,1.147,2.792,1.476,4.294,0.854C22.068,10.851,23,9.456,23,7.832V2.78 C23,2.35,23.35,2,23.78,2h6.439C30.65,2,31,2.35,31,2.78v5.052c0,1.624,0.932,3.019,2.432,3.64 c1.502,0.622,3.146,0.294,4.294-0.854l3.571-3.571c0.306-0.305,0.801-0.305,1.104,0l4.553,4.553c0.305,0.305,0.305,0.8,0,1.104 l-3.572,3.571c-1.148,1.148-1.476,2.794-0.854,4.294c0.621,1.5,2.016,2.432,3.64,2.432h5.052C51.65,23,52,23.35,52,23.78V30.22z"></path> <path d="M27,18c-4.963,0-9,4.037-9,9s4.037,9,9,9s9-4.037,9-9S31.963,18,27,18z M27,34c-3.859,0-7-3.141-7-7s3.141-7,7-7 s7,3.141,7,7S30.859,34,27,34z"></path> </g> </g></svg>
</a>
</div>
</div>
<hr>
@@ -78,8 +29,22 @@
<input id="music-filename" type="text" placeholder="请输入保存为的文件名称(如:周杰伦七里香)"></input>
</div>
<button id="play">播放</button>
<div class="container">
<div class="cawait get_web_music_duration(url)ontainer">
<div id="playering-music" class="text"></div>
</div>
</body>
<hr>
<div class="rows">
<label for="music_list">播放列表:</label>
<select id="music_list"></select>
<label for="music_name">歌曲:</label>
<select id="music_name"></select>
</div>
<button id="play_music_list">播放列表歌曲</button>
<button id="del_music">删除选中歌曲</button>
<footer>
<p>Powered by <a href="https://github.com/hanxi/xiaomusic" target="_blank">xiaomusic</a></p>
</footer>
</body>
</html>

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<title>小爱音箱操控面板</title>
<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">
</head>
<body>
<h2>小爱音箱设置面板<span id="version">(版本未知)</span></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"></select>
<label for="xiaomusic_search">XIAOMUSIC_SEARCH:</label>
<select id="xiaomusic_search">
<option value="ytsearch:">ytsearch:</option>
<option value="bilisearch:">bilisearch:</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>
</div>
<hr>
<button onclick="location.href='/';">返回首页</button>
<button id="get_music_list">获取歌单</button>
<button id="save">保存</button>
<footer>
<p>Powered by <a href="https://github.com/hanxi/xiaomusic" target="_blank">xiaomusic</a></p>
</footer>
</body>
</html>

113
xiaomusic/static/setting.js Normal file
View File

@@ -0,0 +1,113 @@
$(function(){
// 拉取版本
$.get("/getversion", function(data, status) {
console.log(data, status, data["version"]);
$("#version").text(`(${data.version})`);
});
// 拉取现有配置
$.get("/getsetting", function(data, status) {
console.log(data, status);
var mi_did_div = $("#mi_did")
mi_did_div.empty();
$.each(data.mi_did_list, function(index, option){
mi_did_div.append($('<option>', {
value:option,
text:option,
}));
if (data.mi_did == option) {
mi_did_div.val(option);
}
});
var mi_hardware_div = $("#mi_hardware")
mi_hardware_div.empty();
$.each(data.mi_hardware_list, function(index, option){
mi_hardware_div.append($('<option>', {
value:option,
text:option,
}));
if (data.mi_hardware == option) {
mi_hardware_div.val(option);
}
});
if (data.xiaomusic_search != "") {
$("#xiaomusic_search").val(data.xiaomusic_search);
}
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);
}
});
$("#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,
};
$.ajax({
type: "POST",
url: "/savesetting",
contentType: "application/json",
data: JSON.stringify(data),
success: (msg) => {
alert(msg);
},
error: (msg) => {
alert(msg);
}
});
});
$("#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 data = {
url: xiaomusic_music_list_url,
};
$.ajax({
type: "POST",
url: "/downloadjson",
contentType: "application/json",
data: JSON.stringify(data),
success: (res) => {
if (res.ret == "OK") {
$("#xiaomusic_music_list_json").val(res.content);
} else {
console.log(res);
alert(res.ret);
}
},
error: (res) => {
console.log(res);
alert(res);
}
});
});
});

View File

@@ -0,0 +1,75 @@
button {
margin: 10px;
width: 100px;
height: 50px;
border: none;
color: white;
text-align: center;
text-decoration: none;
display: inline-block;
border-radius: 10px;
background-color: #008CBA;
}
button:active {
font-weight:bold;
background-color: #007CBA;
transform: translateY(2px);
}
label {
margin-left: 10px;
width: 300px;
}
input,select {
margin: 10px;
width: 300px;
height: 40px;
}
.container{
width: 280px;
overflow: hidden;
display: inline-block;
}
@keyframes text-scroll {
0% {
left: 100%;
}
25% {
left: 50%;
}
50% {
left: 0%;
}
75% {
left: -50%;
}
100% {
left: -100%;
}
}
.text {
white-space: nowrap;
font-weight: bold;
position: relative;
animation: text-scroll 10s linear infinite;
}
.rows {
display: flex;
flex-direction: column;
margin-left: 20px;
margin-right: 20px;
}
footer {
bottom: 0;
width: 100%;
text-align: center;
padding: 10px 0;
}
textarea{
margin: 10px;
width: 300px;
height: 200px;
}

View File

@@ -1,14 +1,17 @@
#!/usr/bin/env python3
from __future__ import annotations
import difflib
import os
import re
import socket
import tempfile
from collections.abc import AsyncIterator
from http.cookies import SimpleCookie
from typing import AsyncIterator
from urllib.parse import urlparse
import difflib
import aiohttp
import mutagen
import requests
from requests.utils import cookiejar_from_dict
@@ -62,6 +65,147 @@ def validate_proxy(proxy_str: str) -> bool:
return True
# 模糊搜索
def fuzzyfinder(user_input, collection):
return difflib.get_close_matches(user_input, collection, 10, cutoff=0.1)
# 歌曲排序
def custom_sort_key(s):
# 使用正则表达式分别提取字符串的数字前缀和数字后缀
prefix_match = re.match(r"^(\d+)", s)
suffix_match = re.search(r"(\d+)$", s)
numeric_prefix = int(prefix_match.group(0)) if prefix_match else None
numeric_suffix = int(suffix_match.group(0)) if suffix_match else None
if numeric_prefix is not None:
# 如果前缀是数字,先按前缀数字排序,再按整个字符串排序
return (0, numeric_prefix, s)
elif numeric_suffix is not None:
# 如果后缀是数字,先按前缀字符排序,再按后缀数字排序
return (1, s[: suffix_match.start()], numeric_suffix)
else:
# 如果前缀和后缀都不是数字,按字典序排序
return (2, s)
# fork from https://gist.github.com/dougthor42/001355248518bc64d2f8
def walk_to_depth(root, depth=None, *args, **kwargs):
"""
Wrapper around os.walk that stops after going down `depth` folders.
I had my own version, but it wasn't as efficient as
http://stackoverflow.com/a/234329/1354930, so I modified to be more
similar to nosklo's answer.
However, nosklo's answer doesn't work if topdown=False, so I kept my
version.
"""
# Let people use this as a standard `os.walk` function.
if depth is None:
return os.walk(root, *args, **kwargs)
# remove any trailing separators so that our counts are correct.
root = root.rstrip(os.path.sep)
def main_func(root, depth, *args, **kwargs):
"""Faster because it skips traversing dirs that are too deep."""
root_depth = root.count(os.path.sep)
for dirpath, dirnames, filenames in os.walk(root, *args, **kwargs):
yield (dirpath, dirnames, filenames)
# calculate how far down we are.
current_folder_depth = dirpath.count(os.path.sep)
if current_folder_depth >= root_depth + depth:
del dirnames[:]
def fallback_func(root, depth, *args, **kwargs):
"""Slower, but works when topdown is False"""
root_depth = root.count(os.path.sep)
for dirpath, dirnames, filenames in os.walk(root, *args, **kwargs):
current_folder_depth = dirpath.count(os.path.sep)
if current_folder_depth <= root_depth + depth:
yield (dirpath, dirnames, filenames)
# there's gotta be a better way do do this...
try:
if args[0] is False:
yield from fallback_func(root, depth, *args, **kwargs)
return
else:
yield from main_func(root, depth, *args, **kwargs)
return
except IndexError:
pass
try:
if kwargs["topdown"] is False:
yield from fallback_func(root, depth, *args, **kwargs)
return
else:
yield from main_func(root, depth, *args, **kwargs)
return
except KeyError:
yield from main_func(root, depth, *args, **kwargs)
return
def downloadfile(url):
try:
response = requests.get(url, timeout=5) # 增加超时以避免长时间挂起
response.raise_for_status() # 如果响应不是200引发HTTPError异常
return ("OK", response.text)
except requests.exceptions.HTTPError as errh:
return (f"HTTP Error: {errh}", "")
except requests.exceptions.ConnectionError as errc:
return (f"Error Connecting: {errc}", "")
except requests.exceptions.Timeout as errt:
return (f"Timeout Error: {errt}", "")
except requests.exceptions.RequestException as err:
return (f"Oops: Something Else, {err}", "")
return ("Unknow Error", "")
async def _get_web_music_duration(session, url, start=0, end=500):
duration = 0
headers = {"Range": f"bytes={start}-{end}"}
async with session.get(url, headers=headers) as response:
array_buffer = await response.read()
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp.write(array_buffer)
name = tmp.name
try:
m = mutagen.File(name)
duration = m.info.length
except Exception:
pass
os.remove(name)
return duration
async def get_web_music_duration(url, start=0, end=500):
duration = 0
try:
# 设置总超时时间为3秒
timeout = aiohttp.ClientTimeout(total=3)
async with aiohttp.ClientSession(timeout=timeout) as session:
duration = await _get_web_music_duration(session, url, start=0, end=500)
if duration <= 0:
duration = await _get_web_music_duration(
session, url, start=0, end=1000
)
except Exception:
pass
return duration
# 获取文件播放时长
def get_local_music_duration(filename):
duration = 0
try:
m = mutagen.File(filename)
duration = m.info.length
except Exception:
pass
return duration

View File

@@ -3,39 +3,44 @@ import asyncio
import json
import logging
import os
import queue
import random
import re
import time
import urllib.parse
import traceback
import mutagen
from xiaomusic.httpserver import StartHTTPServer
import urllib.parse
from pathlib import Path
from aiohttp import ClientSession, ClientTimeout
from miservice import MiAccount, MiIOService, MiNAService, miio_command
from rich import print
from rich.logging import RichHandler
from miservice import MiAccount, MiIOService, MiNAService
from xiaomusic import (
__version__,
)
from xiaomusic.config import (
COOKIE_TEMPLATE,
LATEST_ASK_API,
KEY_WORD_DICT,
KEY_WORD_ARG_BEFORE_DICT,
KEY_MATCH_ORDER,
KEY_WORD_ARG_BEFORE_DICT,
KEY_WORD_DICT,
LATEST_ASK_API,
SUPPORT_MUSIC_TYPE,
Config,
)
from xiaomusic.httpserver import StartHTTPServer
from xiaomusic.utils import (
parse_cookie_string,
custom_sort_key,
fuzzyfinder,
get_local_music_duration,
get_web_music_duration,
parse_cookie_string,
walk_to_depth,
)
EOF = object()
PLAY_TYPE_ONE = 0 # 单曲循环
PLAY_TYPE_ALL = 1 # 全部循环
PLAY_TYPE_RND = 2 # 随机播放
class XiaoMusic:
@@ -51,43 +56,59 @@ class XiaoMusic:
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.download_proc = None
# 单曲循环,全部循环
self.play_type = PLAY_TYPE_ALL
self.play_type = PLAY_TYPE_RND
self.cur_music = ""
self._next_timer = None
self._timeout = 0
self._volume = 0
self._all_music = {}
self._all_radio = {} # 电台列表
self._play_list = []
self._cur_play_list = ""
self._music_list = {} # 播放列表 key 为目录名, value 为 play_list
self._playing = False
# 关机定时器
self._stop_timer = None
# setup logger
logging.basicConfig(
format=f"%(asctime)s [{__version__}] [%(levelname)s] %(message)s",
datefmt="[%X]",
)
self.log = logging.getLogger("xiaomusic")
self.log.setLevel(logging.DEBUG if config.verbose else logging.INFO)
self.log.addHandler(RichHandler())
self.log.debug(config)
# 尝试从设置里加载配置
self.try_init_setting()
# 启动时重新生成一次播放列表
self.gen_all_music_list()
self._gen_all_music_list()
# 启动时初始化获取声音
self.set_last_record("get_volume#")
self.log.debug("ffmpeg_location: %s", self.ffmpeg_location)
self.log.info("ffmpeg_location: %s", self.ffmpeg_location)
async def poll_latest_ask(self):
async with ClientSession() as session:
@@ -108,7 +129,9 @@ class XiaoMusic:
async def init_all_data(self, session):
await self.login_miboy(session)
await self._init_data_hardware()
session.cookie_jar.update_cookies(self.get_cookie())
cookie_jar = self.get_cookie()
if cookie_jar:
session.cookie_jar.update_cookies(cookie_jar)
self.cookie_jar = session.cookie_jar
async def login_miboy(self, session):
@@ -123,31 +146,37 @@ class XiaoMusic:
self.mina_service = MiNAService(account)
self.miio_service = MiIOService(account)
async def _init_data_hardware(self):
if self.config.cookie:
# if use cookie do not need init
return
hardware_data = await self.mina_service.device_list()
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
for h in hardware_data:
if did := self.config.mi_did:
if h.get("miotDID", "") == str(did):
try:
hardware_data = await self.mina_service.device_list()
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:
continue
if h.get("hardware", "") == self.config.hardware:
self.device_id = h.get("deviceID")
break
else:
raise Exception(
f"we have no hardware: {self.config.hardware} please use `micli mina` to check"
)
else:
self.log.error(
f"we have no hardware: {self.config.hardware} please use `micli mina` to check"
)
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:
@@ -157,10 +186,12 @@ class XiaoMusic:
if d["model"].endswith(self.config.hardware.lower())
)
except StopIteration:
raise Exception(
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:
@@ -169,28 +200,31 @@ class XiaoMusic:
cookie_dict = cookie_jar.get_dict()
self.device_id = cookie_dict["deviceId"]
return cookie_jar
else:
with open(self.mi_token_home) as f:
user_data = json.loads(f.read())
user_id = user_data.get("userId")
service_token = user_data.get("micoapi")[1]
cookie_string = COOKIE_TEMPLATE.format(
device_id=self.device_id, service_token=service_token, user_id=user_id
)
return parse_cookie_string(cookie_string)
if not os.path.exists(self.mi_token_home):
self.log.error(f"{self.mi_token_home} file not exist")
return None
with open(self.mi_token_home) as f:
user_data = json.loads(f.read())
user_id = user_data.get("userId")
service_token = user_data.get("micoapi")[1]
cookie_string = COOKIE_TEMPLATE.format(
device_id=self.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):
retries = 3
for i in range(retries):
try:
timeout = ClientTimeout(total=15)
r = await session.get(
LATEST_ASK_API.format(
hardware=self.config.hardware,
timestamp=str(int(time.time() * 1000)),
),
timeout=timeout,
url = LATEST_ASK_API.format(
hardware=self.config.hardware,
timestamp=str(int(time.time() * 1000)),
)
self.log.debug(f"url:{url}")
r = await session.get(url, timeout=timeout)
except Exception as e:
self.log.warning(
"Execption when get latest ask from xiaoai: %s", str(e)
@@ -198,8 +232,8 @@ class XiaoMusic:
continue
try:
data = await r.json()
except Exception:
self.log.warning("get latest ask from xiaoai error, retry")
except Exception as e:
self.log.warning(f"get latest ask from xiaoai error {e}, retry")
if i == 2:
# tricky way to fix #282 #272 # if it is the third time we re init all data
self.log.info("Maybe outof date trying to re init it")
@@ -208,6 +242,7 @@ class XiaoMusic:
return self._get_last_query(data)
def _get_last_query(self, data):
self.log.debug(f"_get_last_query:{data}")
if d := data.get("data"):
records = json.loads(d).get("records")
if not records:
@@ -229,57 +264,37 @@ class XiaoMusic:
async def do_tts(self, value):
self.log.info("do_tts: %s", value)
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}")
if self.config.mute_xiaoai:
await self.force_stop_xiaoai()
else:
# waiting for xiaoai speaker done
await asyncio.sleep(8)
if not self.config.use_command:
try:
await self.mina_service.text_to_speech(self.device_id, value)
except Exception:
pass
else:
await miio_command(
self.miio_service,
self.config.mi_did,
f"{self.config.tts_command} {value}",
)
self.log.debug(f"do_tts. cur_music:{self.cur_music}")
if self._playing and not self.is_downloading():
# 继续播放歌曲
await self.play()
async def do_set_volume(self, value):
value = int(value)
self._volume = value
self.log.info(f"声音设置为{value}")
if not self.config.use_command:
try:
self.log.debug("do_set_volume not use_command value:%d", value)
await self.mina_service.player_set_volume(self.device_id, value)
except Exception:
pass
else:
self.log.debug("do_set_volume use_command value:%d", value)
await miio_command(
self.miio_service,
self.config.mi_did,
f"{self.config.volume_command}=#{value}",
)
try:
await self.mina_service.player_set_volume(self.device_id, value)
except Exception as e:
self.log.error(f"Execption {e}")
async def force_stop_xiaoai(self):
# TODO:
#await self.mina_service.player_stop(self.device_id)
await self.mina_service.ubus_request(
self.device_id,
"player_play_operation",
"mediaplayer",
{"action": "stop", "media": "app_ios"},
)
await self.mina_service.player_stop(self.device_id)
# 是否在下载中
def is_downloading(self):
if not self.download_proc:
return False
if self.download_proc.returncode != None and self.download_proc.returncode < 0:
if (
self.download_proc.returncode is not None
and self.download_proc.returncode < 0
):
return False
return True
@@ -309,10 +324,10 @@ class XiaoMusic:
if self.proxy:
sbp_args += ("--proxy", f"{self.proxy}")
self.log.info(f"download: {sbp_args}")
self.download_proc = await asyncio.create_subprocess_exec(*sbp_args)
await self.do_tts(f"正在下载歌曲{search_key}")
# 本地是否存在歌曲
def get_filename(self, name):
if name not in self._all_music:
self.log.debug("get_filename not in. name:%s", name)
@@ -323,17 +338,56 @@ class XiaoMusic:
return filename
return ""
# 获取歌曲播放地址
def get_file_url(self, name):
# 判断本地音乐是否存在,网络歌曲不判断
def is_music_exist(self, name):
if name not in self._all_music:
return False
if self.is_web_music(name):
return True
filename = self.get_filename(name)
self.log.debug("get_file_url. name:%s, filename:%s", name, filename)
if filename:
return True
return False
# 是否是网络电台
def is_web_radio_music(self, name):
return name in self._all_radio
# 是否是网络歌曲
def is_web_music(self, name):
if name not in self._all_music:
return False
url = self._all_music[name]
return url.startswith(("http://", "https://"))
# 获取歌曲播放地址
def get_music_url(self, name):
if self.is_web_music(name):
url = self._all_music[name]
self.log.debug("get_music_url web music. name:%s, url:%s", name, url)
return url
filename = self.get_filename(name)
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}"
# 递归获取目录下所有歌曲,生成随机播放列表
def gen_all_music_list(self):
def _gen_all_music_list(self):
self._all_music = {}
for root, dirs, filenames in os.walk(self.music_path):
all_music_by_dir = {}
for root, dirs, filenames in walk_to_depth(
self.music_path, depth=self.music_path_depth
):
dirs[:] = [d for d in dirs if d not in self.exclude_dirs]
self.log.debug("root:%s dirs:%s music_path:%s", root, dirs, self.music_path)
dir_name = os.path.basename(root)
if self.music_path == root:
dir_name = "其他"
if dir_name not in all_music_by_dir:
all_music_by_dir[dir_name] = {}
for filename in filenames:
self.log.debug("gen_all_music_list. filename:%s", filename)
# 过滤隐藏文件
@@ -347,15 +401,71 @@ class XiaoMusic:
name,
extension,
)
if extension not in SUPPORT_MUSIC_TYPE:
if extension.lower() not in SUPPORT_MUSIC_TYPE:
continue
# 歌曲名字相同会覆盖
self._all_music[name] = os.path.join(root, filename)
all_music_by_dir[dir_name][name] = True
self._play_list = list(self._all_music.keys())
random.shuffle(self._play_list)
self._cur_play_list = "全部"
self._gen_play_list()
self.log.debug(self._all_music)
self._music_list = {}
self._music_list["全部"] = self._play_list
for dir_name, musics in all_music_by_dir.items():
self._music_list[dir_name] = list(musics.keys())
self.log.debug("dir_name:%s, list:%s", dir_name, self._music_list[dir_name])
try:
self._append_music_list()
except Exception as e:
self.log.error(f"Execption _append_music_list {e}")
# 给歌单里补充网络歌单
def _append_music_list(self):
if not self.config.music_list_json:
return
music_list = json.loads(self.config.music_list_json)
try:
for item in music_list:
list_name = item.get("name")
musics = item.get("musics")
if (not list_name) or (not musics):
continue
one_music_list = []
for music in musics:
name = music.get("name")
url = music.get("url")
music_type = music.get("type")
if (not name) or (not url):
continue
self._all_music[name] = url
one_music_list.append(name)
# 处理电台列表
if music_type == "radio":
self._all_radio[name] = url
self.log.info(one_music_list)
# 歌曲名字相同会覆盖
self._music_list[list_name] = one_music_list
if self._all_radio:
self._music_list["所有电台"] = list(self._all_radio.keys())
self.log.debug(self._all_music)
self.log.debug(self._music_list)
except Exception as e:
self.log.error(f"Execption music_list:{music_list} {e}")
# 歌曲排序或者打乱顺序
def _gen_play_list(self):
if self.play_type == PLAY_TYPE_RND:
random.shuffle(self._play_list)
else:
self._play_list.sort(key=custom_sort_key)
self.log.debug("play_list:%s", self._play_list)
# 把下载的音乐加入播放列表
def add_download_music(self, name):
self._all_music[name] = os.path.join(self.music_path, f"{name}.mp3")
@@ -368,7 +478,7 @@ class XiaoMusic:
def get_next_music(self):
play_list_len = len(self._play_list)
if play_list_len == 0:
self.log.warning(f"没有随机到歌曲")
self.log.warning("没有随机到歌曲")
return ""
# 随机选择一个文件
index = 0
@@ -379,25 +489,38 @@ class XiaoMusic:
next_index = index + 1
if next_index >= play_list_len:
next_index = 0
filename = self._play_list[next_index]
return filename
# 获取文件播放时长
def get_file_duration(self, filename):
# 获取音频文件对象
audio = mutagen.File(filename)
# 获取播放时长
duration = audio.info.length
return duration
name = self._play_list[next_index]
if not self.is_music_exist(name):
self._play_list.pop(next_index)
self.log.info(f"pop not exist music:{name}")
return self.get_next_music()
return name
# 设置下一首歌曲的播放定时器
def set_next_music_timeout(self):
filename = self.get_filename(self.cur_music)
sec = int(self.get_file_duration(filename))
self.log.info(f"歌曲 {self.cur_music} : {filename} 的时长 {sec}")
async def set_next_music_timeout(self):
name = self.cur_music
if self.is_web_radio_music(name):
self.log.info("电台不会有下一首的定时器")
return
if self.is_web_music(name):
url = self._all_music[name]
duration = await get_web_music_duration(url)
sec = int(duration)
self.log.info(f"网络歌曲 {name} : {url} 的时长 {sec}")
else:
filename = self.get_filename(name)
sec = int(get_local_music_duration(filename))
self.log.info(f"本地歌曲 {name} : {filename} 的时长 {sec}")
if self._next_timer:
self._next_timer.cancel()
self.log.info(f"定时器已取消")
self.log.info("定时器已取消")
if sec <= 0:
self.log.warning("获取歌曲时长失败,不会开启下一首歌曲的定时器")
return
self._timeout = sec
async def _do_next():
@@ -411,23 +534,32 @@ class XiaoMusic:
self.log.info(f"{sec}秒后将会播放下一首")
async def run_forever(self):
StartHTTPServer(self.port, self.music_path, self)
async with ClientSession() as session:
self.session = session
await self.init_all_data(session)
StartHTTPServer(self.port, self.music_path, self)
task = asyncio.create_task(self.poll_latest_ask())
assert task is not None # to keep the reference to task, do not remove this
filtered_keywords = [keyword for keyword in KEY_MATCH_ORDER if "#" not in keyword]
filtered_keywords = [
keyword for keyword in KEY_MATCH_ORDER if "#" not in keyword
]
joined_keywords = "/".join(filtered_keywords)
self.log.info(
f"Running xiaomusic now, 用`{joined_keywords}`开头来控制"
)
self.log.info(f"Running xiaomusic now, 用`{joined_keywords}`开头来控制")
while True:
self.polling_event.set()
await self.new_record_event.wait()
self.new_record_event.clear()
new_record = self.last_record
if new_record is None:
# 其他线程的函数调用
try:
func, callback, arg1 = self.queue.get(False)
ret = await func(arg1=arg1)
callback(ret)
except queue.Empty:
pass
continue
self.polling_event.clear() # stop polling when processing the question
query = new_record.get("query", "").strip()
ctrl_panel = new_record.get("ctrl_panel", False)
@@ -480,79 +612,134 @@ class XiaoMusic:
return ("stop", {})
return (None, None)
# 判断是否播放下一首歌曲
def check_play_next(self):
# 当前没我在播放的歌曲
if self.cur_music == "":
return True
else:
# 当前播放的歌曲不存在了
if not self.is_music_exist(self.cur_music):
return True
return False
# 播放歌曲
async def play(self, **kwargs):
self._playing = True
parts = kwargs["arg1"].split("|")
parts = kwargs.get("arg1", "").split("|")
search_key = parts[0]
name = parts[1] if len(parts) > 1 else search_key
if search_key == "" and name == "":
await self.play_next()
return
if name == "":
name = search_key
self.log.debug("play. search_key:%s name:%s", search_key, name)
filename = self.get_filename(name)
if len(filename) <= 0:
if search_key == "" and name == "":
if self.check_play_next():
await self.play_next()
return
else:
name = self.cur_music
self.log.info("play. search_key:%s name:%s", search_key, name)
# 本地歌曲不存在时下载
if not self.is_music_exist(name):
await self.download(search_key, name)
self.log.info("正在下载中 %s", search_key + ":" + name)
await self.download_proc.wait()
# 把文件插入到播放列表里
self.add_download_music(name)
self._playing = True
self.cur_music = name
self.log.info("cur_music %s", self.cur_music)
url = self.get_file_url(name)
url = self.get_music_url(name)
self.log.info("播放 %s", url)
await self.force_stop_xiaoai()
await self.mina_service.play_by_url(self.device_id, url)
self.log.info("已经开始播放了")
# 设置下一首歌曲的播放定时器
self.set_next_music_timeout()
await self.set_next_music_timeout()
# 下一首
async def play_next(self, **kwargs):
self.log.info("下一首")
name = self.cur_music
self.log.debug("play_next. name:%s, cur_music:%s", name, self.cur_music)
if self.play_type == PLAY_TYPE_ALL or name == "":
if (
self.play_type == PLAY_TYPE_ALL
or self.play_type == PLAY_TYPE_RND
or name == ""
):
name = self.get_next_music()
if name == "":
await self.do_tts(f"本地没有歌曲")
await self.do_tts("本地没有歌曲")
return
await self.play(arg1=name)
# 单曲循环
async def set_play_type_one(self, **kwargs):
self.play_type = PLAY_TYPE_ONE
await self.do_tts(f"已经设置为单曲循环")
await self.do_tts("已经设置为单曲循环")
# 全部循环
async def set_play_type_all(self, **kwargs):
self.play_type = PLAY_TYPE_ALL
await self.do_tts(f"已经设置为全部循环")
self._gen_play_list()
await self.do_tts("已经设置为全部循环")
# 随机播放
async def random_play(self, **kwargs):
self.play_type = PLAY_TYPE_ALL
await self.do_tts(f"已经设置为全部循环并随机播放")
# 重新生成随机播放列表
self.gen_all_music_list()
await self.play_next()
self.play_type = PLAY_TYPE_RND
self._gen_play_list()
await self.do_tts("已经设置为随机播放")
# 刷新列表
async def gen_music_list(self, **kwargs):
self._gen_all_music_list()
# 删除歌曲
def del_music(self, name):
filename = self.get_filename(name)
if filename == "":
self.log.info(f"${name} not exist")
return
try:
os.remove(filename)
self.log.info(f"del ${filename} success")
except OSError:
self.log.error(f"del ${filename} failed")
self._gen_all_music_list()
# 播放一个播放列表
async def play_music_list(self, **kwargs):
parts = kwargs.get("arg1").split("|")
list_name = parts[0]
if list_name not in self._music_list:
await self.do_tts(f"播放列表{list_name}不存在")
return
self._play_list = self._music_list[list_name]
self._cur_play_list = list_name
self._gen_play_list()
self.log.info(f"开始播放列表{list_name}")
music_name = ""
if len(parts) > 1:
music_name = parts[1]
else:
music_name = self.get_next_music()
await self.play(arg1=music_name)
async def stop(self, **kwargs):
self._playing = False
if self._next_timer:
self._next_timer.cancel()
self.log.info(f"定时器已取消")
self.log.info("定时器已取消")
await self.force_stop_xiaoai()
async def stop_after_minute(self, **kwargs):
if self._stop_timer:
self._stop_timer.cancel()
self.log.info(f"关机定时器已取消")
minute = int(kwargs["arg1"])
self.log.info("关机定时器已取消")
minute = int(kwargs.get("arg1", 0))
async def _do_stop():
await asyncio.sleep(minute * 60)
@@ -565,13 +752,15 @@ class XiaoMusic:
self.log.info(f"{minute}分钟后将关机")
async def set_volume(self, **kwargs):
value = kwargs["arg1"]
value = kwargs.get("arg1", 0)
await self.do_set_volume(value)
async def get_volume(self, **kwargs):
playing_info = await self.mina_service.player_get_status(self.device_id)
self.log.debug("get_volume. playing_info:%s", playing_info)
self._volume = json.loads(playing_info.get("data", {}).get("info", "{}")).get("volume", 5)
self._volume = json.loads(playing_info.get("data", {}).get("info", "{}")).get(
"volume", 5
)
self.log.info("get_volume. volume:%s", self._volume)
def get_volume_ret(self):
@@ -579,11 +768,109 @@ class XiaoMusic:
# 搜索音乐
def searchmusic(self, name):
search_list = fuzzyfinder(name, self._play_list)
all_music_list = list(self._all_music.keys())
search_list = fuzzyfinder(name, all_music_list)
self.log.debug("searchmusic. name:%s search_list:%s", name, search_list)
return search_list
# 获取播放列表
def get_music_list(self):
return self._music_list
# 获取当前的播放列表
def get_cur_play_list(self):
return self._cur_play_list
# 正在播放中的音乐
def playingmusic(self):
self.log.debug("playingmusic. cur_music:%s", self.cur_music)
return self.cur_music
# 当前是否正在播放歌曲
def isplaying(self):
return self._playing
# 获取当前配置
def getconfig(self):
return self.config
# 获取设置文件
def getsettingfile(self):
if not os.path.exists(self.conf_path):
os.makedirs(self.conf_path)
filename = os.path.join(self.conf_path, "setting.json")
return filename
def try_init_setting(self):
try:
filename = self.getsettingfile()
with open(filename) as f:
data = json.loads(f.read())
self.update_config_from_setting(data)
except FileNotFoundError:
self.log.info(f"The file {filename} does not exist.")
except json.JSONDecodeError:
self.log.warning(f"The file {filename} contains invalid JSON.")
except Exception as e:
self.log.error(f"Execption init setting {e}")
# 保存配置并重新启动
async def saveconfig(self, data):
# 默认暂时配置保存到 music 目录下
filename = self.getsettingfile()
with open(filename, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=4)
self.update_config_from_setting(data)
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.info("update_config_from_setting ok. data:%s", data)
# 重新初始化
async def reinit(self, **kwargs):
await self.try_update_device_id()
self._gen_all_music_list()
self.log.info("reinit success")
# 获取所有设备
async def getalldevices(self, **kwargs):
did_list = []
hardware_list = []
hardware_data = await self.mina_service.device_list()
for h in hardware_data:
did = h.get("miotDID", "")
if did != "":
did_list.append(did)
hardware = h.get("hardware", "")
if h.get("hardware", "") != "":
hardware_list.append(hardware)
alldevices = {
"did_list": did_list,
"hardware_list": hardware_list,
}
return alldevices
# 用于在web线程里调用
# 获取所有设备
async def call_main_thread_function(self, func, arg1=None):
loop = asyncio.get_event_loop()
future = loop.create_future()
def callback(ret):
nonlocal future
loop.call_soon_threadsafe(future.set_result, ret)
self.queue.put((func, callback, arg1))
self.last_record = None
self.new_record_event.set()
result = await future
return result