diff --git a/.gitignore b/.gitignore index 5012ba227f..aa92e0b687 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,5 @@ cache tmp/ xiaomusic.log.txt* node_modules +js_plugins/ +reference/ diff --git a/Dockerfile b/Dockerfile index 6efe36e4ba..e6ddf8ada8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,52 @@ -FROM hanxi/xiaomusic:builder AS builder +FROM python:3.12-alpine3.22 AS builder + +RUN apk add --no-cache --virtual .build-deps build-base python3-dev libffi-dev openssl-dev zlib-dev jpeg-dev libc6-compat gcc musl-dev \ + && apk add --no-cache nodejs npm + RUN pip install -U pdm ENV PDM_CHECK_UPDATE=false WORKDIR /app -COPY pyproject.toml README.md . +COPY pyproject.toml README.md package.json . + +RUN pdm install --prod --no-editable -v +RUN npm install + COPY xiaomusic/ ./xiaomusic/ COPY plugins/ ./plugins/ COPY holiday/ ./holiday/ +COPY js_plugins/ ./js_plugins/ COPY xiaomusic.py . -RUN pdm install --prod --no-editable -v -FROM hanxi/xiaomusic:runtime +FROM python:3.12-alpine3.22 + +RUN apk add --no-cache bash\ + wget \ + xz \ + tiff \ + openjpeg \ + libxcb \ + supervisor \ + vim \ + libc6-compat \ + ffmpeg \ + nodejs \ + npm \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app +RUN mkdir -p /app/ffmpeg/bin \ + && ln -s /usr/bin/ffmpeg /app/ffmpeg/bin/ffmpeg \ + && ln -s /usr/bin/ffprobe /app/ffmpeg/bin/ffprobe + COPY --from=builder /app/.venv ./.venv +COPY --from=builder /app/node_modules ./node_modules/ COPY --from=builder /app/xiaomusic/ ./xiaomusic/ COPY --from=builder /app/plugins/ ./plugins/ COPY --from=builder /app/holiday/ ./holiday/ +COPY --from=builder /app/js_plugins/ ./js_plugins/ COPY --from=builder /app/xiaomusic.py . COPY --from=builder /app/xiaomusic/__init__.py /base_version.py +COPY --from=builder /app/package.json . RUN touch /app/.dockerenv COPY supervisord.conf /etc/supervisor/supervisord.conf @@ -26,6 +56,6 @@ VOLUME /app/conf VOLUME /app/music EXPOSE 8090 ENV TZ=Asia/Shanghai -ENV PATH=/app/.venv/bin:$PATH +ENV PATH=/app/.venv/bin:/usr/local/bin:$PATH ENTRYPOINT ["/bin/sh", "-c", "/usr/bin/supervisord -c /etc/supervisor/supervisord.conf && tail -F /app/supervisord.log /app/xiaomusic.log.txt"] diff --git a/Dockerfile.builder b/Dockerfile.builder index 7b67315373..c654d25bc5 100644 --- a/Dockerfile.builder +++ b/Dockerfile.builder @@ -1,14 +1,18 @@ FROM python:3.12-alpine3.22 -RUN apk add --no-cache --virtual .build-deps build-base python3-dev libffi-dev openssl-dev zlib-dev jpeg-dev libc6-compat gcc musl-dev +RUN apk add --no-cache --virtual .build-deps build-base python3-dev libffi-dev openssl-dev zlib-dev jpeg-dev libc6-compat gcc musl-dev \ + && apk add --no-cache nodejs npm + RUN pip install -U pdm ENV PDM_CHECK_UPDATE=false WORKDIR /app -COPY pyproject.toml README.md ./ +COPY pyproject.toml README.md package.json ./ RUN pdm install --prod --no-editable -v +RUN npm install COPY xiaomusic/ ./xiaomusic/ COPY plugins/ ./plugins/ COPY holiday/ ./holiday/ COPY xiaomusic.py . +COPY js_plugins/ ./js_plugins/ diff --git a/Dockerfile.runtime b/Dockerfile.runtime index 679f65059c..5b3114fe1c 100644 --- a/Dockerfile.runtime +++ b/Dockerfile.runtime @@ -10,6 +10,8 @@ RUN apk add --no-cache bash\ vim \ libc6-compat \ ffmpeg \ + nodejs \ + npm \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/README.md b/README.md index 90b470b894..1f5aa03178 100644 --- a/README.md +++ b/README.md @@ -1,326 +1,118 @@ -# XiaoMusic: 无限听歌,解放小爱音箱 +# XiaoMusic-Online: Xiaomusic在线版 -[![GitHub License](https://img.shields.io/github/license/hanxi/xiaomusic)](https://github.com/hanxi/xiaomusic) -[![Docker Image Version](https://img.shields.io/docker/v/hanxi/xiaomusic?sort=semver&label=docker%20image)](https://hub.docker.com/r/hanxi/xiaomusic) -[![Docker Pulls](https://img.shields.io/docker/pulls/hanxi/xiaomusic)](https://hub.docker.com/r/hanxi/xiaomusic) -[![PyPI - Version](https://img.shields.io/pypi/v/xiaomusic)](https://pypi.org/project/xiaomusic/) -[![PyPI - Downloads](https://img.shields.io/pypi/dm/xiaomusic)](https://pypi.org/project/xiaomusic/) -[![Python Version from PEP 621 TOML](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fhanxi%2Fxiaomusic%2Fmain%2Fpyproject.toml)](https://pypi.org/project/xiaomusic/) -[![GitHub Release](https://img.shields.io/github/v/release/hanxi/xiaomusic)](https://github.com/hanxi/xiaomusic/releases) -[![Visitors](https://api.visitorbadge.io/api/daily?path=hanxi%2Fxiaomusic&label=daily%20visitor&countColor=%232ccce4&style=flat)](https://visitorbadge.io/status?path=hanxi%2Fxiaomusic) -[![Visitors](https://api.visitorbadge.io/api/visitors?path=hanxi%2Fxiaomusic&label=total%20visitor&countColor=%232ccce4&style=flat)](https://visitorbadge.io/status?path=hanxi%2Fxiaomusic) +对XiaoMusic项目进行了二次开发,增加了开放接口和MusicFree插件调用能力,可实现在线搜索、播放歌曲功能。 -使用小爱音箱播放音乐,音乐使用 yt-dlp 下载。 +原项目: - +当前项目: -文档: +原项目文档: > [!TIP] > 初次安装遇到问题请查阅 [💬 FAQ问题集合](https://github.com/hanxi/xiaomusic/issues/99) ,一般遇到的问题都已经有解决办法。 -## 👋 最简配置运行 +## 🙏 致敬 -已经支持在 web 页面配置其他参数,docker 启动命令如下: +- **原项目**: 本项目基于 [hanxi/xiaomusic](https://github.com/hanxi/xiaomusic) 进行开发,感谢原作者的支持 +- **MusicFree项目**: 集成了 [MusicFree](https://github.com/maotoumao/MusicFree) 的JS插件功能,感谢其开源贡献 +- **开放接口**: 集成了 [TuneFree API](https://api.tunefree.fun/) 开放接口,感谢其提供了更丰富的音乐接口 -```bash -docker run -p 58090:8090 -e XIAOMUSIC_PUBLIC_PORT=58090 -v /xiaomusic_music:/app/music -v /xiaomusic_conf:/app/conf hanxi/xiaomusic -``` +## 🚀 扩展功能与实现逻辑 -🔥 国内: +### 扩展功能概述 +本项目在原版 xiaomusic 基础上,增加了以下扩展功能: -```bash -docker run -p 58090:8090 -e XIAOMUSIC_PUBLIC_PORT=58090 -v /xiaomusic_music:/app/music -v /xiaomusic_conf:/app/conf docker.hanxi.cc/hanxi/xiaomusic -``` +1. **JS插件支持**: 集成 MusicFree 的 JS 插件系统,支持多种音乐源 +2. **开放接口集成**: 支持通过开放接口获取音乐资源 +3. **智能搜索排序**: 基于匹配度的搜索结果优化排序 +4. **插件管理**: 提供插件启用/禁用/卸载等功能 -对应的 docker compose 配置如下: +### 实现逻辑 +- 通过 [JSPluginManager](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\js_plugin_manager.py#L16-L999) 类管理 JS 插件 +- 使用 Node.js 子进程运行 JS 插件代码 +- 提供 [search](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\js_plugin_runner.js#L261-L322)、[get_media_source](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\httpserver.py#L291-L301)、[get_lyric](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\js_plugin_manager.py#L641-L658) 等接口与插件交互 +- 支持调用开放接口[TuneFree API](https://api.tunefree.fun/)直接获取音乐数据(高优先级) +- 通过匹配度算法优化搜索结果排序(多插件混合搜索场景) +#### 调用策略 +- 调用策略: + - 配置了开放接口且启用,只调用开放接口。 + - 未配置或启用接口时,会调用MusicFree插件搜索(需存在且启用) +- MusicFree插件搜索结果,优先级规则: + - 【歌手名】与关键字匹配度(完全匹配1000分,开头匹配800分,部分匹配600分) + - 【歌曲名】与关键字匹配度(完全匹配400分,开头匹配300分,部分匹配200分) + - 插件平台权重(启用插件列表中前9个插件,排名越靠前权重越高,最高9分) -```yaml -services: - xiaomusic: - image: hanxi/xiaomusic - container_name: xiaomusic - restart: unless-stopped - ports: - - 58090:8090 - environment: - XIAOMUSIC_PUBLIC_PORT: 58090 - volumes: - - /xiaomusic_music:/app/music - - /xiaomusic_conf:/app/conf -``` +## 🔧 新增功能介绍 -🔥 国内: +### WEB端搜索、配置 +- 支持网页端搜索/播放歌曲及推送小爱音响(部分MusicFree获取的资源类型小爱不适用) +- 支持网页端管理插件、接口 -```yaml -services: - xiaomusic: - image: docker.hanxi.cc/hanxi/xiaomusic - container_name: xiaomusic - restart: unless-stopped - ports: - - 58090:8090 - environment: - XIAOMUSIC_PUBLIC_PORT: 58090 - volumes: - - /xiaomusic_music:/app/music - - /xiaomusic_conf:/app/conf -``` - -- 其中 conf 目录为配置文件存放目录,music 目录为音乐存放目录,建议分开配置为不同的目录。 -- /xiaomusic_music 和 /xiaomusic_conf 是 docker 所在的主机的目录,可以修改为其他目录。如果报错找不到 /xiaomusic_music 目录,可以先执行 `mkdir -p /xiaomusic_{music,conf}` 命令新建目录。 -- /app/music 和 /app/conf 是 docker 容器里的目录,不要去修改。 -- XIAOMUSIC_PUBLIC_PORT 是用来配置 NAS 本地端口的。8090 是容器端口,不要去修改。 -- 后台访问地址为: http://NAS_IP:58090 +### JS插件管理 +- 支持加载和管理 MusicFree JS 插件 +- 提供插件导入/启用/禁用/卸载功能 +- 支持插件配置文件管理 -> [!NOTE] -> docker 和 docker compose 二选一即可,启动成功后,在 web 页面可以配置其他参数,带有 `*` 号的配置是必须要配置的,其他的用不上时不用修改。初次配置时需要在页面上输入小米账号和密码保存后才能获取到设备列表。 - -> [!TIP] -> 目前安装步骤已经是最简化了,如果还是嫌安装麻烦,可以微信或者 QQ 约我远程安装,我一般周末和晚上才有时间,需要赞助个辛苦费 :moneybag: 50 元一次。 +### 开放接口支持 +- 集成外部音乐API接口 +- 支持在线搜索和播放 +- 可配置开放接口地址 -遇到问题可以去 web 设置页面底部点击【下载日志文件】按钮,然后搜索一下日志文件内容确保里面没有账号密码信息后(有就删除这些敏感信息),然后在提 issues 反馈问题时把下载的日志文件带上。 +### 智能搜索排序 +- 根据歌曲名、艺术家匹配度排序 +- 支持按平台优先级排序 +- 提供更精准的搜索结果 +### 语音指令扩展 +- 新增【在线播放】语音控制指令 -> [!TIP] -> 作者写的一个游戏服务器开发实战课程 ,购买时记得使用优惠码: `2CZ2UA5u` 。 - -> [!TIP] -> 作者的另一个适用于 NAS 上安装的开源工具: - -> [!TIP] -> -> 喜欢听书的可以配合这个工具使用 - -> [!TIP] -> -> - 🔥【广告:可用于安装 frp 实现内网穿透】 -> - 🔥 海外 RackNerd VPS 机器推荐,可支付宝付款。 -> - RackNerd Mobile Leaderboard Banner -> - 不知道选哪个套餐可以直接买这个最便宜的 -> - 也可以用来部署代理,docker 部署方法见 - -> [!TIP] -> -> - 🔥【广告: 搭建您的专属大模型主页 -告别繁琐配置难题,一键即可畅享稳定流畅的AI体验!】 - -> [!TIP] -> - 免费主机 -> - Powered by DartNode - Free VPS for Open Source - - -### 🤐 支持语音口令 - -- 【播放歌曲】,播放本地的歌曲 -- 【播放歌曲+歌名】,比如:播放歌曲周杰伦晴天 -- 【上一首】 -- 【下一首】 -- 【单曲循环】 -- 【全部循环】 -- 【随机播放】 -- 【关机】,【停止播放】,两个效果是一样的。 -- 【刷新列表】,当复制了歌曲进 music 目录后,可以用这个口令刷新歌单。 -- 【播放列表+列表名】,比如:播放列表其他。 -- 【加入收藏】,把当前播放的歌曲加入收藏歌单。 -- 【取消收藏】,把当前播放的歌曲从收藏歌单里移除。 -- 【播放列表收藏】,这个用于播放收藏歌单。 -- ~【播放本地歌曲+歌名】,这个口令和播放歌曲的区别是本地找不到也不会去下载。~ -- 【播放列表第几个+列表名】,具体见: -- 【搜索播放+关键词】,会搜索关键词作为临时搜索列表播放,比如说【搜索播放林俊杰】,会播放所有林俊杰的歌。 -- 【本地搜索播放+关键词】,跟搜索播放的区别是本地找不到也不会去下载。 - -> [!TIP] -> 隐藏玩法: 对小爱同学说播放歌曲小猪佩奇的故事,会先下载小猪佩奇的故事,然后再播放小猪佩奇的故事。 - -## 🛠️ pip 方式安装运行 - -```shell -> pip install -U xiaomusic -> xiaomusic --help - __ __ _ __ __ _ - \ \/ / (_) __ _ ___ | \/ | _ _ ___ (_) ___ - \ / | | / _` | / _ \ | |\/| | | | | | / __| | | / __| - / \ | | | (_| | | (_) | | | | | | |_| | \__ \ | | | (__ - /_/\_\ |_| \__,_| \___/ |_| |_| \__,_| |___/ |_| \___| - XiaoMusic v0.3.69 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` 文件配置。见 +## 👨‍💻 最简配置运行 -不修改默认端口 8090 的情况下,只需要执行 `xiaomusic` 即可启动。 - -## 🔩 开发环境运行 - -- 使用 install_dependencies.sh 下载依赖 -- 使用 pdm 安装环境 -- 默认监听了端口 8090 , 使用其他端口自行修改。 - -```shell -pdm run xiaomusic.py -```` - -如果是开发前端界面,可以通过 -查看有什么接口。目前的 web 控制台非常简陋,欢迎有兴趣的朋友帮忙实现一个漂亮的前端,需要什么接口可以随时提需求。 - -### 🚦 代码提交规范 - -提交前请执行 +已经支持在 web 页面配置其他参数,docker 启动命令如下: -``` -pdm lintfmt +```bash +docker run -d \ + --name xiaomusic-online \ + --restart unless-stopped \ + -p 58090:8090 \ + -e XIAOMUSIC_PUBLIC_PORT=58090 \ + -e TZ=Asia/Shanghai \ + -v /vol1/1000/**/music:/app/music \ + -v /vol1/1000/**/conf:/app/conf \ + -v /vol1/1000/**/logs:/app/logs \ + boluofandocker/xiaomusic-online ``` -用于检查代码和格式化代码。 - -### 本地编译 Docker Image +对应的 docker compose 配置如下: -```shell -docker build -t xiaomusic . +```yaml +services: + xiaomusic-online: + image: boluofandocker/xiaomusic-online + container_name: xiaomusic-online + restart: unless-stopped + ports: + - "58090:8090" + environment: + - XIAOMUSIC_PUBLIC_PORT=58090 + - TZ=Asia/Shanghai + volumes: + - /vol1/1000/**/music:/app/music + - /vol1/1000/**/conf:/app/conf + - /vol1/1000/**/logs:/app/logs ``` +- /vol1/1000/**/ 是 docker 所在的主机的真实目录,需根据自身修改 -### 技术栈 - -- 后端代码使用 Python 语言编写。 -- HTTP 服务使用的是 FastAPI 框架,~~早期版本使用的是 Flask~~。 -- 使用了 Docker ,在 NAS 上安装更方便。 -- 默认的前端主题使用了 jQuery 。 - -## 已测试支持的设备 - -| 型号 | 名称 | -| ---- | ---------------------------------------------------------------------------------------------- | -| 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 | [小爱音箱Play(2019款)](https://home.mi.com/baike/index.html#/detail?model=xiaomi.wifispeaker.lx05) | -| L15A | [小米AI音箱(第二代)](https://home.mi.com/webapp/content/baike/product/index.html?model=xiaomi.wifispeaker.l15a#/) | -| 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) | -| L09A | [小米音箱Art](https://home.mi.com/webapp/content/baike/product/index.html?model=xiaomi.wifispeaker.l09a) | -| LX04 X10A X08A | 已经支持的触屏版 | -| X08C X08E X8F | 已经不需要设置了. ~需要设置【型号兼容模式】选项为 true~ | -| M01/XMYX01JY | 小米小爱音箱HD 需要设置【特殊型号获取对话记录】选项为 true 才能语音播放| -| OH2P | XIAOMI 智能音箱 Pro | -| OH2 | XIAOMI 智能音箱 | - -型号与产品名称对照可以在这里查询 - -> [!NOTE] -> 如果你的设备支持播放,请反馈给我添加到支持列表里,谢谢。 -> 目前应该所有设备类型都已经支持播放,有问题随时反馈。 -> 其他触屏版不能播放可以设置【型号兼容模式】选项为 true 试试。见 - -## 🎵 支持音乐格式 - -- mp3 -- flac -- wav -- ape -- ogg -- m4a - -> [!NOTE] -> 本地音乐会搜索目录下上面格式的文件,下载的歌曲是 mp3 格式的。 -> 已知 L05B L05C LX06 L16A 不支持 flac 格式。 -> 如果格式不能播放可以打开【转换为MP3】和【型号兼容模式】选项。具体见 - -## 🌏 网络歌单功能 - -可以配置一个 json 格式的歌单,支持电台和歌曲,也可以直接用别人分享的链接,同时配备了 m3u 文件格式转换工具,可以很方便的把 m3u 电台文件转换成网络歌单格式的 json 文件,具体用法见 - -> [!NOTE] -> 欢迎有想法的朋友们制作更多的歌单转换工具。 - -## 🍺 更多其他可选配置 - -见 - -## ⚠️ 安全提醒 - -> [!IMPORTANT] -> -> 1. 如果配置了公网访问 xiaomusic ,请一定要开启密码登陆,并设置复杂的密码。且不要在公共场所的 WiFi 环境下使用,否则可能造成小米账号密码泄露。 -> 2. 强烈不建议将小爱音箱的小米账号绑定摄像头,代码难免会有 bug ,一旦小米账号密码泄露,可能监控录像也会泄露。 - -## 🤔 高级篇 - -- 自定义口令功能 -- -- -- - -## 📢 讨论区 - -- [点击链接加入QQ频道【xiaomusic】](https://pd.qq.com/s/e2jybz0ss) -- [点击链接加入群聊【xiaomusic官方交流群3】 1072151477](https://qm.qq.com/q/lxIhquqbza) -- -- [微信群二维码](https://github.com/hanxi/xiaomusic/issues/86) - -## ❤️ 感谢 - -- [xiaomi](https://www.mi.com/) -- [PDM](https://pdm.fming.dev/latest/) -- [xiaogpt](https://github.com/yihong0618/xiaogpt) -- [MiService](https://github.com/yihong0618/MiService) -- [实现原理](https://github.com/yihong0618/gitblog/issues/258) -- [yt-dlp](https://github.com/yt-dlp/yt-dlp) -- [awesome-xiaoai](https://github.com/zzz6519003/awesome-xiaoai) -- [微信小程序: 卯卯音乐](https://github.com/F-loat/xiaoplayer) -- [pure 主题 xiaomusicUI](https://github.com/52fisher/xiaomusicUI) -- [移动端的播放器主题](https://github.com/52fisher/XMusicPlayer) -- [Tailwind主题](https://github.com/clarencejh/xiaomusic) -- [SoundScape主题](https://github.com/jhao0413/SoundScape) -- [一个第三方的主题](https://github.com/DarrenWen/xiaomusicui) -- [Umami 统计](https://github.com/umami-software/umami) -- [Sentry 报错监控](https://github.com/getsentry/sentry) -- 所有帮忙调试和测试的朋友 -- 所有反馈问题和建议的朋友 - -### 👉 其他教程 - -更多功能见 [📝 文档汇总](https://github.com/hanxi/xiaomusic/issues/211) - -## 🚨 免责声明 - -本项目仅供学习和研究目的,不得用于任何商业活动。用户在使用本项目时应遵守所在地区的法律法规,对于违法使用所导致的后果,本项目及作者不承担任何责任。 -本项目可能存在未知的缺陷和风险(包括但不限于设备损坏和账号封禁等),使用者应自行承担使用本项目所产生的所有风险及责任。 -作者不保证本项目的准确性、完整性、及时性、可靠性,也不承担任何因使用本项目而产生的任何损失或损害责任。 -使用本项目即表示您已阅读并同意本免责声明的全部内容。 - -## Star History - -[![Star History Chart](https://api.star-history.com/svg?repos=hanxi/xiaomusic&type=Date)](https://star-history.com/#hanxi/xiaomusic&Date) +### 🤐 新增语音口令 -## 赞赏 +- 【播放/在线播放+关键词(歌手、歌曲名)】,会直接调用接口或插件,搜索关键词,返回匹配后的第一个资源进行播放。比如说:【播放】林俊杰||江南||林俊杰+江南。 -- :moneybag: 爱发电 -- 点个 Star :star: -- 谢谢 :heart: -- ![喝杯奶茶](https://i.v2ex.co/7Q03axO5l.png) +## 📋 待办优化项 -## License +- [ ] 歌手列表播放: 指定歌手名播放,可随机播放该歌手的列表歌曲【播放歌手:陈奕迅】。 +- [ ] 智能续播: 指定歌名播放完毕后,可随机播放这个歌手的其他歌曲、或类似风格歌曲(当前会单曲循环)。 +- [ ] 播放链接优化: 接口或插件可能会返回加密音乐的播放地址(.qmc .mflac .mgg .kwm),应将其排除。 +- [ ] 指令播放优化: 当前会在排序后取第一个资源播放,但可能取到的非最佳项。应考虑优化。 -[MIT](https://github.com/hanxi/xiaomusic/blob/main/LICENSE) License © 2024 涵曦 +## 写在最后 +本项目只是因个人兴趣和家人需要进行的简单二开。如果您有好的建议或问题,欢迎在 GitHub 上提交 issue。但本人不保证会对项目长期、持续的更新和全方位支持响应,建议自行fork进行定制开发。 diff --git a/check_plugins.py b/check_plugins.py new file mode 100644 index 0000000000..5f0841ec5f --- /dev/null +++ b/check_plugins.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +""" +检查所有插件的加载状态 +""" + +import sys +import os +sys.path.append('.') + +from xiaomusic.js_plugin_manager import JSPluginManager +from xiaomusic.config import Config + +def check_all_plugins(): + print("=== 检查所有插件加载状态 ===\n") + + config = Config() + config.verbose = True + + class SimpleLogger: + def info(self, msg): print(f"[INFO] {msg}") + def error(self, msg): print(f"[ERROR] {msg}") + def debug(self, msg): print(f"[DEBUG] {msg}") + + print("1. 创建插件管理器...") + manager = JSPluginManager(None) + manager.config = config + manager.log = SimpleLogger() + + import time + time.sleep(3) # 等待插件加载 + + print("\n2. 获取所有插件状态...") + plugins = manager.get_plugin_list() + print(f" 总共找到 {len(plugins)} 个插件") + + # 分类插件状态 + working_plugins = [] + failed_plugins = [] + + for plugin in plugins: + if plugin.get('loaded', False) and plugin.get('enabled', False): + working_plugins.append(plugin) + else: + failed_plugins.append(plugin) + + print(f"\n 正常工作的插件 ({len(working_plugins)} 个):") + for plugin in working_plugins: + print(f" ✓ {plugin['name']}") + + print(f"\n 失败的插件 ({len(failed_plugins)} 个):") + for plugin in failed_plugins: + print(f" ✗ {plugin['name']}: {plugin.get('error', 'Unknown error')}") + + # 清理 + if hasattr(manager, 'node_process') and manager.node_process: + manager.node_process.terminate() + +if __name__ == "__main__": + check_all_plugins() \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..2b77c17184 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,731 @@ +{ + "name": "xiaomusic-js-plugins", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "xiaomusic-js-plugins", + "version": "1.0.0", + "dependencies": { + "axios": "^1.6.0", + "cheerio": "^1.0.0-rc.12", + "crypto-js": "^4.2.0", + "dayjs": "^1.11.10", + "he": "^1.2.0", + "qs": "^6.14.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cheerio": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/cheerio/-/cheerio-1.1.2.tgz", + "integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.0.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.12.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmmirror.com/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/htmlparser2": { + "version": "10.0.0", + "resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici": { + "version": "7.16.0", + "resolved": "https://registry.npmmirror.com/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000..c7284d706f --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "xiaomusic-js-plugins", + "version": "1.0.0", + "description": "JS plugins for xiaomusic", + "main": "xiaomusic/js_plugin_runner.js", + "dependencies": { + "axios": "^1.6.0", + "cheerio": "^1.0.0-rc.12", + "crypto-js": "^4.2.0", + "dayjs": "^1.11.10", + "he": "^1.2.0", + "qs": "^6.14.0" + } +} diff --git a/pyproject.toml b/pyproject.toml index 043aabc148..77a5f27a6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "fake-useragent>=2.2.0", "miservice-fork", "edge-tts>=7.2.3", + "psutil>=5.9.0", ] requires-python = ">=3.10" readme = "README.md" diff --git a/xiaomusic/config.py b/xiaomusic/config.py index 0dc9cb44b3..1ef389a39c 100644 --- a/xiaomusic/config.py +++ b/xiaomusic/config.py @@ -151,6 +151,7 @@ class Config: ) keywords_play: str = os.getenv("XIAOMUSIC_KEYWORDS_PLAY", "播放歌曲,放歌曲") keywords_search_play: str = os.getenv("XIAOMUSIC_KEYWORDS_SEARCH_PLAY", "搜索播放") + keywords_online_play: str = os.getenv("XIAOMUSIC_KEYWORDS_ONLINE_PLAY", "播放,在线播放") keywords_stop: str = os.getenv("XIAOMUSIC_KEYWORDS_STOP", "关机,暂停,停止,停止播放") keywords_playlist: str = os.getenv( "XIAOMUSIC_KEYWORDS_PLAYLIST", "播放列表,播放歌单" @@ -246,6 +247,7 @@ def init_keyword(self): self.append_keyword(self.keywords_search_playlocal, "search_playlocal") self.append_keyword(self.keywords_play, "play") self.append_keyword(self.keywords_search_play, "search_play") + self.append_keyword(self.keywords_online_play, "online_play") self.append_keyword(self.keywords_stop, "stop") self.append_keyword(self.keywords_playlist, "play_music_list") self.append_user_keyword() diff --git a/xiaomusic/httpserver.py b/xiaomusic/httpserver.py index 44c50862c6..f433a646d3 100644 --- a/xiaomusic/httpserver.py +++ b/xiaomusic/httpserver.py @@ -94,7 +94,7 @@ async def app_lifespan(app): def verification( - credentials: Annotated[HTTPBasicCredentials, Depends(security)], + credentials: Annotated[HTTPBasicCredentials, Depends(security)], ): current_username_bytes = credentials.username.encode("utf8") correct_username_bytes = config.httpauth_username.encode("utf8") @@ -252,6 +252,239 @@ def searchmusic(name: str = "", Verifcation=Depends(verification)): return xiaomusic.searchmusic(name) +@app.get("/api/search/online") +async def search_online_music( + keyword: str = Query(..., description="搜索关键词"), + plugin: str = Query("all", description="指定插件名称,all表示搜索所有插件"), + page: int = Query(1, description="页码"), + limit: int = Query(20, description="每页数量"), + Verifcation=Depends(verification) +): + """在线音乐搜索API""" + try: + if not keyword: + return {"success": False, "error": "Keyword required"} + + return await xiaomusic.get_music_list_online(keyword=keyword, plugin=plugin, page=page, limit=limit) + except Exception as e: + return {"success": False, "error": str(e)} + + +@app.get("/api/proxy/real-music-url") +async def get_real_music_url(url: str = Query(..., description="音乐下载URL"), + Verifcation=Depends(verification)): + """通过服务端代理获取真实的音乐播放URL,避免CORS问题""" + try: + # 获取真实的音乐播放URL + return await xiaomusic.get_real_url_of_openapi(url) + + except Exception as e: + log.error(f"获取真实音乐URL失败: {e}") + # 如果代理获取失败,仍然返回原始URL + return { + "success": False, + "realUrl": url, + "error": str(e) + } + + +@app.post("/api/play/getMediaSource") +async def get_media_source( + request: Request, Verifcation=Depends(verification) +): + """获取音乐真实播放URL""" + try: + # 获取请求数据 + data = await request.json() + # 调用公共函数处理 + return await xiaomusic.get_media_source_url(data) + except Exception as e: + return {"success": False, "error": str(e)} + + +@app.post("/api/play/getLyric") +async def get_media_lyric( + request: Request, Verifcation=Depends(verification) +): + """获取音乐真实播放URL""" + try: + # 获取请求数据 + data = await request.json() + # 调用公共函数处理 + return await xiaomusic.get_media_lyric(data) + except Exception as e: + return {"success": False, "error": str(e)} + + +@app.post("/api/play/online") +async def play_online_music( + request: Request, Verifcation=Depends(verification) +): + """设备端在线播放插件音乐""" + try: + # 获取请求数据 + data = await request.json() + did = data.get('did') + openapi_info = xiaomusic.js_plugin_manager.get_openapi_info() + if openapi_info.get("enabled", False): + media_source = await xiaomusic.get_real_url_of_openapi(data.get('url')) + else: + # 调用公共函数处理,获取音乐真实播放URL + media_source = await xiaomusic.get_media_source_url(data) + if not media_source or not media_source.get('url'): + return {"success": False, "error": "Failed to get media source URL"} + url = media_source.get('url') + decoded_url = urllib.parse.unquote(url) + return await xiaomusic.play_url(did=did, arg1=decoded_url) + except Exception as e: + return {"success": False, "error": str(e)} + + +# =====================================插件入口函数=============== + +@app.get("/api/js-plugins") +def get_js_plugins( + enabled_only: bool = Query(False, description="是否只返回启用的插件"), + Verifcation=Depends(verification) +): + """获取插件列表""" + try: + if not hasattr(xiaomusic, 'js_plugin_manager') or not xiaomusic.js_plugin_manager: + return {"success": False, "error": "JS Plugin Manager not available"} + # 重新加载插件 + # xiaomusic.js_plugin_manager.reload_plugins() + + if enabled_only: + plugins = xiaomusic.js_plugin_manager.get_enabled_plugins() + else: + plugins = xiaomusic.js_plugin_manager.get_plugin_list() + return {"success": True, "data": plugins} + + except Exception as e: + return {"success": False, "error": str(e)} + + +@app.put("/api/js-plugins/{plugin_name}/enable") +def enable_js_plugin(plugin_name: str, Verifcation=Depends(verification)): + """启用插件""" + try: + if not hasattr(xiaomusic, 'js_plugin_manager') or not xiaomusic.js_plugin_manager: + return {"success": False, "error": "JS Plugin Manager not available"} + + success = xiaomusic.js_plugin_manager.enable_plugin(plugin_name) + return {"success": success} + + except Exception as e: + return {"success": False, "error": str(e)} + + +@app.put("/api/js-plugins/{plugin_name}/disable") +def disable_js_plugin(plugin_name: str, Verifcation=Depends(verification)): + """禁用插件""" + try: + if not hasattr(xiaomusic, 'js_plugin_manager') or not xiaomusic.js_plugin_manager: + return {"success": False, "error": "JS Plugin Manager not available"} + + success = xiaomusic.js_plugin_manager.disable_plugin(plugin_name) + return {"success": success} + + except Exception as e: + return {"success": False, "error": str(e)} + + +@app.delete("/api/js-plugins/{plugin_name}/uninstall") +def uninstall_js_plugin(plugin_name: str, Verifcation=Depends(verification)): + """卸载插件""" + try: + if not hasattr(xiaomusic, 'js_plugin_manager') or not xiaomusic.js_plugin_manager: + return {"success": False, "error": "JS Plugin Manager not available"} + + success = xiaomusic.js_plugin_manager.uninstall_plugin(plugin_name) + return {"success": success} + + except Exception as e: + return {"success": False, "error": str(e)} + + +@app.post("/api/js-plugins/upload") +async def upload_js_plugin( + file: UploadFile = File(...), + verification=Depends(verification) +): + """上传 JS 插件""" + try: + # 验证文件扩展名 + if not file.filename.endswith('.js'): + raise HTTPException(status_code=400, detail="只允许上传 .js 文件") + + # 使用 JSPluginManager 中定义的插件目录 + if not hasattr(xiaomusic, 'js_plugin_manager') or not xiaomusic.js_plugin_manager: + raise HTTPException(status_code=500, detail="JS Plugin Manager not available") + + plugin_dir = xiaomusic.js_plugin_manager.plugins_dir + os.makedirs(plugin_dir, exist_ok=True) + file_path = os.path.join(plugin_dir, file.filename) + # 校验是否已存在同名js插件 存在则提示,停止上传 + if os.path.exists(file_path): + raise HTTPException(status_code=409, detail=f"插件 {file.filename} 已存在,请重命名后再上传") + file_path = os.path.join(plugin_dir, file.filename) + + # 写入文件内容 + async with aiofiles.open(file_path, 'wb') as f: + content = await file.read() + await f.write(content) + + # 更新插件配置文件 + plugin_name = os.path.splitext(file.filename)[0] + xiaomusic.js_plugin_manager.update_plugin_config(plugin_name, file.filename) + + # 重新加载插件 + xiaomusic.js_plugin_manager.reload_plugins() + + return {"success": True, "message": "插件上传成功"} + + except Exception as e: + return {"success": False, "error": str(e)} + + +# =====================================开放接口配置函数=============== + +@app.get("/api/openapi/load") +def get_openapi_info( + Verifcation=Depends(verification) +): + """获取开放接口配置信息""" + try: + openapi_info = xiaomusic.js_plugin_manager.get_openapi_info() + return {"success": True, "data": openapi_info} + except Exception as e: + return {"success": False, "error": str(e)} + + +@app.post("/api/openapi/toggle") +def toggle_openapi(Verifcation=Depends(verification)): + """开放接口状态切换""" + try: + return xiaomusic.js_plugin_manager.toggle_openapi() + except Exception as e: + return {"success": False, "error": str(e)} + + +@app.post("/api/openapi/updateUrl") +async def update_openapi_url(request: Request, Verifcation=Depends(verification)): + """更新开放接口地址""" + try: + request_json = await request.json() + search_url = request_json.get('search_url') + if not request_json or 'search_url' not in request_json: + return {"success": False, "error": "Missing 'search_url' in request body"} + return xiaomusic.js_plugin_manager.update_openapi_url(search_url) + except Exception as e: + return {"success": False, "error": str(e)} + + +# =====================================开放接口函数END=============== + @app.get("/playingmusic") def playingmusic(did: str = "", Verifcation=Depends(verification)): if not xiaomusic.did_exist(did): @@ -343,7 +576,7 @@ async def musiclist(Verifcation=Depends(verification)): @app.get("/musicinfo") async def musicinfo( - name: str, musictag: bool = False, Verifcation=Depends(verification) + name: str, musictag: bool = False, Verifcation=Depends(verification) ): url, _ = await xiaomusic.get_music_url(name) info = { @@ -358,9 +591,9 @@ async def musicinfo( @app.get("/musicinfos") async def musicinfos( - name: list[str] = Query(None), - musictag: bool = False, - Verifcation=Depends(verification), + name: list[str] = Query(None), + musictag: bool = False, + Verifcation=Depends(verification), ): ret = [] for music_name in name: @@ -662,7 +895,7 @@ class PlayListUpdateObj(BaseModel): # 修改歌单名字 @app.post("/playlistupdatename") async def playlistupdatename( - data: PlayListUpdateObj, Verifcation=Depends(verification) + data: PlayListUpdateObj, Verifcation=Depends(verification) ): ret = xiaomusic.play_list_update_name(data.oldname, data.newname) if ret: @@ -707,7 +940,7 @@ async def playlistdelmusic(data: PlayListMusicObj, Verifcation=Depends(verificat # 歌单更新歌曲 @app.post("/playlistupdatemusic") async def playlistupdatemusic( - data: PlayListMusicObj, Verifcation=Depends(verification) + data: PlayListMusicObj, Verifcation=Depends(verification) ): ret = xiaomusic.play_list_update_music(data.name, data.music_list) if ret: @@ -728,7 +961,7 @@ async def getplaylist(name: str, Verifcation=Depends(verification)): # 更新版本 @app.post("/updateversion") async def updateversion( - version: str = "", lite: bool = True, Verifcation=Depends(verification) + version: str = "", lite: bool = True, Verifcation=Depends(verification) ): ret = await update_version(version, lite) if ret != "OK": @@ -759,7 +992,7 @@ def access_key_verification(file_path, key, code): if key is not None: current_key_bytes = key.encode("utf8") correct_key_bytes = ( - config.httpauth_username + config.httpauth_password + config.httpauth_username + config.httpauth_password ).encode("utf8") is_correct_key = secrets.compare_digest(correct_key_bytes, current_key_bytes) if is_correct_key: @@ -770,7 +1003,7 @@ def access_key_verification(file_path, key, code): correct_code_bytes = ( hashlib.sha256( ( - file_path + config.httpauth_username + config.httpauth_password + file_path + config.httpauth_username + config.httpauth_password ).encode("utf-8") ) .hexdigest() @@ -955,8 +1188,8 @@ async def stream_generator(): @app.get("/generate_ws_token") def generate_ws_token( - did: str, - _: bool = Depends(verification), # 复用 HTTP Basic 验证 + did: str, + _: bool = Depends(verification), # 复用 HTTP Basic 验证 ): if not xiaomusic.did_exist(did): raise HTTPException(status_code=400, detail="Invalid did") diff --git a/xiaomusic/js_adapter.py b/xiaomusic/js_adapter.py new file mode 100644 index 0000000000..a4c39c5a03 --- /dev/null +++ b/xiaomusic/js_adapter.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +JS 插件适配器 +将 MusicFree JS 插件的数据格式转换为 xiaomusic 接口规范 +""" + +import logging +from typing import Dict, List, Any, Optional +from urllib.parse import urlparse + + +class JSAdapter: + """JS 插件数据适配器""" + + def __init__(self, xiaomusic): + self.xiaomusic = xiaomusic + self.log = logging.getLogger(__name__) + + def format_search_results(self, plugin_results: List[Dict], plugin_name: str) -> List[str]: + """格式化搜索结果为 xiaomusic 格式,返回 ID 列表""" + formatted_ids = [] + for item in plugin_results: + if not isinstance(item, dict): + self.log.warning(f"Invalid item format in plugin {plugin_name}: {item}") + continue + + # 构造符合 xiaomusic 格式的音乐项 + music_id = self._generate_music_id(plugin_name, item.get('id', ''), item.get('songmid', '')) + music_item = { + 'id': music_id, + 'title': item.get('title', item.get('name', '')), + 'artist': self._extract_artists(item), + 'album': item.get('album', item.get('albumName', '')), + 'source': 'online', + 'plugin_name': plugin_name, + 'original_data': item, + 'duration': item.get('duration', 0), + 'cover': item.get('artwork', item.get('cover', item.get('albumPic', ''))), + 'url': item.get('url', ''), + 'lyric': item.get('lyric', item.get('lrc', '')), + 'quality': item.get('quality', ''), + } + + # 添加到 all_music 字典中 + self.xiaomusic.all_music[music_id] = music_item + formatted_ids.append(music_id) + + return formatted_ids + + def format_media_source_result(self, media_source_result: Dict, music_item: Dict) -> Dict: + """格式化媒体源结果""" + if not media_source_result: + return {} + + formatted = { + 'url': media_source_result.get('url', ''), + 'headers': media_source_result.get('headers', {}), + 'userAgent': media_source_result.get('userAgent', media_source_result.get('user_agent', '')) + } + + return formatted + + def format_lyric_result(self, lyric_result: Dict) -> str: + """格式化歌词结果为 lrc 格式字符串""" + if not lyric_result: + return '' + + # 获取原始歌词和翻译 + raw_lrc = lyric_result.get('rawLrc', lyric_result.get('raw_lrc', '')) + translation = lyric_result.get('translation', '') + + # 如果有翻译,合并歌词和翻译 + if translation and raw_lrc: + # 这里可以实现歌词和翻译的合并逻辑 + return f"{raw_lrc}\n{translation}" + + return raw_lrc or translation or '' + + def format_album_info_result(self, album_info_result: Dict) -> Dict: + """格式化专辑信息结果""" + if not album_info_result: + return {} + + formatted = { + 'isEnd': album_info_result.get('isEnd', True), + 'musicList': self.format_search_results(album_info_result.get('musicList', []), 'album'), + 'albumItem': { + 'title': album_info_result.get('albumItem', {}).get('title', ''), + 'artist': album_info_result.get('albumItem', {}).get('artist', ''), + 'cover': album_info_result.get('albumItem', {}).get('cover', ''), + 'description': album_info_result.get('albumItem', {}).get('description', ''), + } + } + + return formatted + + def format_music_sheet_info_result(self, music_sheet_result: Dict) -> Dict: + """格式化音乐单信息结果""" + if not music_sheet_result: + return {} + + formatted = { + 'isEnd': music_sheet_result.get('isEnd', True), + 'musicList': self.format_search_results(music_sheet_result.get('musicList', []), 'playlist'), + 'sheetItem': { + 'title': music_sheet_result.get('sheetItem', {}).get('title', ''), + 'cover': music_sheet_result.get('sheetItem', {}).get('cover', ''), + 'description': music_sheet_result.get('sheetItem', {}).get('description', ''), + } + } + + return formatted + + def format_artist_works_result(self, artist_works_result: Dict) -> Dict: + """格式化艺术家作品结果""" + if not artist_works_result: + return {} + + formatted = { + 'isEnd': artist_works_result.get('isEnd', True), + 'data': self.format_search_results(artist_works_result.get('data', []), 'artist'), + } + + return formatted + + def format_top_lists_result(self, top_lists_result: List[Dict]) -> List[Dict]: + """格式化榜单列表结果""" + if not top_lists_result: + return [] + + formatted = [] + for group in top_lists_result: + formatted_group = { + 'title': group.get('title', ''), + 'data': [] + } + + for item in group.get('data', []): + formatted_item = { + 'id': item.get('id', ''), + 'title': item.get('title', ''), + 'description': item.get('description', ''), + 'coverImg': item.get('coverImg', item.get('cover', '')), + } + formatted_group['data'].append(formatted_item) + + formatted.append(formatted_group) + + return formatted + + def format_top_list_detail_result(self, top_list_detail_result: Dict) -> Dict: + """格式化榜单详情结果""" + if not top_list_detail_result: + return {} + + formatted = { + 'isEnd': top_list_detail_result.get('isEnd', True), + 'musicList': self.format_search_results(top_list_detail_result.get('musicList', []), 'toplist'), + 'topListItem': top_list_detail_result.get('topListItem', {}), + } + + return formatted + + def _generate_music_id(self, plugin_name: str, item_id: str, fallback_id: str = '') -> str: + """生成唯一音乐ID""" + if item_id: + return f"online_{plugin_name}_{item_id}" + else: + # 如果没有 id,尝试使用其他标识符 + return f"online_{plugin_name}_{fallback_id}" + + def _extract_artists(self, item: Dict) -> str: + """提取艺术家信息""" + # 尝试多种可能的艺术家字段 + artist_fields = ['artist', 'artists', 'singer', 'author', 'creator', 'singers'] + + for field in artist_fields: + if field in item: + value = item[field] + if isinstance(value, list): + # 如果是艺术家列表,连接为字符串 + artists = [] + for artist in value: + if isinstance(artist, dict): + artists.append(artist.get('name', str(artist))) + else: + artists.append(str(artist)) + return ', '.join(artists) + elif isinstance(value, dict): + # 如果是艺术家对象 + return value.get('name', str(value)) + elif value: + return str(value) + + # 如果没有找到艺术家信息,返回默认值 + return '未知艺术家' + + def convert_music_item_for_plugin(self, music_item: Dict) -> Dict: + """将 xiaomusic 音乐项转换为插件兼容格式""" + # 如果原始数据存在,优先使用原始数据 + if isinstance(music_item, dict) and 'original_data' in music_item: + return music_item['original_data'] + + # 否则构造一个基本的音乐项 + converted = { + 'id': music_item.get('id', ''), + 'title': music_item.get('title', ''), + 'artist': music_item.get('artist', ''), + 'album': music_item.get('album', ''), + 'url': music_item.get('url', ''), + 'duration': music_item.get('duration', 0), + 'artwork': music_item.get('cover', ''), + 'lyric': music_item.get('lyric', ''), + 'quality': music_item.get('quality', ''), + } + + return converted diff --git a/xiaomusic/js_plugin_manager.py b/xiaomusic/js_plugin_manager.py new file mode 100644 index 0000000000..87a684bf31 --- /dev/null +++ b/xiaomusic/js_plugin_manager.py @@ -0,0 +1,1014 @@ +#!/usr/bin/env python3 +""" +JS 插件管理器 +负责加载、管理和运行 MusicFree JS 插件 +""" + +import json +import logging +import os +import subprocess +import threading +import time +import shutil +from typing import Dict, Any, List + + +class JSPluginManager: + """JS 插件管理器""" + + def __init__(self, xiaomusic): + self.xiaomusic = xiaomusic + base_path = self.xiaomusic.config.conf_path + self.log = logging.getLogger(__name__) + # JS插件文件夹: + self.plugins_dir = os.path.join(base_path, "js_plugins") + # 插件配置Json: + self.plugins_config_path = os.path.join(base_path, "plugins-config.json") + self.plugins = {} # 插件状态信息 + self.node_process = None + self.message_queue = [] + self.response_handlers = {} + self._lock = threading.Lock() + self.request_id = 0 + self.pending_requests = {} + + # 启动 Node.js 子进程 + self._start_node_process() + + # 启动消息处理线程 + self._start_message_handler() + + # 加载插件 + self._load_plugins() + + def _start_node_process(self): + """启动 Node.js 子进程""" + runner_path = os.path.join(os.path.dirname(__file__), "js_plugin_runner.js") + + try: + self.node_process = subprocess.Popen( + ['node', '--max-old-space-size=128', runner_path], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding='utf-8', + errors='replace', + bufsize=1 # 行缓冲 + ) + + self.log.info("Node.js process started successfully") + + # 启动进程监控线程 + threading.Thread(target=self._monitor_node_process, daemon=True).start() + + except Exception as e: + self.log.error(f"Failed to start Node.js process: {e}") + raise + + def _monitor_node_process(self): + """监控 Node.js 进程状态""" + while True: + if self.node_process and self.node_process.poll() is not None: + self.log.warning("Node.js process died, restarting...") + self._start_node_process() + time.sleep(5) + + def _start_message_handler(self): + """启动消息处理线程""" + + def stdout_handler(): + while True: + if self.node_process and self.node_process.stdout: + try: + line = self.node_process.stdout.readline() + if line: + response = json.loads(line.strip()) + self._handle_response(response) + except json.JSONDecodeError as e: + # 捕获非 JSON 输出(可能是插件的调试信息或错误信息) + self.log.warning(f"Non-JSON output from Node.js process: {line.strip()}, error: {e}") + except Exception as e: + self.log.error(f"Message handler error: {e}") + time.sleep(0.1) + + def stderr_handler(): + """处理 Node.js 进程的错误输出""" + while True: + if self.node_process and self.node_process.stderr: + try: + error_line = self.node_process.stderr.readline() + if error_line: + self.log.error(f"Node.js process error output: {error_line.strip()}") + except Exception as e: + self.log.error(f"Error handler error: {e}") + time.sleep(0.1) + + threading.Thread(target=stdout_handler, daemon=True).start() + threading.Thread(target=stderr_handler, daemon=True).start() + + def _send_message(self, message: Dict[str, Any], timeout: int = 30) -> Dict[str, Any]: + """发送消息到 Node.js 子进程""" + with self._lock: + if not self.node_process or self.node_process.poll() is not None: + raise Exception("Node.js process not available") + + message_id = f"msg_{int(time.time() * 1000)}" + message['id'] = message_id + + # 记录发送的消息 + self.log.info( + f"JS Plugin Manager sending message: {message.get('action', 'unknown')} for plugin: {message.get('pluginName', 'unknown')}") + if 'params' in message: + self.log.info(f"JS Plugin Manager search params: {message['params']}") + elif 'musicItem' in message: + self.log.info(f"JS Plugin Manager music item: {message['musicItem']}") + + # 发送消息 + self.node_process.stdin.write(json.dumps(message) + '\n') + self.node_process.stdin.flush() + + # 等待响应 + response = self._wait_for_response(message_id, timeout) + self.log.info( + f"JS Plugin Manager received response for message {message_id}: {response.get('success', 'unknown')}") + return response + + def _wait_for_response(self, message_id: str, timeout: int) -> Dict[str, Any]: + """等待特定消息的响应""" + start_time = time.time() + + while time.time() - start_time < timeout: + if message_id in self.response_handlers: + response = self.response_handlers.pop(message_id) + return response + time.sleep(0.1) + + raise TimeoutError(f"Message {message_id} timeout") + + def _handle_response(self, response: Dict[str, Any]): + """处理 Node.js 进程的响应""" + message_id = response.get('id') + self.log.debug(f"JS Plugin Manager received raw response: {response}") # 添加原始响应日志 + + # 添加更严格的数据验证 + if not isinstance(response, dict): + self.log.error(f"JS Plugin Manager received invalid response type: {type(response)}, value: {response}") + return + + if 'id' not in response: + self.log.error(f"JS Plugin Manager received response without id: {response}") + return + + # 确保 success 字段存在 + if 'success' not in response: + self.log.warning(f"JS Plugin Manager received response without success field: {response}") + response['success'] = False + + # 如果有 result 字段,验证其结构 + if 'result' in response and response['result'] is not None: + result = response['result'] + if isinstance(result, dict): + # 对搜索结果进行特殊处理 + if 'data' in result and not isinstance(result['data'], list): + self.log.warning( + f"JS Plugin Manager received result with invalid data type: {type(result['data'])}, setting to empty list") + result['data'] = [] + + if message_id: + self.response_handlers[message_id] = response + + """------------------------------开放接口相关函数----------------------------------------""" + + def get_openapi_info(self) -> Dict[str, Any]: + """获取开放接口配置信息 + Returns: + Dict[str, Any]: 包含 OpenAPI 配置信息的字典,包括启用状态和搜索 URL + """ + try: + # 读取配置文件中的 OpenAPI 配置信息 + if os.path.exists(self.plugins_config_path): + with open(self.plugins_config_path, 'r', encoding='utf-8') as f: + config_data = json.load(f) + # 返回 openapi_info 配置项 + return config_data.get("openapi_info", {}) + else: + return {"enabled": False} + except Exception as e: + self.log.error(f"Failed to read OpenAPI info from config: {e}") + return {} + + def toggle_openapi(self) -> Dict[str, Any]: + """切换开放接口配置状态 + Returns: 切换后的配置信息 + """ + try: + # 读取配置文件中的 OpenAPI 配置信息 + if os.path.exists(self.plugins_config_path): + with open(self.plugins_config_path, 'r', encoding='utf-8') as f: + config_data = json.load(f) + + # 获取当前的 openapi_info 配置,如果没有则初始化 + openapi_info = config_data.get("openapi_info", {}) + + # 切换启用状态:和当前状态取反 + current_enabled = openapi_info.get("enabled", False) + openapi_info["enabled"] = not current_enabled + + # 更新配置数据 + config_data["openapi_info"] = openapi_info + # 写回配置文件 + with open(self.plugins_config_path, 'w', encoding='utf-8') as f: + json.dump(config_data, f, ensure_ascii=False, indent=2) + return {"success": True} + else: + return {"success": False} + except Exception as e: + self.log.error(f"Failed to toggle OpenAPI config: {e}") + # 出错时返回默认配置 + return {"success": False, "error": str(e)} + + def update_openapi_url(self,openapi_url: str) -> Dict[str, Any]: + """更新开放接口地址 + Returns: 更新后的配置信息 + :type openapi_url: 新的接口地址 + """ + try: + # 读取配置文件中的 OpenAPI 配置信息 + if os.path.exists(self.plugins_config_path): + with open(self.plugins_config_path, 'r', encoding='utf-8') as f: + config_data = json.load(f) + + # 获取当前的 openapi_info 配置,如果没有则初始化 + openapi_info = config_data.get("openapi_info", {}) + + # 切换启用状态:和当前状态取反 + # current_url = openapi_info.get("search_url", "") + openapi_info["search_url"] = openapi_url + + # 更新配置数据 + config_data["openapi_info"] = openapi_info + # 写回配置文件 + with open(self.plugins_config_path, 'w', encoding='utf-8') as f: + json.dump(config_data, f, ensure_ascii=False, indent=2) + return {"success": True} + else: + return {"success": False} + except Exception as e: + self.log.error(f"Failed to toggle OpenAPI config: {e}") + # 出错时返回默认配置 + return {"success": False, "error": str(e)} + + """----------------------------------------------------------------------""" + + def _load_plugins(self): + """加载所有插件""" + if not os.path.exists(self.plugins_dir): + os.makedirs(self.plugins_dir) + + # 读取、加载插件配置Json + if not os.path.exists(self.plugins_config_path): + # 复制 plugins-config-example.json 模板,创建插件配置Json文件 + example_config_path = os.path.join(os.path.dirname(__file__), "plugins-config-example.json") + if os.path.exists(example_config_path): + shutil.copy2(example_config_path, self.plugins_config_path) + else: + base_config = { + "account": "", + "password": "", + "enabled_plugins": [], + "plugins_info": [], + "openapi_info": { + "enabled": False, + "search_url": "" + } + } + with open(self.plugins_config_path, 'w', encoding='utf-8') as f: + json.dump(base_config, f, ensure_ascii=False, indent=2) + # 输出文件夹、配置文件地址 + self.log.info(f"Plugins directory: {self.plugins_dir}") + self.log.info(f"Plugins config file: {self.plugins_config_path}") + # 只加载指定的插件,避免加载所有插件导致超时 + # enabled_plugins = ['kw', 'qq-yuanli'] # 可以根据需要添加更多 + # 读取配置文件配置 + enabled_plugins = self.get_enabled_plugins() + for filename in os.listdir(self.plugins_dir): + if filename.endswith('.js'): + try: + plugin_name = os.path.splitext(filename)[0] + # 如果是重要插件或没有指定重要插件列表,则加载 + if not enabled_plugins or plugin_name in enabled_plugins: + try: + self.log.info(f"Loading plugin: {plugin_name}") + self.load_plugin(plugin_name) + except Exception as e: + self.log.error(f"Failed to load important plugin {plugin_name}: {e}") + # 即使加载失败也记录插件信息 + self.plugins[plugin_name] = { + 'name': plugin_name, + 'enabled': False, + 'loaded': False, + 'error': str(e) + } + else: + self.log.debug(f"Skipping plugin (not in important list): {plugin_name}") + # 标记为未加载但可用 + self.plugins[plugin_name] = { + 'name': plugin_name, + 'enabled': False, + 'loaded': False, + 'error': 'Not loaded (not in important plugins list)' + } + except Exception as e: + self.log.error(f"Failed to load plugin {filename}: {e}") + # 即使加载失败也记录插件信息 + self.plugins[plugin_name] = { + 'name': plugin_name, + 'enabled': False, + 'loaded': False, + 'error': str(e) + } + + def load_plugin(self, plugin_name: str) -> bool: + """加载单个插件""" + plugin_file = os.path.join(self.plugins_dir, f"{plugin_name}.js") + + if not os.path.exists(plugin_file): + raise FileNotFoundError(f"Plugin file not found: {plugin_file}") + + try: + with open(plugin_file, 'r', encoding='utf-8') as f: + js_code = f.read() + + response = self._send_message({ + 'action': 'load', + 'name': plugin_name, + 'code': js_code + }) + + if response['success']: + self.plugins[plugin_name] = { + 'status': 'loaded', + 'load_time': time.time(), + 'enabled': True + } + self.log.info(f"Loaded JS plugin: {plugin_name}") + return True + else: + self.log.error(f"Failed to load JS plugin {plugin_name}: {response['error']}") + return False + + except Exception as e: + self.log.error(f"Failed to load JS plugin {plugin_name}: {e}") + return False + + def get_plugin_list(self) -> List[Dict[str, Any]]: + """获取启用的插件列表""" + result = [] + try: + # 读取配置文件中的启用插件列表 + if os.path.exists(self.plugins_config_path): + with open(self.plugins_config_path, 'r', encoding='utf-8') as f: + config_data = json.load(f) + plugin_infos = config_data.get("plugins_info", []) + enabled_plugins = config_data.get("enabled_plugins", []) + + # 创建一个映射,用于快速查找插件在 enabled_plugins 中的位置 + enabled_order = {name: i for i, name in enumerate(enabled_plugins)} + + # 先按 enabled 属性排序(True 在前) + # 再按 enabled_plugins 顺序排序(启用的插件才参与此排序) + def sort_key(plugin_info): + name = plugin_info['name'] + is_enabled = plugin_info.get('enabled', False) + order = enabled_order.get(name, len(enabled_plugins)) if is_enabled else len(enabled_plugins) + # (-is_enabled) 将 True(1) 放到前面,False(0) 放到后面 + # order 控制启用插件间的相对顺序 + return -is_enabled, order + + result = sorted(plugin_infos, key=sort_key) + except Exception as e: + self.log.error(f"Failed to read enabled plugins from config: {e}") + return result + + def get_enabled_plugins(self) -> List[str]: + """获取启用的插件列表""" + try: + # 读取配置文件中的启用插件列表 + if os.path.exists(self.plugins_config_path): + with open(self.plugins_config_path, 'r', encoding='utf-8') as f: + config_data = json.load(f) + return config_data.get("enabled_plugins", []) + else: + return [] + except Exception as e: + self.log.error(f"Failed to read enabled plugins from config: {e}") + return [] + + def search(self, plugin_name: str, keyword: str, page: int = 1, limit: int = 20): + """搜索音乐""" + if plugin_name not in self.plugins: + raise ValueError(f"Plugin {plugin_name} not found or not loaded") + + self.log.info(f"JS Plugin Manager starting search in plugin {plugin_name} for keyword: {keyword}") + response = self._send_message({ + 'action': 'search', + 'pluginName': plugin_name, + 'params': { + 'keywords': keyword, + 'page': page, + 'limit': limit + } + }) + + self.log.debug(f"JS Plugin Manager search response: {response}") # 使用 debug 级别,减少日志量 + + if not response['success']: + self.log.error(f"JS Plugin Manager search failed in plugin {plugin_name}: {response['error']}") + # 添加详细的错误信息 + self.log.error(f"JS Plugin Manager full error response: {response}") + raise Exception(f"Search failed: {response['error']}") + else: + # 检查返回的数据结构 + result_data = response['result'] + self.log.debug(f"JS Plugin Manager search raw result: {result_data}") # 使用 debug 级别 + data_list = result_data.get('data', []) + is_end = result_data.get('isEnd', True) + self.log.info( + f"JS Plugin Manager search completed in plugin {plugin_name}, isEnd: {is_end}, found {len(data_list)} results") + # 检查数据类型是否正确 + if not isinstance(data_list, list): + self.log.error( + f"JS Plugin Manager search returned invalid data type: {type(data_list)}, value: {data_list}") + else: + self.log.debug( + f"JS Plugin Manager search data sample: {data_list[:2] if len(data_list) > 0 else 'No results'}") + return result_data + + async def openapi_search(self, url: str, keyword: str, limit: int = 10): + """直接调用在线接口进行音乐搜索 + + Args: + url (str): 在线搜索接口地址 + keyword (str): 搜索关键词,支持: 歌曲名-歌手名 搜索 + limit (int): 每页数量,默认为5 + Returns: + Dict[str, Any]: 搜索结果,数据结构与search函数一致 + """ + import aiohttp + import asyncio + + try: + # 如果关键词包含 '-',则提取歌手名、歌名 + if '-' in keyword: + parts = keyword.split('-') + keyword = parts[0] + artist = parts[1] + else: + artist = "" + # 构造请求参数 + params = { + 'type': "aggregateSearch", + 'keyword': keyword, + 'limit': limit + } + # 使用aiohttp发起异步HTTP GET请求 + async with aiohttp.ClientSession() as session: + async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=10)) as response: + response.raise_for_status() # 抛出HTTP错误 + # 解析响应数据 + raw_data = await response.json() + + self.log.info(f"在线接口返回Json: {raw_data}") + + # 检查API调用是否成功 + if raw_data.get('code') != 200: + raise Exception(f"API request failed with code: {raw_data.get('code', 'unknown')}") + + # 提取实际的搜索结果 + api_data = raw_data.get('data', {}) + results = api_data.get('results', []) + + # 转换数据格式以匹配插件系统的期望格式 + converted_data = [] + for item in results: + converted_item = { + 'id': item.get('id', ''), + 'title': item.get('name', ''), + 'artist': item.get('artist', ''), + 'album': item.get('album', ''), + 'platform': 'OpenAPI-' + item.get('platform'), + 'isOpenAPI': True, + 'url': item.get('url', ''), + 'artwork': item.get('pic', ''), + 'lrc': item.get('lrc', '') + } + converted_data.append(converted_item) + # 排序筛选 + unified_result = {"data": converted_data} + # 调用优化函数 + optimized_result = self.optimize_search_results( + unified_result, + search_keyword=keyword, + limit=limit, + search_artist=artist + ) + results = optimized_result.get('data', []) + # 返回统一格式的数据 + return { + "success": True, + "data": results, + "total": len(results), + "sources": {"OpenAPI": len(results)}, + "page": 1, + "limit": limit + } + + except asyncio.TimeoutError as e: + self.log.error(f"OpenAPI search timeout at URL {url}: {e}") + return { + "success": False, + "error": f"OpenAPI search timeout: {str(e)}", + "data": [], + "total": 0, + "sources": {}, + "page": 1, + "limit": limit + } + except Exception as e: + self.log.error(f"OpenAPI search error at URL {url}: {e}") + return { + "success": False, + "error": f"OpenAPI search error: {str(e)}", + "data": [], + "total": 0, + "sources": {}, + "page": 1, + "limit": limit + } + + def optimize_search_results( + self, + result_data: Dict[str, Any], # 搜索结果数据,字典类型,包含任意类型的值 + search_keyword: str = "", # 搜索关键词,默认为空字符串 + search_artist: str = "", # 搜索歌手名,默认为空字符串 + limit: int = 1 # 返回结果数量限制,默认为1 + ) -> Dict[str, Any]: # 返回优化后的搜索结果,字典类型,包含任意类型的值 + """ + 优化搜索结果,根据关键词、歌手名和平台权重对结果进行排序 + 参数: + result_data: 原始搜索结果数据 + search_keyword: 搜索的关键词 + search_artist: 搜索的歌手名 + limit: 返回结果的最大数量 + 返回: + 优化后的搜索结果数据,已根据匹配度和平台权重排序 + """ + if not result_data or 'data' not in result_data or not result_data['data']: + return result_data + + # 清理搜索关键词和歌手名,去除首尾空格 + search_keyword = search_keyword.strip() + search_artist = search_artist.strip() + + # 如果关键词和歌手名都为空,则不进行排序 + if not search_keyword and not search_artist: + return result_data # 两者都空才不排序 + + # 获取待处理的数据列表 + data_list = result_data['data'] + self.log.info(f"列表信息::{data_list}") + # 预计算平台权重,启用插件列表中的前9个插件有权重,排名越靠前权重越高 + enabled_plugins = self.get_enabled_plugins() + plugin_weights = {p: 9 - i for i, p in enumerate(enabled_plugins[:9])} + + def calculate_match_score(item): + """ + 计算单个搜索结果的匹配分数 + 参数: + item: 单个搜索结果项 + 返回: + 匹配分数,包含标题匹配分、艺术家匹配分和平台加分 + """ + # 获取并标准化标题、艺术家和平台信息 + title = item.get('title', '').lower() + artist = item.get('artist', '').lower() + platform = item.get('platform', '') + + # 标准化搜索关键词和艺术家名 + kw = search_keyword.lower() + ar = search_artist.lower() + + # 歌名匹配分 + title_score = 0 + if kw: + if kw == title: + title_score = 400 + elif title.startswith(kw): + title_score = 300 + elif kw in title: + title_score = 200 + + # 歌手匹配分 + artist_score = 0 + if ar: + if ar == artist: + artist_score = 1000 + elif artist.startswith(ar): + artist_score = 800 + elif ar in artist: + artist_score = 600 + + platform_bonus = plugin_weights.get(platform, 0) + return title_score + artist_score + platform_bonus + + sorted_data = sorted(data_list, key=calculate_match_score, reverse=True) + self.log.info(f"排序后列表信息::{sorted_data}") + if 0 < limit < len(sorted_data): + sorted_data = sorted_data[:limit] + result_data['data'] = sorted_data + return result_data + + def get_media_source(self, plugin_name: str, music_item: Dict[str, Any], quality): + """获取媒体源""" + if plugin_name not in self.plugins: + raise ValueError(f"Plugin {plugin_name} not found or not loaded") + + self.log.debug( + f"JS Plugin Manager getting media source in plugin {plugin_name} for item: {music_item.get('title', 'unknown')} by {music_item.get('artist', 'unknown')}") + response = self._send_message({ + 'action': 'getMediaSource', + 'pluginName': plugin_name, + 'musicItem': music_item, + 'quality': quality + }) + + if not response['success']: + self.log.error(f"JS Plugin Manager getMediaSource failed in plugin {plugin_name}: {response['error']}") + raise Exception(f"getMediaSource failed: {response['error']}") + else: + self.log.debug( + f"JS Plugin Manager getMediaSource completed in plugin {plugin_name}, URL length: {len(response['result'].get('url', '')) if response['result'] else 0}") + + return response['result'] + + def get_lyric(self, plugin_name: str, music_item: Dict[str, Any]): + """获取歌词""" + if plugin_name not in self.plugins: + raise ValueError(f"Plugin {plugin_name} not found or not loaded") + + self.log.debug( + f"JS Plugin Manager getting lyric in plugin {plugin_name} for music: {music_item.get('title', 'unknown')}") + response = self._send_message({ + 'action': 'getLyric', + 'pluginName': plugin_name, + 'musicItem': music_item + }) + + if not response['success']: + self.log.error(f"JS Plugin Manager getLyric failed in plugin {plugin_name}: {response['error']}") + raise Exception(f"getLyric failed: {response['error']}") + + return response['result'] + + def get_music_info(self, plugin_name: str, music_item: Dict[str, Any]): + """获取音乐详情""" + if plugin_name not in self.plugins: + raise ValueError(f"Plugin {plugin_name} not found or not loaded") + + self.log.debug( + f"JS Plugin Manager getting music info in plugin {plugin_name} for music: {music_item.get('title', 'unknown')}") + response = self._send_message({ + 'action': 'getMusicInfo', + 'pluginName': plugin_name, + 'musicItem': music_item + }) + + if not response['success']: + self.log.error(f"JS Plugin Manager getMusicInfo failed in plugin {plugin_name}: {response['error']}") + raise Exception(f"getMusicInfo failed: {response['error']}") + + return response['result'] + + def get_album_info(self, plugin_name: str, album_info: Dict[str, Any], page: int = 1): + """获取专辑详情""" + if plugin_name not in self.plugins: + raise ValueError(f"Plugin {plugin_name} not found or not loaded") + + self.log.debug( + f"JS Plugin Manager getting album info in plugin {plugin_name} for album: {album_info.get('title', 'unknown')}") + response = self._send_message({ + 'action': 'getAlbumInfo', + 'pluginName': plugin_name, + 'albumInfo': album_info + }) + + if not response['success']: + self.log.error(f"JS Plugin Manager getAlbumInfo failed in plugin {plugin_name}: {response['error']}") + raise Exception(f"getAlbumInfo failed: {response['error']}") + + return response['result'] + + def get_music_sheet_info(self, plugin_name: str, playlist_info: Dict[str, Any], page: int = 1): + """获取歌单详情""" + if plugin_name not in self.plugins: + raise ValueError(f"Plugin {plugin_name} not found or not loaded") + + self.log.debug( + f"JS Plugin Manager getting music sheet info in plugin {plugin_name} for playlist: {playlist_info.get('title', 'unknown')}") + response = self._send_message({ + 'action': 'getMusicSheetInfo', + 'pluginName': plugin_name, + 'playlistInfo': playlist_info + }) + + if not response['success']: + self.log.error(f"JS Plugin Manager getMusicSheetInfo failed in plugin {plugin_name}: {response['error']}") + raise Exception(f"getMusicSheetInfo failed: {response['error']}") + + return response['result'] + + def get_artist_works(self, plugin_name: str, artist_item: Dict[str, Any], page: int = 1, type_: str = 'music'): + """获取作者作品""" + if plugin_name not in self.plugins: + raise ValueError(f"Plugin {plugin_name} not found or not loaded") + + self.log.debug( + f"JS Plugin Manager getting artist works in plugin {plugin_name} for artist: {artist_item.get('title', 'unknown')}") + response = self._send_message({ + 'action': 'getArtistWorks', + 'pluginName': plugin_name, + 'artistItem': artist_item, + 'page': page, + 'type': type_ + }) + + if not response['success']: + self.log.error(f"JS Plugin Manager getArtistWorks failed in plugin {plugin_name}: {response['error']}") + raise Exception(f"getArtistWorks failed: {response['error']}") + + return response['result'] + + def import_music_item(self, plugin_name: str, url_like: str): + """导入单曲""" + if plugin_name not in self.plugins: + raise ValueError(f"Plugin {plugin_name} not found or not loaded") + + self.log.debug(f"JS Plugin Manager importing music item in plugin {plugin_name} from: {url_like}") + response = self._send_message({ + 'action': 'importMusicItem', + 'pluginName': plugin_name, + 'urlLike': url_like + }) + + if not response['success']: + self.log.error(f"JS Plugin Manager importMusicItem failed in plugin {plugin_name}: {response['error']}") + raise Exception(f"importMusicItem failed: {response['error']}") + + return response['result'] + + def import_music_sheet(self, plugin_name: str, url_like: str): + """导入歌单""" + if plugin_name not in self.plugins: + raise ValueError(f"Plugin {plugin_name} not found or not loaded") + + self.log.debug(f"JS Plugin Manager importing music sheet in plugin {plugin_name} from: {url_like}") + response = self._send_message({ + 'action': 'importMusicSheet', + 'pluginName': plugin_name, + 'urlLike': url_like + }) + + if not response['success']: + self.log.error(f"JS Plugin Manager importMusicSheet failed in plugin {plugin_name}: {response['error']}") + raise Exception(f"importMusicSheet failed: {response['error']}") + + return response['result'] + + def get_top_lists(self, plugin_name: str): + """获取榜单列表""" + if plugin_name not in self.plugins: + raise ValueError(f"Plugin {plugin_name} not found or not loaded") + + self.log.debug(f"JS Plugin Manager getting top lists in plugin {plugin_name}") + response = self._send_message({ + 'action': 'getTopLists', + 'pluginName': plugin_name + }) + + if not response['success']: + self.log.error(f"JS Plugin Manager getTopLists failed in plugin {plugin_name}: {response['error']}") + raise Exception(f"getTopLists failed: {response['error']}") + + return response['result'] + + def get_top_list_detail(self, plugin_name: str, top_list_item: Dict[str, Any], page: int = 1): + """获取榜单详情""" + if plugin_name not in self.plugins: + raise ValueError(f"Plugin {plugin_name} not found or not loaded") + + self.log.debug( + f"JS Plugin Manager getting top list detail in plugin {plugin_name} for list: {top_list_item.get('title', 'unknown')}") + response = self._send_message({ + 'action': 'getTopListDetail', + 'pluginName': plugin_name, + 'topListItem': top_list_item, + 'page': page + }) + + if not response['success']: + self.log.error(f"JS Plugin Manager getTopListDetail failed in plugin {plugin_name}: {response['error']}") + raise Exception(f"getTopListDetail failed: {response['error']}") + + return response['result'] + + # 启用插件 + def enable_plugin(self, plugin_name: str) -> bool: + if plugin_name in self.plugins: + self.plugins[plugin_name]['enabled'] = True + # 读取、修改 插件配置json文件:① 将plugins_info属性中对于的插件状态改为禁用、2:将 enabled_plugins中对应插件移除 + # 同步更新配置文件 + try: + # 使用自定义的配置文件路径 + config_file_path = self.plugins_config_path + + # 读取现有配置 + if os.path.exists(config_file_path): + with open(config_file_path, 'r', encoding='utf-8') as f: + config_data = json.load(f) + + # 更新plugins_info中对应插件的状态 + for plugin_info in config_data.get("plugins_info", []): + if plugin_info.get("name") == plugin_name: + plugin_info["enabled"] = True + + # 添加到enabled_plugins中(如果不存在) + if "enabled_plugins" not in config_data: + config_data["enabled_plugins"] = [] + + if plugin_name not in config_data["enabled_plugins"]: + # 追加到list的第一个 + config_data["enabled_plugins"].insert(0, plugin_name) + + # 写回配置文件 + with open(config_file_path, 'w', encoding='utf-8') as f: + json.dump(config_data, f, ensure_ascii=False, indent=2) + + self.log.info(f"Plugin config updated for enabled plugin {plugin_name}") + # 更新插件引擎 + self.reload_plugins() + + except Exception as e: + self.log.error(f"Failed to update plugin config when enabling {plugin_name}: {e}") + return True + return False + + # 禁用插件 + def disable_plugin(self, plugin_name: str) -> bool: + + if plugin_name in self.plugins: + self.plugins[plugin_name]['enabled'] = False + # 读取、修改 插件配置json文件:① 将plugins_info属性中对于的插件状态改为禁用、2:将 enabled_plugins中对应插件移除 + # 同步更新配置文件 + try: + # 使用自定义的配置文件路径 + config_file_path = self.plugins_config_path + + # 读取现有配置 + if os.path.exists(config_file_path): + with open(config_file_path, 'r', encoding='utf-8') as f: + config_data = json.load(f) + + # 更新plugins_info中对应插件的状态 + for plugin_info in config_data.get("plugins_info", []): + if plugin_info.get("name") == plugin_name: + plugin_info["enabled"] = False + + # 添加到enabled_plugins中(如果不存在) + if "enabled_plugins" not in config_data: + config_data["enabled_plugins"] = [] + + if plugin_name in config_data["enabled_plugins"]: + # 移除对应的插件名 + config_data["enabled_plugins"].remove(plugin_name) + + # 写回配置文件 + with open(config_file_path, 'w', encoding='utf-8') as f: + json.dump(config_data, f, ensure_ascii=False, indent=2) + + self.log.info(f"Plugin config updated for enabled plugin {plugin_name}") + # 更新插件引擎 + self.reload_plugins() + except Exception as e: + self.log.error(f"Failed to update plugin config when enabling {plugin_name}: {e}") + return True + return False + + # 卸载插件 + def uninstall_plugin(self, plugin_name: str) -> bool: + """卸载插件:移除配置信息并删除插件文件""" + if plugin_name in self.plugins: + try: + # 从内存中移除插件 + self.plugins.pop(plugin_name) + + # 使用自定义的配置文件路径 + config_file_path = self.plugins_config_path + + # 读取现有配置 + if os.path.exists(config_file_path): + with open(config_file_path, 'r', encoding='utf-8') as f: + config_data = json.load(f) + + # 移除plugins_info属性中对应的插件项目 + if "plugins_info" in config_data: + config_data["plugins_info"] = [ + plugin_info for plugin_info in config_data["plugins_info"] + if plugin_info.get("name") != plugin_name + ] + + # 从enabled_plugins中移除插件(如果存在) + if "enabled_plugins" in config_data and plugin_name in config_data["enabled_plugins"]: + config_data["enabled_plugins"].remove(plugin_name) + + # 回写配置文件 + with open(config_file_path, 'w', encoding='utf-8') as f: + json.dump(config_data, f, ensure_ascii=False, indent=2) + + self.log.info(f"Plugin config updated for uninstalled plugin {plugin_name}") + + # 删除插件文件夹中的指定插件文件 + plugin_file_path = os.path.join(self.plugins_dir, f"{plugin_name}.js") + if os.path.exists(plugin_file_path): + os.remove(plugin_file_path) + self.log.info(f"Plugin file removed: {plugin_file_path}") + else: + self.log.warning(f"Plugin file not found: {plugin_file_path}") + + return True + except Exception as e: + self.log.error(f"Failed to uninstall plugin {plugin_name}: {e}") + return False + return False + + def reload_plugins(self): + """重新加载所有插件""" + self.log.info("Reloading all plugins...") + # 清空现有插件状态 + self.plugins.clear() + # 重新加载插件 + self._load_plugins() + self.log.info("Plugins reloaded successfully") + + def update_plugin_config(self, plugin_name: str, plugin_file: str): + """更新插件配置文件""" + try: + # 使用自定义的配置文件路径 + config_file_path = self.plugins_config_path + # 如果配置文件不存在,创建一个基础配置 + if not os.path.exists(config_file_path): + base_config = { + "account": "", + "password": "", + "enabled_plugins": [], + "plugins_info": [] + } + with open(config_file_path, 'w', encoding='utf-8') as f: + json.dump(base_config, f, ensure_ascii=False, indent=2) + + # 读取现有配置 + with open(config_file_path, 'r', encoding='utf-8') as f: + config_data = json.load(f) + + # 检查是否已存在该插件信息 + plugin_exists = False + for plugin_info in config_data.get("plugins_info", []): + if plugin_info.get("name") == plugin_name: + plugin_exists = True + break + + # 如果不存在,则添加新的插件信息 + if not plugin_exists: + new_plugin_info = { + "name": plugin_name, + "file": plugin_file, + "enabled": False # 默认不启用 + } + if "plugins_info" not in config_data: + config_data["plugins_info"] = [] + config_data["plugins_info"].append(new_plugin_info) + # 写回配置文件 + with open(config_file_path, 'w', encoding='utf-8') as f: + json.dump(config_data, f, ensure_ascii=False, indent=2) + + self.log.info(f"Plugin config updated for {plugin_name}") + + except Exception as e: + self.log.error(f"Failed to update plugin config: {e}") + + def shutdown(self): + """关闭插件管理器""" + if self.node_process: + self.node_process.terminate() + self.node_process.wait() diff --git a/xiaomusic/js_plugin_runner.js b/xiaomusic/js_plugin_runner.js new file mode 100644 index 0000000000..018e53ccf6 --- /dev/null +++ b/xiaomusic/js_plugin_runner.js @@ -0,0 +1,657 @@ +#!/usr/bin/env node + +/** + * JS 插件运行器 + * 在安全的沙箱环境中运行 MusicFree JS 插件 + */ + +const vm = require('vm'); +const fs = require('fs'); + +// 设置编码 +process.stdin.setEncoding('utf8'); +process.stdout.setDefaultEncoding('utf8'); + +class PluginRunner { + constructor() { + this.plugins = new Map(); + this.requestId = 0; + this.pendingRequests = new Map(); + this.setupMessageHandler(); + } + + setupMessageHandler() { + let buffer = ''; + process.stdin.on('data', (data) => { + buffer += data.toString(); + + // 按行分割并处理完整的消息 + let lines = buffer.split('\n'); + buffer = lines.pop(); // 保留最后一行(可能不完整) + + for (const line of lines) { + if (line.trim() === '') continue; + try { + const message = JSON.parse(line.trim()); + console.log(`[JS_PLUGIN_RUNNER] Raw message received: ${line.trim()}`); + this.handleMessage(message); + } catch (error) { + console.error(`[JS_PLUGIN_RUNNER] Failed to parse message: ${line.trim()}`); + console.error(`[JS_PLUGIN_RUNNER] Error: ${error.message}`); + this.sendResponse('unknown', { + success: false, + error: `JSON parse error: ${error.message}` + }); + } + } + }); + } + + async handleMessage(message) { + const { id, action } = message; + // 只在必要时输出日志以避免干扰通信 + // console.debug(`[JS_PLUGIN_RUNNER] Received message: ${action} with id: ${id}`); + // if (message.pluginName) console.debug(`[JS_PLUGIN_RUNNER] Plugin: ${message.pluginName}`); + // if (message.params) console.debug(`[JS_PLUGIN_RUNNER] Params:`, message.params); + // if (message.musicItem) console.debug(`[JS_PLUGIN_RUNNER] Music Item:`, message.musicItem); + + try { + let result; + switch (action) { + case 'load': + console.log(`[JS_PLUGIN_RUNNER] Loading plugin: ${message.name}`); + result = this.loadPlugin(message.name, message.code); + break; + case 'search': + result = await this.search(message.pluginName, message.params); + break; + case 'getMediaSource': + result = await this.getMediaSource(message.pluginName, message.musicItem, message.quality); + break; + case 'getLyric': + result = await this.getLyric(message.pluginName, message.musicItem); + break; + case 'getMusicInfo': + result = await this.getMusicInfo(message.pluginName, message.musicItem); + break; + case 'getAlbumInfo': + result = await this.getAlbum(message.pluginName, message.albumInfo); + break; + case 'getMusicSheetInfo': + result = await this.getPlaylist(message.pluginName, message.playlistInfo); + break; + case 'getArtistWorks': + result = await this.getArtistWorks(message.pluginName, message.artistItem, message.page, message.type); + break; + case 'importMusicItem': + result = await this.importMusicItem(message.pluginName, message.urlLike); + break; + case 'importMusicSheet': + result = await this.importMusicSheet(message.pluginName, message.urlLike); + break; + case 'getTopLists': + result = await this.getTopLists(message.pluginName); + break; + case 'getTopListDetail': + result = await this.getTopListDetail(message.pluginName, message.topListItem, message.page); + break; + case 'unload': + console.log(`[JS_PLUGIN_RUNNER] Unloading plugin: ${message.name}`); + result = this.unloadPlugin(message.name); + break; + default: + throw new Error(`Unknown action: ${action}`); + } + + this.sendResponse(id, { success: true, result }); + } catch (error) { + console.error(`[JS_PLUGIN_RUNNER] Action ${action} failed:`, error.message); + this.sendResponse(id, { success: false, error: error.message }); + } + } + + sendResponse(id, response) { + response.id = id; + process.stdout.write(JSON.stringify(response) + '\n'); + } + + loadPlugin(name, code) { + try { + // 创建安全的沙箱环境 + const sandbox = this.createSandbox(); + + // 创建上下文 + const context = vm.createContext(sandbox); + + // 包装代码以支持 ES6 模块语法 + const wrappedCode = ` + (function() { + ${code} + // 如果是 TypeScript 编译的代码,需要处理 exports + if (typeof module !== 'undefined' && module.exports) { + return module.exports; + } + // 如果是 ES6 模块,需要处理 exports + if (typeof exports !== 'undefined' && exports.__esModule) { + return exports.default || exports; + } + return module.exports; + })(); + `; + + // 执行插件代码 + const options = { + timeout: 10000, + displayErrors: true, + breakOnSigint: false + }; + + const plugin = vm.runInContext(wrappedCode, context, options); + + // 验证插件接口 + if (!plugin || typeof plugin !== 'object') { + throw new Error('Plugin must export an object'); + } + + this.plugins.set(name, plugin); + + // 记录插件信息 + this.plugins.set(name + '_meta', { + loadTime: Date.now(), + capabilities: this.detectCapabilities(plugin) + }); + + return true; + } catch (error) { + console.error(`Failed to load plugin ${name}:`, error.message); + throw error; + } + } + + createSandbox() { + const safeConsole = { + log: (...args) => {}, // 禁用插件的 console.log 避免干扰主进程通信 + warn: (...args) => console.warn(`[PLUGIN]`, ...args), // 保留警告,但添加标识 + error: (...args) => console.error(`[PLUGIN]`, ...args), // 保留错误,但添加标识 + debug: (...args) => {} // 禁用调试输出 + }; + + const safeFetch = async (url, options = {}) => { + // 代理网络请求到主进程 + return await this.proxyFetch(url, options); + }; + + const safeTimer = (callback, delay) => { + if (delay > 10000) { // 最大10秒 + throw new Error('Timer delay too long'); + } + return setTimeout(callback, delay); + }; + + // 支持的模块列表 + const allowedModules = { + 'axios': require('axios'), + 'crypto-js': require('crypto-js'), + 'he': require('he'), + 'dayjs': require('dayjs'), + 'cheerio': require('cheerio'), + 'qs': require('qs') + }; + + const safeRequire = (moduleName) => { + if (allowedModules[moduleName]) { + return allowedModules[moduleName]; + } + throw new Error(`Module '${moduleName}' is not allowed or not installed`); + }; + + const module = { exports: {} }; + const exports = module.exports; + + // 模拟 env 对象 + const env = { + getUserVariables: () => ({ + music_u: '', + ikun_key: '', + source: '' + }) + }; + + return { + // 基础对象 + console: safeConsole, + Buffer: Buffer, + Math: Math, + Date: Date, + JSON: JSON, + + // 受限的全局对象 + global: undefined, + process: undefined, + + // 受限的网络访问 + fetch: safeFetch, + XMLHttpRequest: undefined, + + // 受限的定时器 + setTimeout: safeTimer, + setInterval: undefined, + clearTimeout: clearTimeout, + clearInterval: clearInterval, + + // 模块系统 + module: module, + exports: exports, + require: safeRequire, + + // MusicFree 环境对象 + env: env + }; + } + + detectCapabilities(plugin) { + const capabilities = []; + if (typeof plugin.search === 'function') capabilities.push('search'); + if (typeof plugin.getMediaSource === 'function') capabilities.push('getMediaSource'); + if (typeof plugin.getLyric === 'function') capabilities.push('getLyric'); + if (typeof plugin.getAlbum === 'function') capabilities.push('getAlbum'); + if (typeof plugin.getPlaylist === 'function') capabilities.push('getPlaylist'); + return capabilities; + } + + async search(pluginName, params) { + const plugin = this.plugins.get(pluginName); + if (!plugin) { + throw new Error(`Plugin ${pluginName} not found`); + } + + // 检查插件是否有 search 方法 - 参考 MusicFreeDesktop 实现 + if (!plugin.search || typeof plugin.search !== 'function') { + // 只在详细模式下输出调试信息 + console.debug(`[JS_PLUGIN_RUNNER] Plugin ${pluginName} does not have a search function`); + return { + isEnd: true, + data: [] + }; + } + + try { + let query, page, type; + if (typeof params === 'string') { + // 兼容旧的字符串格式 + query = params; + page = 1; + type = 'music'; + } else if (typeof params === 'object') { + // 新的对象格式,参考 MusicFreeDesktop + query = params.keywords || params.query || ''; + page = params.page || 1; + type = params.type || 'music'; + } else { + throw new Error('Invalid search parameters'); + } + + // 移除调试输出,避免干扰 JSON 通信 + // console.debug(`[JS_PLUGIN_RUNNER] Calling search with query: ${query}, page: ${page}, type: ${type}`); + const result = await plugin.search(query, page, type); + + // 将调试信息写入日志文件而不是控制台 + fs.appendFileSync('00-plugin_debug.log', `===========================${pluginName}插件原始返回结果:===================================\n`); + fs.appendFileSync('00-plugin_debug.log', `${JSON.stringify(result, null, 2)}\n`); + // 严格验证返回结果 - 参考 MusicFreeDesktop 实现 + if (!result || typeof result !== 'object') { + console.error(`[JS_PLUGIN_RUNNER] Invalid search result from plugin ${pluginName}:`, typeof result); + throw new Error(`Plugin ${pluginName} returned invalid search result`); + } + + // 确保返回正确的数据结构 - 参考 MusicFreeDesktop 实现 + const validatedResult = { + isEnd: result.isEnd !== false, // 默认为 true,除非明确设置为 false + data: Array.isArray(result.data) ? result.data : [] // 确保 data 是数组 + }; + //为 validatedResult中data的 每个元素添加一个 platform 属性 + validatedResult.data.forEach(item => { + item.platform = pluginName; + }); + // 不输出调试信息以避免干扰通信 + return validatedResult; + } catch (error) { + console.error(`[JS_PLUGIN_RUNNER] Search error in plugin ${pluginName}:`, error.message); + // console.error(`[JS_PLUGIN_RUNNER] Full error:`, error); // 避免输出可能包含非JSON的对象 + throw new Error(`Search failed in plugin ${pluginName}: ${error.message}`); + } + } + + + async getMediaSource(pluginName, musicItem, quality) { + const plugin = this.plugins.get(pluginName); + if (!plugin) { + throw new Error(`Plugin ${pluginName} not found`); + } + + // 检查插件是否有 getMediaSource 方法 - 参考 MusicFreeDesktop 实现 + if (!plugin.getMediaSource || typeof plugin.getMediaSource !== 'function') { + // 不输出调试信息以避免干扰通信 + return null; + } + + try { + const result = await plugin.getMediaSource(musicItem,quality); + // 参考 MusicFreeDesktop 实现,验证结果 + if (result === null || result === undefined) { + return null; + } + if (typeof result !== 'object') { + console.error(`[JS_PLUGIN_RUNNER] Invalid media source result from plugin ${pluginName}:`, typeof result); + throw new Error(`Plugin ${pluginName} returned invalid media source result`); + } + return result; + } catch (error) { + console.error(`[JS_PLUGIN_RUNNER] getMediaSource error in plugin ${pluginName}:`, error.message); + throw new Error(`getMediaSource failed in plugin ${pluginName}: ${error.message}`); + } + } + + async getLyric(pluginName, songId) { + const plugin = this.plugins.get(pluginName); + if (!plugin) { + throw new Error(`Plugin ${pluginName} not found`); + } + + // 检查插件是否有 getLyric 方法 - 参考 MusicFreeDesktop 实现 + if (!plugin.getLyric || typeof plugin.getLyric !== 'function') { + // 不输出调试信息以避免干扰通信 + return null; + } + + try { + const result = await plugin.getLyric(songId); + // 参考 MusicFreeDesktop 实现,验证结果 + if (result === null || result === undefined) { + return null; + } + if (typeof result !== 'object') { + console.error(`[JS_PLUGIN_RUNNER] Invalid lyric result from plugin ${pluginName}:`, typeof result); + throw new Error(`Plugin ${pluginName} returned invalid lyric result`); + } + return result; + } catch (error) { + console.error(`[JS_PLUGIN_RUNNER] getLyric error in plugin ${pluginName}:`, error.message); + throw new Error(`getLyric failed in plugin ${pluginName}: ${error.message}`); + } + } + + async getAlbum(pluginName, albumInfo) { + const plugin = this.plugins.get(pluginName); + if (!plugin) { + throw new Error(`Plugin ${pluginName} not found`); + } + + // 检查插件是否有 getAlbumInfo 方法 (按照官方文档标准) + if (!plugin.getAlbumInfo || typeof plugin.getAlbumInfo !== 'function') { + // 不输出调试信息以避免干扰通信 + return null; + } + + try { + // 使用默认页码 1(从MusicFree官方文档得知默认为1) + const result = await plugin.getAlbumInfo(albumInfo, 1); + // 参考 MusicFree 实现,验证结果 + if (result === null || result === undefined) { + return null; + } + if (typeof result !== 'object') { + console.error(`[JS_PLUGIN_RUNNER] Invalid album result from plugin ${pluginName}:`, typeof result); + throw new Error(`Plugin ${pluginName} returned invalid album result`); + } + return result; + } catch (error) { + console.error(`[JS_PLUGIN_RUNNER] getAlbumInfo error in plugin ${pluginName}:`, error.message); + throw new Error(`getAlbumInfo failed in plugin ${pluginName}: ${error.message}`); + } + } + + async getPlaylist(pluginName, playlistInfo) { + const plugin = this.plugins.get(pluginName); + if (!plugin) { + throw new Error(`Plugin ${pluginName} not found`); + } + + // 检查插件是否有 getMusicSheetInfo 方法 (按照官方文档标准) + if (!plugin.getMusicSheetInfo || typeof plugin.getMusicSheetInfo !== 'function') { + // 不输出调试信息以避免干扰通信 + return null; + } + + try { + // 使用默认页码 1(从MusicFree官方文档得知默认为1) + const result = await plugin.getMusicSheetInfo(playlistInfo, 1); + // 参考 MusicFree 实现,验证结果 + if (result === null || result === undefined) { + return null; + } + if (typeof result !== 'object') { + console.error(`[JS_PLUGIN_RUNNER] Invalid playlist result from plugin ${pluginName}:`, typeof result); + throw new Error(`Plugin ${pluginName} returned invalid playlist result`); + } + return result; + } catch (error) { + console.error(`[JS_PLUGIN_RUNNER] getMusicSheetInfo error in plugin ${pluginName}:`, error.message); + throw new Error(`getMusicSheetInfo failed in plugin ${pluginName}: ${error.message}`); + } + } + + async getMusicInfo(pluginName, musicItem) { + const plugin = this.plugins.get(pluginName); + if (!plugin) { + throw new Error(`Plugin ${pluginName} not found`); + } + + // 检查插件是否有 getMusicInfo 方法 (按照官方文档标准) + if (!plugin.getMusicInfo || typeof plugin.getMusicInfo !== 'function') { + // 不输出调试信息以避免干扰通信 + return null; + } + + try { + const result = await plugin.getMusicInfo(musicItem); + // 参考 MusicFree 实现,验证结果 + if (result === null || result === undefined) { + return null; + } + if (typeof result !== 'object') { + console.error(`[JS_PLUGIN_RUNNER] Invalid music info result from plugin ${pluginName}:`, typeof result); + throw new Error(`Plugin ${pluginName} returned invalid music info result`); + } + return result; + } catch (error) { + console.error(`[JS_PLUGIN_RUNNER] getMusicInfo error in plugin ${pluginName}:`, error.message); + throw new Error(`getMusicInfo failed in plugin ${pluginName}: ${error.message}`); + } + } + + async getArtistWorks(pluginName, artistItem, page = 1, type = 'music') { + const plugin = this.plugins.get(pluginName); + if (!plugin) { + throw new Error(`Plugin ${pluginName} not found`); + } + + // 检查插件是否有 getArtistWorks 方法 (按照官方文档标准) + if (!plugin.getArtistWorks || typeof plugin.getArtistWorks !== 'function') { + // 不输出调试信息以避免干扰通信 + return null; + } + + try { + const result = await plugin.getArtistWorks(artistItem, page, type); + // 参考 MusicFree 实现,验证结果 + if (result === null || result === undefined) { + return null; + } + if (typeof result !== 'object') { + console.error(`[JS_PLUGIN_RUNNER] Invalid artist works result from plugin ${pluginName}:`, typeof result); + throw new Error(`Plugin ${pluginName} returned invalid artist works result`); + } + return result; + } catch (error) { + console.error(`[JS_PLUGIN_RUNNER] getArtistWorks error in plugin ${pluginName}:`, error.message); + throw new Error(`getArtistWorks failed in plugin ${pluginName}: ${error.message}`); + } + } + + async importMusicItem(pluginName, urlLike) { + const plugin = this.plugins.get(pluginName); + if (!plugin) { + throw new Error(`Plugin ${pluginName} not found`); + } + + // 检查插件是否有 importMusicItem 方法 (按照官方文档标准) + if (!plugin.importMusicItem || typeof plugin.importMusicItem !== 'function') { + // 不输出调试信息以避免干扰通信 + return null; + } + + try { + const result = await plugin.importMusicItem(urlLike); + // 参考 MusicFree 实现,验证结果 + if (result === null || result === undefined) { + return null; + } + if (typeof result !== 'object') { + console.error(`[JS_PLUGIN_RUNNER] Invalid import music item result from plugin ${pluginName}:`, typeof result); + throw new Error(`Plugin ${pluginName} returned invalid import music item result`); + } + return result; + } catch (error) { + console.error(`[JS_PLUGIN_RUNNER] importMusicItem error in plugin ${pluginName}:`, error.message); + throw new Error(`importMusicItem failed in plugin ${pluginName}: ${error.message}`); + } + } + + async importMusicSheet(pluginName, urlLike) { + const plugin = this.plugins.get(pluginName); + if (!plugin) { + throw new Error(`Plugin ${pluginName} not found`); + } + + // 检查插件是否有 importMusicSheet 方法 (按照官方文档标准) + if (!plugin.importMusicSheet || typeof plugin.importMusicSheet !== 'function') { + // 不输出调试信息以避免干扰通信 + return null; + } + + try { + const result = await plugin.importMusicSheet(urlLike); + // 参考 MusicFree 实现,验证结果 + if (result === null || result === undefined) { + return null; + } + if (!Array.isArray(result)) { + console.error(`[JS_PLUGIN_RUNNER] Invalid import music sheet result from plugin ${pluginName}:`, typeof result); + throw new Error(`Plugin ${pluginName} returned invalid import music sheet result`); + } + return result; + } catch (error) { + console.error(`[JS_PLUGIN_RUNNER] importMusicSheet error in plugin ${pluginName}:`, error.message); + throw new Error(`importMusicSheet failed in plugin ${pluginName}: ${error.message}`); + } + } + + async getTopLists(pluginName) { + const plugin = this.plugins.get(pluginName); + if (!plugin) { + throw new Error(`Plugin ${pluginName} not found`); + } + + // 检查插件是否有 getTopLists 方法 (按照官方文档标准) + if (!plugin.getTopLists || typeof plugin.getTopLists !== 'function') { + // 不输出调试信息以避免干扰通信 + return null; + } + + try { + const result = await plugin.getTopLists(); + // 参考 MusicFree 实现,验证结果 + if (result === null || result === undefined) { + return null; + } + if (!Array.isArray(result)) { + console.error(`[JS_PLUGIN_RUNNER] Invalid top lists result from plugin ${pluginName}:`, typeof result); + throw new Error(`Plugin ${pluginName} returned invalid top lists result`); + } + return result; + } catch (error) { + console.error(`[JS_PLUGIN_RUNNER] getTopLists error in plugin ${pluginName}:`, error.message); + throw new Error(`getTopLists failed in plugin ${pluginName}: ${error.message}`); + } + } + + async getTopListDetail(pluginName, topListItem, page = 1) { + const plugin = this.plugins.get(pluginName); + if (!plugin) { + throw new Error(`Plugin ${pluginName} not found`); + } + + // 检查插件是否有 getTopListDetail 方法 (按照官方文档标准) + if (!plugin.getTopListDetail || typeof plugin.getTopListDetail !== 'function') { + // 不输出调试信息以避免干扰通信 + return null; + } + + try { + const result = await plugin.getTopListDetail(topListItem, page); + // 参考 MusicFree 实现,验证结果 + if (result === null || result === undefined) { + return null; + } + if (typeof result !== 'object') { + console.error(`[JS_PLUGIN_RUNNER] Invalid top list detail result from plugin ${pluginName}:`, typeof result); + throw new Error(`Plugin ${pluginName} returned invalid top list detail result`); + } + return result; + } catch (error) { + console.error(`[JS_PLUGIN_RUNNER] getTopListDetail error in plugin ${pluginName}:`, error.message); + throw new Error(`getTopListDetail failed in plugin ${pluginName}: ${error.message}`); + } + } + + unloadPlugin(name) { + const deleted = this.plugins.delete(name); + this.plugins.delete(name + '_meta'); + return deleted; + } + + async proxyFetch(url, options) { + // 代理网络请求到主进程 + const requestId = ++this.requestId; + + return new Promise((resolve, reject) => { + // 发送请求到主进程 + this.sendResponse('fetch_' + requestId, { + action: 'fetch', + requestId: requestId, + url: url, + options: options + }); + + // 等待响应(简化实现) + setTimeout(() => { + reject(new Error('Fetch proxy not implemented')); + }, 1000); + }); + } +} + +// 启动插件运行器 +const runner = new PluginRunner(); + +// 处理进程退出 +process.on('SIGINT', () => { + console.log('Received SIGINT, shutting down gracefully'); + process.exit(0); +}); + +process.on('SIGTERM', () => { + console.log('Received SIGTERM, shutting down gracefully'); + process.exit(0); +}); diff --git a/xiaomusic/plugins-config-example.json b/xiaomusic/plugins-config-example.json new file mode 100644 index 0000000000..836b910f3a --- /dev/null +++ b/xiaomusic/plugins-config-example.json @@ -0,0 +1,10 @@ +{ + "account": "", + "password": "", + "openapi_info": { + "search_url": "https://music-dl.sayqz.com/api/", + "enabled": true + }, + "enabled_plugins": [], + "plugins_info": [] +} diff --git a/xiaomusic/static/default/setting.html b/xiaomusic/static/default/setting.html index cdf814618d..391888087c 100644 --- a/xiaomusic/static/default/setting.html +++ b/xiaomusic/static/default/setting.html @@ -108,7 +108,7 @@

小爱音箱设置面板 - + @@ -231,6 +231,8 @@

小爱音箱设置面板 + + + + + + + +
+
+ 请输入关键词开始搜索 +
+
+ + + +
+
+ +
+ +
+
+
+
未知歌曲
+
未知艺术家
+
+ +
+
+
+
+ + + + +
+
+
+ + + + + diff --git a/xiaomusic/static/onlineSearch/setting.html b/xiaomusic/static/onlineSearch/setting.html new file mode 100644 index 0000000000..a8e6c58811 --- /dev/null +++ b/xiaomusic/static/onlineSearch/setting.html @@ -0,0 +1,605 @@ + + + + + + MusicFree 插件设置 + + + +
+
+

插件&接口设置

+

管理您的插件和在线接口

+ +
+ + 返回搜索 + + + + + + + OpenAPI + + + + + + +
+
+ +
+
接口配置
+ +
+
+
+
+
接口地址:
+
接口状态:
+
+
+
+ + + +
+
+
+ + +
插件配置
+
+
加载中...
+
+
+
+ + + + diff --git a/xiaomusic/static/search.mp3 b/xiaomusic/static/search.mp3 new file mode 100644 index 0000000000..8e1f200c95 Binary files /dev/null and b/xiaomusic/static/search.mp3 differ diff --git a/xiaomusic/static/silence.mp3 b/xiaomusic/static/silence.mp3 new file mode 100644 index 0000000000..97de37e6b6 Binary files /dev/null and b/xiaomusic/static/silence.mp3 differ diff --git a/xiaomusic/static/tailwind/index.html b/xiaomusic/static/tailwind/index.html index 917e3101ca..36e8d26f94 100644 --- a/xiaomusic/static/tailwind/index.html +++ b/xiaomusic/static/tailwind/index.html @@ -254,7 +254,8 @@

{{ curSelectPlaylist }}

- + +
@@ -484,7 +485,7 @@

{{ curSelectPlaylist }}

if (window.did === 'web_device') return; const data = await API.getPlayingStatus(window.did); - + if (data.ret === 'OK') { isPlaying.value = data.is_playing; currentTime.value = data.offset || 0; @@ -785,11 +786,11 @@

{{ curSelectPlaylist }}

// 创建新的音频播放器 const audio = document.createElement('audio'); audio.id = 'audio-player'; - + // 设置音频属性 audio.preload = 'auto'; // 预加载 audio.crossOrigin = 'anonymous'; // 允许跨域 - + // 添加到文档 document.body.appendChild(audio); audioPlayer.value = audio; @@ -799,7 +800,7 @@

{{ curSelectPlaylist }}

console.error('Audio playback error:', e); const error = e.target.error; let errorMessage = '播放出错'; - + if (error) { switch (error.code) { case error.MEDIA_ERR_ABORTED: @@ -816,7 +817,7 @@

{{ curSelectPlaylist }}

break; } } - + showMessage(errorMessage, 'alert-error'); isPlaying.value = false; }); diff --git a/xiaomusic/static/tailwind/md.js b/xiaomusic/static/tailwind/md.js index 341135ce09..2078818752 100644 --- a/xiaomusic/static/tailwind/md.js +++ b/xiaomusic/static/tailwind/md.js @@ -38,7 +38,7 @@ function startProgressUpdate() { if (progressInterval) { clearInterval(progressInterval); } - + // 每秒更新一次进度条 progressInterval = setInterval(() => { if (duration > 0) { @@ -66,18 +66,18 @@ function stopProgressUpdate() { window.playMusic = function(songName) { const currentPlaylist = localStorage.getItem("cur_playlist"); console.log(`播放音乐: ${songName}, 播放列表: ${currentPlaylist}`); - + // 检查是否是当前播放的歌曲 const currentPlayingSong = localStorage.getItem("cur_music"); const isCurrentSong = currentPlayingSong === songName; - + if (window.did === 'web_device') { // Web播放模式 $.get(`/musicinfo?name=${songName}`, function (data, status) { if (data.ret == "OK") { if (validHost(data.url)) { const audio = $("#audio")[0]; - + // 如果是同一首歌,切换播放/暂停状态 if (audio.src && audio.src === data.url) { if (audio.paused) { @@ -148,25 +148,25 @@ function do_play_music_list(listname, musicname) { // 更新播放信息 function updatePlayingInfo(songName, isPlaying) { if (!songName) return; - + // 更新播放栏信息 const displayText = isPlaying ? `【播放中】 ${songName}` : `【暂停中】 ${songName}`; $("#playering-music").text(displayText); $("#playering-music-mobile").text(displayText); - + // 更新播放按钮图标 $(".play").text(isPlaying ? "pause_circle_outline" : "play_circle_outline"); - + // 更新收藏状态 updateFavoriteStatus(songName); - + // 高亮当前播放的歌曲 highlightPlayingSong(songName, isPlaying); - + // 保存当前播放的歌曲 localStorage.setItem("cur_music", songName); localStorage.setItem("is_playing", isPlaying); - + // 根据播放状态控制进度条更新 if (isPlaying) { startProgressUpdate(); @@ -179,10 +179,10 @@ function updatePlayingInfo(songName, isPlaying) { function highlightPlayingSong(songName, isPlaying) { // 移除所有歌曲的高亮状态 $(".song-item").removeClass("bg-blue-50 dark:bg-blue-900/20"); - + // 重置所有播放按钮为播放图标(只选择播放按钮中的图标) $(".play-icon").text("play_arrow"); - + // 高亮当前歌曲,无论是播放还是暂停状态 $(".song-item").each(function() { const itemSongName = $(this).find("h3").text(); @@ -249,33 +249,33 @@ function nextTrack() { function togglePlayMode(isSend = true) { console.log('切换播放模式...'); - + // 从本地存储获取当前播放模式,如果没有则使用默认值2(随机播放) if (playModeIndex === undefined || playModeIndex === null) { playModeIndex = parseInt(localStorage.getItem("playModeIndex")) || 2; } - + // 计算下一个播放模式索引:2 -> 3 -> 4 -> 2 const nextModeIndex = playModeIndex >= 4 ? 2 : playModeIndex + 1; - + // 获取下一个播放模式 const nextMode = playModes[nextModeIndex]; console.log('切换到播放模式:', nextModeIndex, nextMode.cmd); - + // 更新按钮图标和提示文本 const modeBtn = $("#modeBtn"); const modeBtnIcon = modeBtn.find(".material-icons"); const tooltip = modeBtn.find(".tooltip"); - + modeBtnIcon.text(nextMode.icon); tooltip.text(nextMode.cmd); - + // 如果需要发送命令,则发送到设备 if (isSend && window.did !== 'web_device') { console.log('发送播放模式命令:', nextMode.cmd); sendcmd(nextMode.cmd); } - + // 保存新的播放模式到本地存储和全局变量 localStorage.setItem("playModeIndex", nextModeIndex); playModeIndex = nextModeIndex; @@ -287,7 +287,7 @@ function addToFavorites() { const isLiked = favoritelist.includes(currentSong); const cmd = isLiked ? "取消收藏" : "加入收藏"; - + // 发送收藏命令 $.ajax({ type: "POST", @@ -299,7 +299,7 @@ function addToFavorites() { }), success: () => { console.log(`${cmd}成功: ${currentSong}`); - + // 更新本地收藏列表 if (isLiked) { // 取消收藏 @@ -312,7 +312,7 @@ function addToFavorites() { } $(".favorite").addClass("favorite-active"); } - + // 如果当前在收藏列表页面,刷新列表 if (localStorage.getItem("cur_playlist") === "收藏") { refresh_music_list(); @@ -390,23 +390,23 @@ $.get("/getsetting", function (data, status) { if (data.mi_did != null) { dids = data.mi_did.split(","); } - + if (did != "web_device" && dids.length > 0 && (did == null || did == "" || !dids.includes(did))) { did = dids[0]; localStorage.setItem("cur_did", did); } window.did = did; - + // 渲染设备按钮 renderDeviceButtons(data.devices, did); - + // 获取音量 $.get(`/getvolume?did=${did}`, function (data, status) { console.log(data, status, data["volume"]); $("#volume").val(data.volume); }); - + // 刷新音乐列表 refresh_music_list(); @@ -459,7 +459,7 @@ function _refresh_music_list(callback) { console.error("未获取到音乐列表数据"); return; } - + favoritelist = data["收藏"] || []; // 设置默认播放列表 @@ -521,7 +521,7 @@ function renderSystemPlaylists(data) { const songs = data[playlist.name] || []; const count = songs.length; const isActive = playlist.name === currentPlaylist; - + const button = $(` `); - + container.append(button); }); } @@ -551,35 +551,35 @@ function renderSystemPlaylists(data) { // 渲染专辑列表 function renderAlbumList(data) { const container = $("#album-list"); - + if (!data || typeof data !== 'object') { return; } - + container.empty(); - + // 系统预设的播放列表,这些不在专辑列表中显示 const systemPlaylists = [ '收藏', '最近新增', '所有歌曲', '临时搜索列表', '所有电台', '全部', '下载', '其他' ]; - + const currentPlaylist = localStorage.getItem("cur_playlist"); - + // 遍历所有播放列表 for (const [listName, songs] of Object.entries(data)) { // 跳过系统预设列表 if (systemPlaylists.includes(listName)) { continue; } - + // 跳过空列表 if (songs.length === 0) { continue; } - + const isActive = listName === currentPlaylist; - + const button = $(` `); - + container.append(button); } } @@ -625,10 +625,10 @@ window.showPlaylist = function(listName) { // 渲染歌曲列表 const songs = data[listName] || []; renderSongList(songs); - + // 保存当前播放列表 localStorage.setItem("cur_playlist", listName); - + // 重新渲染系统播放列表和专辑列表以更新高亮状态 renderSystemPlaylists(data); renderAlbumList(data); @@ -641,10 +641,10 @@ function refresh_music_list() { // 刷新列表时清空并临时禁用搜索框 const searchInput = document.getElementById("search-input"); if (!searchInput) { - console.error("未找到搜索输入框"); + // console.error("未找到搜索输入框"); return; } - + const oriPlaceHolder = searchInput.placeholder; const oriValue = searchInput.value; const inputEvent = new Event("input", { bubbles: true }); @@ -802,7 +802,7 @@ function handleSearch() { console.log("搜索输入框不存在"); return; } - + console.log("触发搜索::!") searchInput.addEventListener( "input", debounce(function () { @@ -831,18 +831,18 @@ function get_playing_music() { console.log(data); if (data.ret == "OK" && data.cur_music) { // 确保cur_music存在 updatePlayingInfo(data.cur_music, data.is_playing); - + // 更新进度条和时间显示 offset = data.offset || 0; duration = data.duration || 0; - + if (duration > 0) { // 更新进度条 $("#progress").val((offset / duration) * 100); // 更新时间显示 $("#current-time").text(formatTime(offset)); $("#duration").text(formatTime(duration)); - + // 如果正在播放,启动进度条更新 if (data.is_playing) { startProgressUpdate(); @@ -856,7 +856,7 @@ function get_playing_music() { $("#duration").text("00:00"); stopProgressUpdate(); } - + // 更新收藏状态 if (favoritelist.includes(data.cur_music)) { $(".favorite").addClass("favorite-active"); @@ -906,7 +906,7 @@ window.stopPlay = function() { sendcmd("停止"); updatePlayingInfo(currentSong, false); } - + // 重置进度条和时间显示 $("#progress").val(0); $("#current-time").text("00:00"); @@ -1051,7 +1051,7 @@ function adjustVolume(value) { audio.volume = value; localStorage.setItem('volume', value); } - + // 更新设备音量 if (window.did && window.did !== 'web_device') { $.ajax({ @@ -1112,7 +1112,7 @@ document.getElementById('volume-slider')?.addEventListener('input', function() { function renderDeviceButtons(devices, currentDid) { const container = $("#device-buttons"); container.empty(); - + // 切换设备函数 function switchDevice(did) { // 只有在切换到不同设备时才刷新页面 @@ -1122,7 +1122,7 @@ function renderDeviceButtons(devices, currentDid) { location.reload(); } } - + // 添加设备按钮 Object.values(devices).forEach(device => { const isActive = device.did === currentDid; @@ -1148,14 +1148,14 @@ function renderDeviceButtons(devices, currentDid) { ${isActive ? 'check' : ''} `); - + button.click(function() { switchDevice(device.did); }); - + container.append(button); }); - + // 添加本机播放按钮 const isWebDevice = currentDid === 'web_device'; const webDeviceButton = $(` @@ -1180,11 +1180,11 @@ function renderDeviceButtons(devices, currentDid) { ${isWebDevice ? 'check' : ''} `); - + webDeviceButton.click(function() { switchDevice('web_device'); }); - + container.append(webDeviceButton); } @@ -1194,7 +1194,7 @@ function showPlaylist(type) { $('.playlist-button').removeClass('bg-blue-50 dark:bg-blue-900/20'); // 添加当前按钮的活动状态 $(`[data-playlist="${type}"]`).addClass('bg-blue-50 dark:bg-blue-900/20'); - + switch(type) { case 'all': // 显示所有歌曲 @@ -1268,12 +1268,12 @@ function renderSongList(songs) { `); - + // 添加播放按钮点击事件 songItem.find('.play-button').on('click', function() { playMusic(song); }); - + // 添加删除按钮点击事件 songItem.find('.delete-button').on('click', function() { if (confirm(`确定要删除歌曲 "${song}" 吗?`)) { @@ -1293,7 +1293,7 @@ function renderSongList(songs) { }); } }); - + container.append(songItem); }); } @@ -1302,18 +1302,18 @@ function renderSongList(songs) { window.playMusic = function(songName) { const currentPlaylist = localStorage.getItem("cur_playlist"); console.log(`播放音乐: ${songName}, 播放列表: ${currentPlaylist}`); - + // 检查是否是当前播放的歌曲 const currentPlayingSong = localStorage.getItem("cur_music"); const isCurrentSong = currentPlayingSong === songName; - + if (window.did === 'web_device') { // Web播放模式 $.get(`/musicinfo?name=${songName}`, function (data, status) { if (data.ret == "OK") { if (validHost(data.url)) { const audio = $("#audio")[0]; - + // 如果是同一首歌,切换播放/暂停状态 if (audio.src && audio.src === data.url) { if (audio.paused) { @@ -1362,25 +1362,25 @@ window.playMusic = function(songName) { // 更新播放信息 function updatePlayingInfo(songName, isPlaying) { if (!songName) return; - + // 更新播放栏信息 const displayText = isPlaying ? `【播放中】 ${songName}` : `【暂停中】 ${songName}`; $("#playering-music").text(displayText); $("#playering-music-mobile").text(displayText); - + // 更新播放按钮图标 $(".play").text(isPlaying ? "pause_circle_outline" : "play_circle_outline"); - + // 更新收藏状态 updateFavoriteStatus(songName); - + // 高亮当前播放的歌曲 highlightPlayingSong(songName, isPlaying); - + // 保存当前播放的歌曲 localStorage.setItem("cur_music", songName); localStorage.setItem("is_playing", isPlaying); - + // 根据播放状态控制进度条更新 if (isPlaying) { startProgressUpdate(); @@ -1393,10 +1393,10 @@ function updatePlayingInfo(songName, isPlaying) { function highlightPlayingSong(songName, isPlaying) { // 移除所有歌曲的高亮状态 $(".song-item").removeClass("bg-blue-50 dark:bg-blue-900/20"); - + // 重置所有播放按钮为播放图标(只选择播放按钮中的图标) $(".play-icon").text("play_arrow"); - + // 高亮当前歌曲,无论是播放还是暂停状态 $(".song-item").each(function() { const itemSongName = $(this).find("h3").text(); @@ -1456,7 +1456,7 @@ window.stopPlay = function() { sendcmd("停止"); updatePlayingInfo(currentSong, false); } - + // 重置进度条和时间显示 $("#progress").val(0); $("#current-time").text("00:00"); @@ -1490,12 +1490,12 @@ window.prevTrack = function() { const currentPlaylist = localStorage.getItem("cur_playlist"); $.get("/musiclist", function (data, status) { if (!data || !data[currentPlaylist]) return; - + const songs = data[currentPlaylist]; const currentSong = $("#playering-music").text().replace('当前播放歌曲:', ''); const currentIndex = songs.indexOf(currentSong); const prevIndex = currentIndex > 0 ? currentIndex - 1 : songs.length - 1; - + playMusic(songs[prevIndex]); }); } else { @@ -1510,12 +1510,12 @@ window.nextTrack = function() { const currentPlaylist = localStorage.getItem("cur_playlist"); $.get("/musiclist", function (data, status) { if (!data || !data[currentPlaylist]) return; - + const songs = data[currentPlaylist]; const currentSong = $("#playering-music").text().replace('当前播放歌曲:', ''); const currentIndex = songs.indexOf(currentSong); const nextIndex = currentIndex < songs.length - 1 ? currentIndex + 1 : 0; - + playMusic(songs[nextIndex]); }); } else { @@ -1530,10 +1530,10 @@ window.toggleFavorite = function() { const favoriteIcon = document.querySelector('.favorite-icon'); const isFavorite = favoriteIcon.textContent === 'favorite'; - + // 切换图标 favoriteIcon.textContent = isFavorite ? 'favorite_border' : 'favorite'; - + // 发送收藏/取消收藏请求 $.ajax({ type: "POST", diff --git a/xiaomusic/utils.py b/xiaomusic/utils.py index 5236675f92..be975d4649 100644 --- a/xiaomusic/utils.py +++ b/xiaomusic/utils.py @@ -200,7 +200,7 @@ def custom_sort_key(s): def _get_depth_path(root, directory, depth): # 计算当前目录的深度 - relative_path = root[len(directory) :].strip(os.sep) + relative_path = root[len(directory):].strip(os.sep) path_parts = relative_path.split(os.sep) if len(path_parts) >= depth: return os.path.join(directory, *path_parts[:depth]) @@ -231,7 +231,7 @@ def traverse_music_directory(directory, depth, exclude_dirs, support_extension): dirs[:] = [d for d in dirs if d not in exclude_dirs] # 计算当前目录的深度 - current_depth = root[len(directory) :].count(os.sep) + 1 + current_depth = root[len(directory):].count(os.sep) + 1 if current_depth > depth: depth_path = _get_depth_path(root, directory, depth - 1) _append_files_result(result, depth_path, root, files, support_extension) @@ -242,14 +242,14 @@ def traverse_music_directory(directory, depth, exclude_dirs, support_extension): # 发送给网页3thplay,用于三者设备播放 async def thdplay( - action, args="/static/3thdplay.mp3", target="HTTP://192.168.1.10:58090/thdaction" + action, args="/static/3thdplay.mp3", target="HTTP://192.168.1.10:58090/thdaction" ): # 接口地址 target,在参数文件指定 data = {"action": action, "args": args} try: async with aiohttp.ClientSession() as session: async with session.post( - target, json=data, timeout=5 + target, json=data, timeout=5 ) as response: # 增加超时以避免长时间挂起 # 如果响应不是200,引发异常 response.raise_for_status() @@ -276,7 +276,7 @@ async def downloadfile(url): # 使用 aiohttp 创建一个客户端会话来发起请求 async with aiohttp.ClientSession() as session: async with session.get( - cleaned_url, timeout=5 + cleaned_url, timeout=5 ) as response: # 增加超时以避免长时间挂起 # 如果响应不是200,引发异常 response.raise_for_status() @@ -345,11 +345,11 @@ async def get_web_music_duration(url, config): cleaned_url = parsed_url.geturl() async with aiohttp.ClientSession() as session: async with session.get( - cleaned_url, - allow_redirects=True, - headers={ - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36" - }, + cleaned_url, + allow_redirects=True, + headers={ + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36" + }, ) as response: url = str(response.url) # 设置总超时时间为3秒 @@ -402,28 +402,39 @@ async def get_duration_by_mutagen(file_path): def get_duration_by_ffprobe(file_path, ffmpeg_location): duration = 0 try: + # 构造 ffprobe 命令参数 + cmd_args = [ + os.path.join(ffmpeg_location, "ffprobe"), + "-v", + "error", # 只输出错误信息,避免混杂在其他输出中 + "-show_entries", + "format=duration", # 仅显示时长 + "-of", + "json", # 以 JSON 格式输出 + file_path, + ] + + # 输出待执行的完整命令 + full_command = " ".join(cmd_args) + log.info(f"待执行的完整命令 ffprobe command: {full_command}") + # 使用 ffprobe 获取文件的元数据,并以 JSON 格式输出 result = subprocess.run( - [ - os.path.join(ffmpeg_location, "ffprobe"), - "-v", - "error", # 只输出错误信息,避免混杂在其他输出中 - "-show_entries", - "format=duration", # 仅显示时长 - "-of", - "json", # 以 JSON 格式输出 - file_path, - ], + cmd_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) + # 输出命令执行结果 + log.info(f"命令执行结果 command result - return code: {result.returncode}, stdout: {result.stdout}") + # 解析 JSON 输出 ffprobe_output = json.loads(result.stdout) # 获取时长 duration = float(ffprobe_output["format"]["duration"]) + log.info(f"Successfully extracted duration: {duration} seconds for file: {file_path}") except Exception as e: log.warning(f"Error getting local music {file_path} duration: {e}") @@ -488,8 +499,8 @@ def remove_id3_tags(input_file: str, config) -> str: # 检查是否存在ID3 v2.3或v2.4标签 if not ( - audio.tags - and (audio.tags.version == (2, 3, 0) or audio.tags.version == (2, 4, 0)) + audio.tags + and (audio.tags.version == (2, 3, 0) or audio.tags.version == (2, 4, 0)) ): return None @@ -1137,7 +1148,7 @@ def remove_common_prefix(directory): # 检查文件名是否以共同前缀开头 if filename.startswith(common_prefix): # 构造新的文件名 - new_filename = filename[len(common_prefix) :] + new_filename = filename[len(common_prefix):] match = pattern.search(new_filename.strip()) if match: num = match.group(1) @@ -1328,10 +1339,10 @@ async def fetch_json_get(url, headers, config): async with aiohttp.ClientSession(connector=connector) as session: # 3. 发起带代理的GET请求 async with session.get( - url, - headers=headers, - proxy=proxy, # 传入格式化后的代理参数 - timeout=10, # 超时时间(秒),避免无限等待 + url, + headers=headers, + proxy=proxy, # 传入格式化后的代理参数 + timeout=10, # 超时时间(秒),避免无限等待 ) as response: if response.status == 200: data = await response.json() @@ -1471,7 +1482,7 @@ def size(self) -> int: async def text_to_mp3( - text: str, save_dir: str, voice: str = "zh-CN-XiaoxiaoNeural" + text: str, save_dir: str, voice: str = "zh-CN-XiaoxiaoNeural" ) -> str: """ 使用edge-tts将文本转换为MP3语音文件 diff --git a/xiaomusic/xiaomusic.py b/xiaomusic/xiaomusic.py index eb0ac1ab5f..699d167885 100644 --- a/xiaomusic/xiaomusic.py +++ b/xiaomusic/xiaomusic.py @@ -93,6 +93,7 @@ def __init__(self, config: Config): self.music_list = {} # 播放列表 key 为目录名, value 为 play_list self.default_music_list_names = [] # 非自定义个歌单 self.devices = {} # key 为 did + self._cur_did = None # 当前设备did self.running_task = [] self.all_music_tags = {} # 歌曲额外信息 self._tag_generation_task = False @@ -108,6 +109,23 @@ def __init__(self, config: Config): # 计划任务 self.crontab = Crontab(self.log) + # 初始化 JS 插件管理器 + try: + from xiaomusic.js_plugin_manager import JSPluginManager + self.js_plugin_manager = JSPluginManager(self) + self.log.info("JS Plugin Manager initialized successfully") + except Exception as e: + self.log.error(f"Failed to initialize JS Plugin Manager: {e}") + self.js_plugin_manager = None + + # 初始化 JS 插件适配器 + try: + from xiaomusic.js_adapter import JSAdapter + self.js_adapter = JSAdapter(self) + self.log.info("JS Adapter initialized successfully") + except Exception as e: + self.log.error(f"Failed to initialize JS Adapter: {e}") + # 尝试从设置里加载配置 self.try_init_setting() @@ -129,6 +147,51 @@ def __init__(self, config: Config): if self.config.conf_path == self.music_path: self.log.warning("配置文件目录和音乐目录建议设置为不同的目录") + # 私有方法:调用插件方法的通用封装 + async def __call_plugin_method(self, plugin_name: str, method_name: str, music_item: dict, result_key: str, + required_field: str = None, **kwargs): + """ + 通用方法:调用 JS 插件的方法并返回结果 + + Args: + plugin_name: 插件名称 + method_name: 插件方法名(如 get_media_source 或 get_lyric) + music_item: 音乐项数据 + result_key: 返回结果中的字段名(如 'url' 或 'rawLrc') + required_field: 必须存在的字段(用于校验) + **kwargs: 传递给插件方法的额外参数 + + Returns: + dict: 包含 success 和对应字段的字典 + """ + if not music_item: + return {"success": False, "error": "Music item required"} + + # 检查插件管理器是否可用 + if not self.js_plugin_manager: + return {"success": False, "error": "JS Plugin Manager not available"} + + enabled_plugins = self.js_plugin_manager.get_enabled_plugins() + if plugin_name not in enabled_plugins: + return {"success": False, "error": f"Plugin {plugin_name} not enabled"} + + try: + # 调用插件方法,传递额外参数 + result = getattr(self.js_plugin_manager, method_name)(plugin_name, music_item, **kwargs) + if not result or not result.get(result_key) or result.get(result_key) == 'None': + return {"success": False, "error": f"Failed to get {result_key}"} + + # 如果指定了必填字段,则额外校验 + if required_field and not result.get(required_field): + return {"success": False, "error": f"Missing required field: {required_field}"} + # 追加属性后返回 + result["success"] = True + return result + + except Exception as e: + self.log.error(f"Plugin {plugin_name} {method_name} failed: {e}") + return {"success": False, "error": str(e)} + def init_config(self): self.music_path = self.config.music_path self.download_path = self.config.download_path @@ -307,6 +370,8 @@ async def try_update_device_id(self): if device_id and hardware and did and (did in mi_dids): device = self.config.devices.get(did, Device()) device.did = did + # 将did存一下 方便其他地方调用 + self._cur_did = did device.device_id = device_id device.hardware = hardware device.name = name @@ -496,7 +561,7 @@ def get_music_tags(self, name): picture = tags["picture"] if picture: if picture.startswith(self.config.picture_cache_path): - picture = picture[len(self.config.picture_cache_path) :] + picture = picture[len(self.config.picture_cache_path):] picture = picture.replace("\\", "/") if picture.startswith("/"): picture = picture[1:] @@ -530,29 +595,35 @@ def set_music_tag(self, name, info): self.try_save_tag_cache() return "OK" - async def get_music_sec_url(self, name): + async def get_music_sec_url(self, name, true_url): """获取歌曲播放时长和播放地址 Args: name: 歌曲名称 + true_url: 真实播放URL Returns: tuple: (播放时长(秒), 播放地址) """ - url, origin_url = await self.get_music_url(name) - self.log.info( - f"get_music_sec_url. name:{name} url:{url} origin_url:{origin_url}" - ) - - # 电台直接返回 - if self.is_web_radio_music(name): - self.log.info("电台不会有播放时长") - return 0, url # 获取播放时长 - if self.is_web_music(name): - sec = await self._get_web_music_duration(name, url, origin_url) + if true_url is not None: + url = true_url + sec = await self._get_online_music_duration(name, true_url) + self.log.info(f"在线歌曲时长获取::{name} ;sec::{sec}") else: - sec = await self._get_local_music_duration(name, url) + url, origin_url = await self.get_music_url(name) + self.log.info( + f"get_music_sec_url. name:{name} url:{url} origin_url:{origin_url}" + ) + + # 电台直接返回 + if self.is_web_radio_music(name): + self.log.info("电台不会有播放时长") + return 0, url + if self.is_web_music(name): + sec = await self._get_web_music_duration(name, url, origin_url) + else: + sec = await self._get_local_music_duration(name, url) if sec <= 0: self.log.warning(f"获取歌曲时长失败 {name} {url}") @@ -582,6 +653,14 @@ async def _get_local_music_duration(self, name, url): self.log.info(f"本地歌曲 {name} : {filename} {url} 的时长 {sec} 秒") return sec + async def _get_online_music_duration(self, name, url): + """获取在线音乐时长""" + self.log.info(f"get_music_sec_url. name:{name}") + duration = await get_local_music_duration(url, self.config) + sec = math.ceil(duration) + self.log.info(f"在线歌曲 {name} : {url} 的时长 {sec} 秒") + return sec + async def get_music_url(self, name): """获取音乐播放地址 @@ -633,7 +712,7 @@ def _get_local_music_url(self, name): # 处理文件路径 if filename.startswith(self.config.music_path): - filename = filename[len(self.config.music_path) :] + filename = filename[len(self.config.music_path):] filename = filename.replace("\\", "/") if filename.startswith("/"): filename = filename[1:] @@ -723,7 +802,7 @@ async def _gen_all_music_tag(self, only_items: dict = None): # TODO: 网络歌曲获取歌曲额外信息 pass elif os.path.exists(file_or_url) and not_in_dirs( - file_or_url, ignore_tag_absolute_dirs + file_or_url, ignore_tag_absolute_dirs ): all_music_tags[name] = extract_audio_metadata( file_or_url, self.config.picture_cache_path @@ -760,7 +839,7 @@ def _gen_all_music_list(self): if dir_name == os.path.basename(self.music_path): dir_name = "其他" if self.music_path != self.download_path and dir_name == os.path.basename( - self.download_path + self.download_path ): dir_name = "下载" if dir_name not in all_music_by_dir: @@ -934,7 +1013,7 @@ async def run_forever(self): self.start_file_watch() analytics_task = asyncio.create_task(self.analytics_task_daily()) assert ( - analytics_task is not None + analytics_task is not None ) # to keep the reference to task, do not remove this async with ClientSession() as session: self.session = session @@ -1047,11 +1126,11 @@ def match_cmd(self, did, query, ctrl_panel): opvalue = self.config.key_word_dict.get(opkey) if ( - (not ctrl_panel) - and (not self.isplaying(did)) - and self.active_cmd - and (opvalue not in self.active_cmd) - and (opkey not in self.active_cmd) + (not ctrl_panel) + and (not self.isplaying(did)) + and self.active_cmd + and (opvalue not in self.active_cmd) + and (opkey not in self.active_cmd) ): self.log.info(f"不在激活命令中 {opvalue}") continue @@ -1103,6 +1182,7 @@ def did_exist(self, did): # 播放一个 url async def play_url(self, did="", arg1="", **kwargs): + self.log.info(f"手动播放链接:{arg1}") url = arg1 return await self.devices[did].group_player_play(url) @@ -1158,6 +1238,365 @@ async def del_music(self, name): # TODO: 这里可以优化性能 self._gen_all_music_list() + # ===========================MusicFree插件函数================================ + + # 在线获取歌曲列表 + async def get_music_list_online(self, plugin="all", keyword="", page=1, limit=20, **kwargs): + self.log.info(f"在线获取歌曲列表!") + """ + 在线获取歌曲列表 + + Args: + plugin: 插件名称,"OpenAPI"表示 通过开放接口获取,其他为插件在线搜索 + keyword: 搜索关键词 + page: 页码 + limit: 每页数量 + **kwargs: 其他参数 + Returns: + dict: 搜索结果 + """ + openapi_info = self.js_plugin_manager.get_openapi_info() + if openapi_info.get("enabled", False) and openapi_info.get("search_url", "") != "": + # 开放接口获取 + return await self.js_plugin_manager.openapi_search(openapi_info.get("search_url"), keyword) + else: + if not self.js_plugin_manager: + return {"success": False, "error": "JS Plugin Manager not available"} + # 插件在线搜索 + return await self.get_music_list_mf(plugin, keyword, page, limit) + + @staticmethod + async def get_real_url_of_openapi(url: str, timeout: int = 10) -> dict: + """ + 通过服务端代理获取开放接口真实的音乐播放URL,避免CORS问题 + Args: + url (str): 原始音乐URL + timeout (int): 请求超时时间(秒) + + Returns: + dict: 包含success、realUrl、statusCode等信息的字典 + """ + import aiohttp + from urllib.parse import urlparse + + try: + # 验证URL格式 + parsed_url = urlparse(url) + if not parsed_url.scheme or not parsed_url.netloc: + return { + "success": False, + "url": url, + "error": "Invalid URL format" + } + # 创建aiohttp客户端会话 + async with aiohttp.ClientSession() as session: + # 发送HEAD请求跟随重定向 + async with session.head(url, allow_redirects=True, + timeout=aiohttp.ClientTimeout(total=timeout)) as response: + # 获取最终重定向后的URL + final_url = str(response.url) + + return { + "success": True, + "url": final_url, + "statusCode": response.status + } + except Exception as e: + return { + "success": False, + "url": url, + "error": f"Error occurred: {str(e)}" + } + + # 调用MusicFree插件获取歌曲列表 + async def get_music_list_mf(self, plugin="all", keyword="", page=1, limit=20, **kwargs): + self.log.info(f"通过MusicFree插件搜索音乐列表!") + """ + 通过MusicFree插件搜索音乐列表 + + Args: + plugin: 插件名称,"all"表示所有插件 + keyword: 搜索关键词 + page: 页码 + limit: 每页数量 + **kwargs: 其他参数 + + Returns: + dict: 搜索结果 + """ + # 检查JS插件管理器是否可用 + if not self.js_plugin_manager: + return {"success": False, "error": "JS插件管理器不可用"} + # 如果关键词包含 '-',则提取歌手名、歌名 + if '-' in keyword: + parts = keyword.split('-') + keyword = parts[0] + artist = parts[1] + else: + artist = "" + try: + if plugin == "all": + # 搜索所有启用的插件 + return await self._search_all_plugins(keyword, artist, page, limit) + else: + # 搜索指定插件 + return await self._search_specific_plugin(plugin, keyword, artist, page, limit) + except Exception as e: + self.log.error(f"搜索音乐时发生错误: {e}") + return {"success": False, "error": str(e)} + + async def _search_all_plugins(self, keyword, artist, page, limit): + """搜索所有启用的插件""" + enabled_plugins = self.js_plugin_manager.get_enabled_plugins() + if not enabled_plugins: + return {"success": False, "error": "没有可用的接口和插件,请先进行配置!"} + + results = [] + sources = {} + + # 计算每个插件的限制数量 + plugin_count = len(enabled_plugins) + item_limit = max(1, limit // plugin_count) if plugin_count > 0 else limit + + # 并行搜索所有插件 + search_tasks = [ + self._search_plugin_task(plugin_name, keyword, page, item_limit) + for plugin_name in enabled_plugins + ] + + plugin_results = await asyncio.gather(*search_tasks, return_exceptions=True) + + # 处理搜索结果 + for i, result in enumerate(plugin_results): + plugin_name = list(enabled_plugins)[i] + + # 检查是否为异常对象 + if isinstance(result, Exception): + self.log.error(f"插件 {plugin_name} 搜索失败: {result}") + continue + + # 检查是否为有效的搜索结果(修改这里的判断逻辑) + if result and isinstance(result, dict): + # 检查是否有错误信息 + if "error" in result: + self.log.error(f"插件 {plugin_name} 搜索失败: {result.get('error', '未知错误')}") + continue + + # 处理成功的搜索结果 + data_list = result.get("data", []) + if data_list: + results.extend(data_list) + sources[plugin_name] = len(data_list) + # 如果没有data字段但有其他数据,也认为是成功的结果 + elif result: # 非空字典 + results.append(result) + sources[plugin_name] = 1 + + # 统一排序并提取前limit条数据 + if results: + unified_result = {"data": results} + optimized_result = self.js_plugin_manager.optimize_search_results( + unified_result, + search_keyword=keyword, + limit=limit, + search_artist=artist + ) + results = optimized_result.get('data', []) + + return { + "success": True, + "data": results, + "total": len(results), + "sources": sources, + "page": page, + "limit": limit + } + + async def _search_specific_plugin(self, plugin, keyword, artist, page, limit): + """搜索指定插件""" + try: + results = self.js_plugin_manager.search(plugin, keyword, page, limit) + + # 额外检查 resources 字段 + data_list = results.get('data', []) + if data_list: + # 优化搜索结果排序 + results = self.js_plugin_manager.optimize_search_results( + results, + search_keyword=keyword, + limit=limit, + search_artist=artist + ) + + return { + "success": True, + "data": results.get('data', []), + "total": results.get('total', 0), + "page": page, + "limit": limit + } + except Exception as e: + self.log.error(f"插件 {plugin} 搜索失败: {e}") + return {"success": False, "error": str(e)} + + async def _search_plugin_task(self, plugin_name, keyword, page, limit): + """单个插件搜索任务""" + try: + return self.js_plugin_manager.search(plugin_name, keyword, page, limit) + except Exception as e: + # 直接抛出异常,让 asyncio.gather 处理 + raise e + + # 调用MusicFree插件获取真实播放url + async def get_media_source_url(self, music_item, quality: str = 'standard'): + """获取音乐项的媒体源URL + Args: + music_item : MusicFree插件定义的 IMusicItem + quality: 音质参数 + Returns: + dict: 包含成功状态和URL信息的字典 + """ + # kwargs可追加 + kwargs = {'quality': quality} + return await self.__call_plugin_method( + plugin_name=music_item.get('platform'), + method_name="get_media_source", + music_item=music_item, + result_key="url", + required_field="url", + **kwargs + ) + + # 调用MusicFree插件获取歌词 + async def get_media_lyric(self, music_item): + """获取音乐项的歌词 Lyric + Args: + music_item : MusicFree插件定义的 IMusicItem + Returns: + dict: 包含成功状态和URL信息的字典 + """ + return await self.__call_plugin_method( + plugin_name=music_item.get('platform'), + method_name="get_lyric", + music_item=music_item, + result_key="rawLrc", + required_field="rawLrc" + ) + + # 调用在线搜索歌曲,并优化返回 + async def search_music_online(self, search_key, name): + """调用MusicFree插件搜索歌曲 + + Args: + search_key (str): 搜索关键词 + name (str): 歌曲名 + Returns: + dict: 包含成功状态和URL信息的字典 + """ + + try: + # 获取歌曲列表 + result = await self.get_music_list_online(keyword=name, limit=10) + self.log.info(f"在线搜索歌曲列表: {result}") + + if result.get('success') and result.get('total') > 0: + # 打印输出 result.data + self.log.info(f"歌曲列表: {result.get('data')}") + # 根据搜素关键字,智能搜索出最符合的一条music_item + music_item = await self._search_top_one(result.get('data'), search_key, name) + # 验证 music_item 是否为字典类型 + if not isinstance(music_item, dict): + self.log.error(f"music_item should be a dict, but got {type(music_item)}: {music_item}") + return {"success": False, "error": "Invalid music item format"} + + # 如果是OpenAPI,则需要转换播放链接 + openapi_info = self.js_plugin_manager.get_openapi_info() + if openapi_info.get("enabled", False): + return await self.get_real_url_of_openapi(music_item.get('url')) + else: + media_source = await self.get_media_source_url(music_item) + if media_source.get('success'): + return {"success": True, "url": media_source.get('url')} + else: + return {"success": False, "error": media_source.get('error')} + else: + return {"success": False, "error": "未找到歌曲"} + + except Exception as e: + # 记录错误日志 + self.log.error(f"searchKey {search_key} get media source failed: {e}") + return {"success": False, "error": str(e)} + + async def _search_top_one(self, music_items, search_key, name): + """智能搜索出最符合的一条music_item""" + try: + # 如果没有音乐项目,返回None + if not music_items: + return None + + self.log.info(f"搜索关键字: {search_key};歌名:{name}") + # 如果只有一个项目,直接返回 + if len(music_items) == 1: + return music_items[0] + + # 计算每个项目的匹配分数 + def calculate_match_score(item): + """计算匹配分数""" + title = item.get('title', '').lower() if item.get('title') else '' + artist = item.get('artist', '').lower() if item.get('artist') else '' + keyword = search_key.lower() + + if not keyword: + return 0 + + score = 0 + # 歌曲名匹配权重 + if keyword in title: + # 完全匹配得最高分 + if title == keyword: + score += 90 + # 开头匹配 + elif title.startswith(keyword): + score += 70 + # 结尾匹配 + elif title.endswith(keyword): + score += 50 + # 包含匹配 + else: + score += 30 + # 部分字符匹配 + elif any(char in title for char in keyword.split()): + score += 10 + # 艺术家名匹配权重 + if keyword in artist: + # 完全匹配 + if artist == keyword: + score += 9 + # 开头匹配 + elif artist.startswith(keyword): + score += 7 + # 结尾匹配 + elif artist.endswith(keyword): + score += 5 + # 包含匹配 + else: + score += 3 + # 部分字符匹配 + elif any(char in artist for char in keyword.split()): + score += 1 + return score + + # 按匹配分数排序,返回分数最高的项目 + sorted_items = sorted(music_items, key=calculate_match_score, reverse=True) + return sorted_items[0] + + except Exception as e: + self.log.error(f"_search_top_one error: {e}") + # 出现异常时返回第一个项目 + return music_items[0] if music_items else None + + # =========================================================== + def _find_real_music_list_name(self, list_name): if not self.config.enable_fuzzy_match: self.log.debug("没开启模糊匹配") @@ -1188,11 +1627,14 @@ async def play_music_list(self, did="", arg1="", **kwargs): return await self.do_play_music_list(did, list_name, music_name) async def do_play_music_list(self, did, list_name, music_name=""): + # 查找并获取真实的音乐列表名称 list_name = self._find_real_music_list_name(list_name) + # 检查音乐列表是否存在,如果不存在则进行语音提示并返回 if list_name not in self.music_list: await self.do_tts(did, f"播放列表{list_name}不存在") return + # 调用设备播放音乐列表的方法 await self.devices[did].play_music_list(list_name, music_name) # 播放一个播放列表里第几个 @@ -1245,9 +1687,37 @@ async def search_play(self, did="", arg1="", **kwargs): did, name, search_key, exact=False, update_cur_list=False ) + # 在线播放:在线搜索、播放 + async def online_play(self, did="", arg1="", **kwargs): + # 先推送默认【搜索中】音频,搜索到播放url后推送给小爱 + config = self.config + if config and hasattr(config, 'hostname') and hasattr(config, 'public_port'): + proxy_base = f"{config.hostname}:{config.public_port}" + else: + proxy_base = "http://192.168.31.241:8090" + search_audio = proxy_base + "/static/search.mp3" + silence_audio = proxy_base + "/static/silence.mp3" + await self.play_url(self.get_cur_did(), search_audio) + + # TODO 添加一个定时器,4秒后触发 + + # 获取搜索关键词 + parts = arg1.split("|") + search_key = parts[0] + name = parts[1] if len(parts) > 1 else search_key + if not name: + name = search_key + self.log.info(f"搜索关键字{search_key},搜索歌名{name}") + result = await self.search_music_online(search_key, name) + # 搜索成功,则直接推送url播放 + if result.get("success", False): + url = result.get("url", "") + # 播放歌曲 + await self.devices[did].play_music(name, true_url=url) + # 后台搜索播放 async def do_play( - self, did, name, search_key="", exact=False, update_cur_list=False + self, did, name, search_key="", exact=False, update_cur_list=False ): return await self.devices[did].play(name, search_key, exact, update_cur_list) @@ -1633,6 +2103,9 @@ def get_offset_duration(self): offset = time.time() - self._start_time - self._paused_time return offset, duration + async def play_music(self, name, true_url=None): + return await self._playmusic(name, true_url=true_url) + # 初始化播放列表 def update_playlist(self, reorder=True): # 没有重置 list 且非初始化 @@ -1640,7 +2113,7 @@ def update_playlist(self, reorder=True): # 更新总播放列表,为了UI显示 self.xiaomusic.music_list["临时搜索列表"] = copy.copy(self._play_list) elif ( - self.device.cur_playlist == "临时搜索列表" and len(self._play_list) == 0 + self.device.cur_playlist == "临时搜索列表" and len(self._play_list) == 0 ) or (self.device.cur_playlist not in self.xiaomusic.music_list): self.device.cur_playlist = "全部" else: @@ -1682,7 +2155,7 @@ async def _play(self, name="", search_key="", exact=True, update_cur_list=False) return else: name = self.get_cur_music() - self.log.info(f"play. search_key:{search_key} name:{name}") + self.log.info(f"play. search_key:{search_key} name:{name}: exact:{exact}") # 本地歌曲不存在时下载 if exact: @@ -1713,10 +2186,15 @@ async def _play(self, name="", search_key="", exact=True, update_cur_list=False) if self.config.disable_download: await self.do_tts(f"本地不存在歌曲{name}") return - await self.download(search_key, name) - # 把文件插入到播放列表里 - await self.add_download_music(name) - await self._playmusic(name) + else: + # 如果插件播放失败,则执行下载流程 + await self.download(search_key, name) + # 把文件插入到播放列表里 + await self.add_download_music(name) + await self._playmusic(name) + else: + # 本地存在歌曲,直接播放 + await self._playmusic(name) # 下一首 async def play_next(self): @@ -1726,18 +2204,18 @@ async def _play_next(self): self.log.info("开始播放下一首") name = self.get_cur_music() if ( - self.device.play_type == PLAY_TYPE_ALL - or self.device.play_type == PLAY_TYPE_RND - or self.device.play_type == PLAY_TYPE_SEQ - or name == "" - or ( + self.device.play_type == PLAY_TYPE_ALL + or self.device.play_type == PLAY_TYPE_RND + or self.device.play_type == PLAY_TYPE_SEQ + or name == "" + or ( (name not in self._play_list) and self.device.play_type != PLAY_TYPE_ONE - ) + ) ): name = self.get_next_music() self.log.info(f"_play_next. name:{name}, cur_music:{self.get_cur_music()}") if name == "": - await self.do_tts("本地没有歌曲") + # await self.do_tts("本地没有歌曲") return await self._play(name, exact=True) @@ -1749,10 +2227,10 @@ async def _play_prev(self): self.log.info("开始播放上一首") name = self.get_cur_music() if ( - self.device.play_type == PLAY_TYPE_ALL - or self.device.play_type == PLAY_TYPE_RND - or name == "" - or (name not in self._play_list) + self.device.play_type == PLAY_TYPE_ALL + or self.device.play_type == PLAY_TYPE_RND + or name == "" + or (name not in self._play_list) ): name = self.get_prev_music() self.log.info(f"_play_prev. name:{name}, cur_music:{self.get_cur_music()}") @@ -1803,7 +2281,7 @@ async def playlocal(self, name, exact=True, update_cur_list=False): return await self._playmusic(name) - async def _playmusic(self, name): + async def _playmusic(self, name, true_url=None): # 取消组内所有的下一首歌曲的定时器 self.cancel_group_next_timer() @@ -1811,20 +2289,22 @@ async def _playmusic(self, name): self.device.cur_music = name self.log.info(f"cur_music {self.get_cur_music()}") - sec, url = await self.xiaomusic.get_music_sec_url(name) + sec, url = await self.xiaomusic.get_music_sec_url(name, true_url) await self.group_force_stop_xiaoai() self.log.info(f"播放 {url}") # 有3方设备打开 /static/3thplay.html 通过socketio连接返回true 忽律小爱音箱的播放 online = await thdplay("play", url, self.xiaomusic.thdtarget) + self.log.error(f"IS online {online}") + if not online: results = await self.group_player_play(url, name) if all(ele is None for ele in results): self.log.info(f"播放 {name} 失败. 失败次数: {self._play_failed_cnt}") await asyncio.sleep(1) if ( - self.isplaying() - and self._last_cmd != "stop" - and self._play_failed_cnt < 10 + self.isplaying() + and self._last_cmd != "stop" + and self._play_failed_cnt < 10 ): self._play_failed_cnt = self._play_failed_cnt + 1 await self._play_next() @@ -1878,8 +2358,8 @@ async def get_if_xiaoai_is_playing(self): self.log.info(playing_info) # WTF xiaomi api is_playing = ( - json.loads(playing_info.get("data", {}).get("info", "{}")).get("status", -1) - == 1 + json.loads(playing_info.get("data", {}).get("info", "{}")).get("status", -1) + == 1 ) return is_playing @@ -1998,8 +2478,8 @@ def get_music(self, direction="next"): if direction == "next": new_index = index + 1 if ( - self.device.play_type == PLAY_TYPE_SEQ - and new_index >= play_list_len + self.device.play_type == PLAY_TYPE_SEQ + and new_index >= play_list_len ): self.log.info("顺序播放结束") return "" @@ -2086,7 +2566,7 @@ async def play_one_url(self, device_id, url, name): f"play_one_url continue_play device_id:{device_id} ret:{ret} url:{url} audio_id:{audio_id}" ) elif self.config.use_music_api or ( - self.hardware in NEED_USE_PLAY_MUSIC_API + self.hardware in NEED_USE_PLAY_MUSIC_API ): ret = await self.xiaomusic.mina_service.play_by_music_url( device_id, url, audio_id=audio_id @@ -2293,7 +2773,7 @@ def find_cur_playlist(self, name): return "最近新增" for list_name, play_list in self.xiaomusic.music_list.items(): if (list_name not in ["全部", "所有歌曲", "所有电台", "临时搜索列表"]) and ( - name in play_list + name in play_list ): return list_name if name in self.xiaomusic.music_list.get("所有歌曲", []): @@ -2341,3 +2821,5 @@ def _execute_callback(): self._debounce_handle = self.loop.call_later( self.debounce_delay, _execute_callback ) + + # =================================================================== diff --git "a/\344\270\232\345\212\241\346\265\201\347\250\213\345\233\276.md" "b/\344\270\232\345\212\241\346\265\201\347\250\213\345\233\276.md" new file mode 100644 index 0000000000..c5b9283986 --- /dev/null +++ "b/\344\270\232\345\212\241\346\265\201\347\250\213\345\233\276.md" @@ -0,0 +1,111 @@ +好的,我们来梳理一下在线音乐搜索和播放的实现机制及调用流程。 + +## 在线音乐搜索与播放实现机制 + +### 核心组件 + +1. **[XiaoMusic.searchmusic](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1523-L1547)**: 主搜索入口,融合本地和在线搜索结果。 +2. **[JSPluginManager](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\js_plugin_manager.py#L18-L528)**: 管理和调用 JavaScript 插件,执行在线搜索和获取播放链接。 +3. **[JSAdapter](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\js_adapter.py#L11-L216)**: 在 Python 代码和 JS 插件之间进行数据格式转换。 +4. **[XiaoMusic._get_online_music_url](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L621-L662)**: 获取在线音乐的实际播放链接。 +5. **`XiaoMusic.all_music`**: 存储所有音乐(包括在线音乐)的元数据,其中在线音乐条目包含 `source: 'online'` 标记。 + +### 调用流程 + +以下是用户发出“搜索播放周杰伦”这类指令后的典型调用流程: + +```mermaid +sequenceDiagram + participant User as 用户 + participant XiaoMusic as XiaoMusic(主控模块) + participant XiaoMusicDevice as XiaoMusicDevice(设备控制) + participant LocalDB as 本地音乐库 + participant JSPluginManager as JS插件管理器 + participant JSAdapter as JS适配器 + participant OnlineSource as 在线音乐源 + + User->>XiaoMusic: 发起“最新提问”轮询 (poll_latest_ask) + XiaoMusic->>XiaoMusic: 检查上次查询 (_check_last_query) + XiaoMusic->>XiaoMusic: 触发新记录事件 (new_record_event.set) + XiaoMusic->>XiaoMusic: 执行命令检查 (do_check_cmd) + XiaoMusic->>XiaoMusic: 匹配指令 (match_cmd) + XiaoMusic->>XiaoMusic: 搜索并准备播放 (search_play) + + XiaoMusic->>XiaoMusicDevice: 调用 do_play + XiaoMusicDevice->>XiaoMusicDevice: 执行 _play + + alt 本地模糊匹配成功? + XiaoMusicDevice->>LocalDB: 模糊查找歌曲名 + LocalDB-->>XiaoMusicDevice: 返回匹配结果 + XiaoMusicDevice->>XiaoMusic: 获取真实音乐名 (find_real_music_name) + XiaoMusic->>XiaoMusicDevice: 调用 _playmusic(本地) + else 本地未匹配 + XiaoMusic->>LocalDB: 检查音乐是否存在 (is_music_exist?) + LocalDB-->>XiaoMusic: 不存在 + Note right of XiaoMusic: 可能触发在线搜索流程 + + XiaoMusic->>JSPluginManager: 调用 searchmusic + JSPluginManager->>LocalDB: 本地 fuzzyfinder 搜索 + JSPluginManager->>JSPluginManager: 调用 JSPluginManager.search + JSPluginManager->>JSAdapter: 格式化搜索结果 (format_search_results) + JSAdapter-->>XiaoMusic: 合并结果列表 + XiaoMusic->>User: 提供选择(或自动选中)→ 得到 name_online + User-->>XiaoMusic: 选定歌曲 + XiaoMusic->>XiaoMusicDevice: _play(name_online, ...) + XiaoMusicDevice->>XiaoMusicDevice: _playmusic(name_online) + end + + XiaoMusicDevice->>XiaoMusic: 获取音乐秒级URL (get_music_sec_url) + XiaoMusic->>XiaoMusic: 获取完整播放URL (get_music_url) + + alt 是否为在线音乐? + XiaoMusic->>XiaoMusic: 是 → 调用 _get_online_music_url + XiaoMusic->>JSAdapter: 转换音乐项 (convert_music_item_for_plugin) + JSAdapter->>JSPluginManager: 获取媒体源 (get_media_source) + JSPluginManager->>JSAdapter: 格式化媒体源结果 (format_media_source_result) + JSAdapter-->>XiaoMusic: 返回播放URL + else 本地音乐 + XiaoMusic->>XiaoMusic: 使用本地路径或其他URL逻辑 + end + + XiaoMusic->>XiaoMusicDevice: 调用 group_player_play + XiaoMusicDevice->>MiService: 通过 MiNAService / MiIOService 发送播放指令 + MiService-->>XiaoMusicDevice: 指令接收确认 + XiaoMusicDevice-->>User: 音乐开始播放 + +``` + + +**详细步骤分解**: + +1. **语音指令接收**: `XiaoMusic.poll_latest_ask` 持续监听音箱指令,收到后通过 `XiaoMusic._check_last_query` 触发 `XiaoMusic.new_record_event`。 +2. **指令解析**: `XiaoMusic.do_check_cmd` 和 `XiaoMusic.match_cmd` 识别出是 `search_play` 指令。 +3. **初始播放请求**: `XiaoMusic.search_play` 调用 `XiaoMusic.do_play(..., exact=False)`。 +4. **设备处理**: `XiaoMusicDevice.play` 调用 `XiaoMusicDevice._play`。 +5. **模糊匹配**: + * `XiaoMusicDevice._play` 调用 `XiaoMusic.find_real_music_name(name, n=config.search_music_count)`。 + * `XiaoMusic.find_real_music_name` 主要在现有的 `XiaoMusic.all_music` keys (即本地歌曲名) 中进行模糊匹配。 + * 如果匹配到本地歌曲,则直接播放。 +6. **在线搜索触发 (隐式)**: + * 如果 `find_real_music_name` 未在本地找到足够匹配项,或者后续流程发现 `name` 并非一个已知的有效歌曲名(例如,它只是一个搜索关键词)。 + * **关键点**: 真正的在线搜索通常发生在用户通过 Web UI 输入关键词点击搜索,或者语音指令被解析为需要主动调用搜索功能时。此时会调用 [XiaoMusic.searchmusic(name)](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1523-L1547)。 + * [XiaoMusic.searchmusic](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1523-L1547): + * 首先调用 [fuzzyfinder](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\utils.py#L116-L119) 在本地歌曲中搜索。 + * 然后调用 [XiaoMusic._search_online_music(name)](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1549-L1571)。 + * [XiaoMusic._search_online_music](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1549-L1571) 遍历启用的插件 ([JSPluginManager.get_enabled_plugins](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\js_plugin_manager.py#L272-L277)),对每个插件调用 [JSPluginManager.search(plugin_name, name)](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\js_plugin_manager.py#L279-L322)。 + * 搜索结果通过 [JSAdapter.format_search_results](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\js_adapter.py#L18-L47) 格式化,使其符合 [XiaoMusic](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L72-L1713) 内部使用的结构(通常是歌曲名列表,这些名字会被添加到 `XiaoMusic.all_music` 中,并标记为在线音乐)。 + * 本地和在线结果合并后返回。 +7. **播放准备**: 假设通过某种方式(如 UI 选择或默认选取)得到了一个确定的在线歌曲名 `name_online`。[XiaoMusicDevice._play](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1803-L1844) 或 [XiaoMusicDevice._playmusic](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1931-L1972) 被调用。 +8. **URL 获取**: + * [XiaoMusicDevice._playmusic](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1931-L1972) 调用 [XiaoMusic.get_music_sec_url(name_online)](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L550-L576)。 + * [XiaoMusic.get_music_sec_url](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L550-L576) 调用 [XiaoMusic.get_music_url(name_online)](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L602-L614)。 + * [XiaoMusic.get_music_url](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L602-L614) 检查 [XiaoMusic.is_online_music(name_online)](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L616-L619)。由于 `name_online` 是在线歌曲,此检查返回 `True`。 + * 因此,调用 [XiaoMusic._get_online_music_url(name_online)](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L621-L662)。 +9. **在线 URL 获取**: + * [XiaoMusic._get_online_music_url](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L621-L662) 从 `XiaoMusic.all_music[name_online]` 中取出预存的 `plugin_name` 和 `original_data`。 + * 使用 [JSAdapter.convert_music_item_for_plugin](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\js_adapter.py#L197-L216) 将这些数据转换为插件期望的格式。 + * 调用 [JSPluginManager.get_media_source(plugin_name, converted_item)](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\js_plugin_manager.py#L324-L342)。这会执行插件内的相应函数来获取播放链接。 + * 结果通过 [JSAdapter.format_media_source_result](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\js_adapter.py#L49-L60) 格式化,提取出实际的 URL。 +10. **发送播放指令**: 获取到 URL 后,[XiaoMusicDevice._playmusic](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1931-L1972) 调用 [XiaoMusicDevice.group_player_play(url)](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L2193-L2200),最终通过 `MiNAService` 或 `MiIOService` 将 URL 发送给对应的小爱音箱设备进行播放。 + +这个流程展示了如何将本地音乐和通过插件接入的在线音乐源统一管理,并在需要时动态获取播放链接,从而实现无缝的音乐播放体验。 diff --git "a/\345\212\237\350\203\275\346\217\217\350\277\260.md" "b/\345\212\237\350\203\275\346\217\217\350\277\260.md" new file mode 100644 index 0000000000..ae65e22a16 --- /dev/null +++ "b/\345\212\237\350\203\275\346\217\217\350\277\260.md" @@ -0,0 +1,285 @@ +好的,我们来分析一下当前代码的功能结构以及在线音乐搜索播放的流程。 + +### 整体功能和调用顺序概述 + +整个 [XiaoMusic](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L72-L1713) 系统大致可以分为以下几个核心功能模块: + +1. **初始化与配置 ([__init__](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\__init__.py#L0-L1), [init_config](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L148-L170), [setup_logger](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L187-L216), [try_init_setting](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1602-L1613), [update_devices](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L172-L185))**: + * 初始化配置、日志、设备列表、播放列表等。 + * 加载插件管理器 ([JSPluginManager](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\js_plugin_manager.py#L18-L528), [JSAdapter](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\js_adapter.py#L11-L216)) 和其他组件。 + +2. **设备通信与状态管理 ([poll_latest_ask](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L218-L259), [init_all_data](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L261-L273), [login_miboy](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L292-L310), `get_latest_ask_*`)**: + * 登录小米账号,获取设备列表和服务实例 (`MiNAService`, `MiIOService`)。 + * 轮询或监听来自小爱音箱的语音指令。 + +3. **音乐数据管理 ([_gen_all_music_list](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L815-L903), [_append_music_list](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L919-L955), [refresh_custom_play_list](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L905-L916), [try_gen_all_music_tag](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L766-L773))**: + * 扫描本地音乐目录,构建 [all_music](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L89-L89) (歌曲名->路径/URL映射) 和 [music_list](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\httpserver.py#L826-L826) (播放列表)。 + * 加载网络电台/歌曲列表。 + * 管理自定义播放列表。 + * 提取和缓存歌曲元数据 (标签)。 + +4. **指令解析与路由 ([do_check_cmd](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1029-L1041), [match_cmd](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1094-L1143))**: + * 解析从小爱音箱收到的文本指令。 + * 根据指令关键字 ([key_match_order](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\config.py#L127-L127)) 和参数匹配到对应的方法 ([play](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1300-L1310), [search_play](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1313-L1323), [set_volume](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1514-L1520) 等)。 + +5. **播放控制逻辑 ([play*](file://C:\dev\boluofan\xiaomusic-online\MusicFreeDesktop-master\res\play.png), [do_play](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1326-L1329), `stop*`, `set_play_type*`, `play_music_list*`, `play_*_index`)**: + * 处理播放请求的核心逻辑。 + * [play](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1300-L1310) 和 [search_play](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1313-L1323) 是主要入口,它们最终都调用 [do_play](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1326-L1329)。 + * [do_play](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1326-L1329) 再调用具体设备 ([XiaoMusicDevice](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1716-L2428)) 的 [play](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1300-L1310) 方法。 + * 管理播放列表的选择、切换、模式设置等。 + +6. **设备操作 ([XiaoMusicDevice](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1716-L2428) 类)**: + * 每个音箱设备对应一个 [XiaoMusicDevice](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1716-L2428) 实例。 + * 负责具体的播放、暂停、停止、TTS、音量控制等操作。 + * 管理当前设备的播放列表 ([_play_list](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1736-L1736))、播放状态 ([_playing](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1729-L1729))、定时器等。 + * 调用 `MiNAService` 或 `MiIOService` 向音箱发送指令。 + +7. **音乐资源处理 ([get_music_url](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L602-L614), [get_music_sec_url](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L550-L576), `_get_*_music_url`, [download](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L2035-L2076))**: + * 根据歌曲名查找其播放 URL。 + * 区分本地音乐、普通网络音乐、需要 API 的网络音乐、在线插件音乐。 + * 处理 URL 代理、下载等。 + +8. **在线音乐插件集成 ([searchmusic](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\httpserver.py#L250-L251), [_search_online_music](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1549-L1571), [is_online_music](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L616-L619), [_get_online_music_url](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L621-L662))**: + * 集成 [JSPluginManager](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\js_plugin_manager.py#L18-L528) 来搜索和播放来自外部插件源的音乐。 + +9. **辅助功能 ([searchmusic](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\httpserver.py#L250-L251), [find_real_music_name](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1145-L1176), [crontab](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\crontab.py#L0-L189), [analytics](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\analytics.py#L0-L140), `file watch`)**: + * 模糊搜索、计划任务、使用统计、文件监控等。 + +**简化的主要调用链**: +``` +main() -> XiaoMusic.__init__() + -> XiaoMusic.run_forever() + -> XiaoMusic.poll_latest_ask() + -> XiaoMusic._get_last_query() / get_latest_ask_by_mina() + -> XiaoMusic._check_last_query() + -> XiaoMusic.new_record_event.set() + -> XiaoMusic.do_check_cmd() + -> XiaoMusic.match_cmd() + -> XiaoMusic.play() / XiaoMusic.search_play() (或其他指令方法) + -> XiaoMusic.do_play() + -> XiaoMusicDevice.play() (通过 self.devices[did]) + -> XiaoMusicDevice._play() + -> (可能调用 search/download) + -> XiaoMusicDevice._playmusic() + -> XiaoMusic.get_music_sec_url() + -> XiaoMusic.get_music_url() + -> (根据不同类型调用 _get_*_music_url) + -> XiaoMusicDevice.group_player_play() + -> XiaoMusicDevice.play_one_url() + -> MiNAService.play_by_*() / MiIOService.*() (向音箱发指令) +``` + + +### 在线搜索歌曲并播放的实现 + +对于**非本地音乐**,特别是通过 JS 插件实现的在线音乐,流程如下: + +1. **触发搜索播放**: 用户说出类似“播放周杰伦的歌”这样的指令。 +2. **指令识别**: [XiaoMusic.match_cmd()](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1094-L1143) 识别出这是 [search_play](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1313-L1323) 指令,并提取参数 `"周杰伦|周杰伦"` (假设搜索词和歌曲名都是周杰伦)。 +3. **调用 [search_play](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1313-L1323)**: + ```python + # XiaoMusic.search_play + async def search_play(self, did="", arg1="", **kwargs): + parts = arg1.split("|") + search_key = parts[0] # "周杰伦" + name = parts[1] if len(parts) > 1 else search_key # "周杰伦" + if not name: + name = search_key + + # 调用后台搜索播放,exact=False表示模糊匹配,update_cur_list=False表示不更新主播放列表 + return await self.do_play( + did, name, search_key, exact=False, update_cur_list=False + ) + ``` + +4. **进入 [do_play](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1326-L1329)**: [XiaoMusic.do_play](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1326-L1329) 只是转发给对应设备。 + ```python + # XiaoMusic.do_play + async def do_play(self, did, name, search_key="", exact=False, update_cur_list=False): + # 调用 XiaoMusicDevice 实例的 play 方法 + return await self.devices[did].play(name, search_key, exact, update_cur_list) + ``` + +5. **设备处理 `play`**: `XiaoMusicDevice.play` 再调用内部 `_play` 方法。 + ```python + # XiaoMusicDevice.play + async def play(self, name="", search_key="", exact=True, update_cur_list=False): + self._last_cmd = "play" + return await self._play( + name=name, + search_key=search_key, + exact=exact, + update_cur_list=update_cur_list, + ) + ``` + +6. **关键步骤 - [_play](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1803-L1844) 中的模糊匹配**: + ```python + # XiaoMusicDevice._play (精简版) + async def _play(self, name="", search_key="", exact=True, update_cur_list=False): + # ... (检查空参数) ... + self.log.info(f"play. search_key:{search_key} name:{name}") + + # *** 关键步骤 1: 模糊匹配 *** + # 如果 exact=False (search_play 的情况),会调用 find_real_music_name + if exact: + names = self.xiaomusic.find_real_music_name(name, n=1) # 精确匹配 + else: + # n=self.config.search_music_count (通常>1),用于搜索 + names = self.xiaomusic.find_real_music_name( + name, n=self.config.search_music_count + ) + + if len(names) > 0: + # 如果本地找到了匹配的歌曲... + # ... (处理本地歌曲逻辑) ... + elif not self.xiaomusic.is_music_exist(name): + # *** 关键步骤 2: 本地未找到且 name 不存在于 all_music *** + # 这里是触发在线搜索的关键点之一 + # 因为在线音乐项会被预先加载到 all_music 中,并标记 source='online' + # 所以 is_music_exist(name) 对于有效的在线音乐名应该返回 True + # 但如果 name 是搜索词而不是具体歌曲名,则 is_music_exist(name) 会是 False + # 此时会进入下面的逻辑 + + # 注意:这里的逻辑主要是针对本地音乐下载。 + # 对于在线音乐,更关键的是 find_real_music_name 内部如何处理。 + + # ... (下载逻辑,对在线音乐通常禁用) ... + # 如果上面都没播放,则尝试播放 name (可能是 find_real_music_name 找到的结果或原始输入) + await self._playmusic(name) + ``` + +7. **关键步骤 - `find_real_music_name` 的增强搜索**: + * `XiaoMusic.find_real_music_name` 被 `XiaoMusicDevice._play` 调用。 + * 它首先使用 `find_best_match` 在本地歌曲列表 (`self.all_music.keys()`) 中进行模糊搜索。 + * **关键增强**: 在 `XiaoMusic.searchmusic` 方法中,实现了更广泛的搜索: + ```python + # XiaoMusic.searchmusic (被 find_real_music_name 或 UI 等调用) + def searchmusic(self, name): + self.log.info(f"Starting search for: {name}") + all_music_list = list(self.all_music.keys()) + self.log.info(f"Local music count: {len(all_music_list)}") + + # 1. 现有本地音乐搜索 + search_list = fuzzyfinder(name, all_music_list, self._extra_index_search) + self.log.info(f"Local search results count: {len(search_list)}") + + # 2. 【新增】JS 插件在线搜索 + if hasattr(self, 'js_plugin_manager') and self.js_plugin_manager: + try: + online_results = self._search_online_music(name) # *** 调用在线搜索 *** + self.log.info(f"Online search results count: {len(online_results)}") + # 合并结果,优先级:JS插件 > 本地 + search_list = online_results + search_list # *** 合并结果 *** + self.log.info(f"Total search results count: {len(search_list)}") + except Exception as e: + self.log.error(f"Online music search failed: {e}") + else: + self.log.warning("JS Plugin Manager not available") + + self.log.debug(f"searchmusic. name:{name} search_list:{search_list}") + return search_list # 返回混合结果列表 + ``` + + + * [XiaoMusic._search_online_music](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1549-L1571) 负责实际工作: + ```python + # XiaoMusic._search_online_music + def _search_online_music(self, name): + self.log.info(f"Starting online music search for: {name}") + online_results = [] + + enabled_plugins = self.js_plugin_manager.get_enabled_plugins() + self.log.info(f"Enabled plugins: {enabled_plugins}") + + # 并行搜索所有启用的插件 + for plugin_name in enabled_plugins: + try: + self.log.info(f"Searching in plugin: {plugin_name}") + # *** 调用 JS 插件的 search 方法 *** + plugin_results = self.js_plugin_manager.search(plugin_name, name) + # *** 使用 JSAdapter 格式化插件返回的结果 *** + formatted_results = self._format_online_results(plugin_results.get('data', []), plugin_name) + self.log.info(f"Plugin {plugin_name} returned {len(formatted_results)} results") + online_results.extend(formatted_results) # 添加到总结果 + except Exception as e: + self.log.error(f"Plugin {plugin_name} search failed: {e}") + import traceback + self.log.error(f"Full traceback: {traceback.format_exc()}") + + self.log.info(f"Total online results: {len(online_results)}") + return online_results # 返回所有插件的格式化结果 + ``` + + * [XiaoMusic._format_online_results](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1573-L1575) 调用 [JSAdapter](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\js_adapter.py#L11-L216) 来标准化插件返回的数据结构。 + * **结论**: [find_real_music_name](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1145-L1176) 主要在现有列表中找。而 [searchmusic](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\httpserver.py#L250-L251) 才是真正的在线+本地混合搜索入口。虽然 [_play](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1803-L1844) 直接调用了 [find_real_music_name](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1145-L1176),但在实际的搜索播放场景(如Web UI搜索或特定语音指令流程),可能会先调用 [searchmusic](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\httpserver.py#L250-L251) 获取结果列表,然后让用户选择或自动选取第一个结果传给 [play](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1300-L1310)/[_play](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1803-L1844)。 + +8. **准备播放 - [_playmusic](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1931-L1972)**: + * [_play](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1803-L1844) 方法最终会调用 [XiaoMusicDevice._playmusic(name)](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1931-L1972)。 + * [_playmusic](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1931-L1972) 首先调用 [XiaoMusic.get_music_sec_url(name)](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L550-L576) 来获取歌曲时长和播放 URL。 +9. **获取在线音乐 URL**: + * [XiaoMusic.get_music_sec_url](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L550-L576) 调用 [XiaoMusic.get_music_url(name)](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L602-L614)。 + * [XiaoMusic.get_music_url](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L602-L614) 判断歌曲来源: + ```python + # XiaoMusic.get_music_url (精简版) + async def get_music_url(self, name): + if self.is_online_music(name): # *** 检查是否是在线音乐 *** + return await self._get_online_music_url(name) # *** 调用在线音乐处理 *** + elif self.is_web_music(name): + return await self._get_web_music_url(name) + return self._get_local_music_url(name), None + ``` + + * [XiaoMusic.is_online_music(name)](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L616-L619) 会检查 `self.all_music[name]` 字典中是否有 `'source': 'online'` 键值对。 + * **关键步骤 3 - 获取播放链接**: 如果是在线音乐,则调用 [XiaoMusic._get_online_music_url(name)](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L621-L662): + ```python + # XiaoMusic._get_online_music_url + async def _get_online_music_url(self, name): + music_info = self.all_music.get(name, {}) # 获取预存的音乐信息 + if not music_info or music_info.get('source') != 'online': + return "", None + + try: + plugin_name = music_info.get('plugin_name') # 插件名 + original_data = music_info.get('original_data', {}) # 插件原始数据 + + # 使用适配器转换音乐项,使其符合插件接口要求 + plugin_music_item = self.js_adapter.convert_music_item_for_plugin(music_info) + + # *** 核心:调用 JS 插件管理器获取媒体源 (播放链接) *** + media_source = self.js_plugin_manager.get_media_source(plugin_name, plugin_music_item) + + # 使用适配器格式化媒体源结果 (例如提取 URL) + formatted_source = self.js_adapter.format_media_source_result(media_source, music_info) + + if not formatted_source or not formatted_source.get('url'): + self.log.error(f"Failed to get media source for {name}") + return "", None + + url = formatted_source['url'] # 获取最终播放 URL + origin_url = url + + # 是否需要代理 + if self.config.web_music_proxy: + proxy_url = self._get_proxy_url(url) + return proxy_url, origin_url + + return url, origin_url # 返回播放 URL + + except Exception as e: + self.log.error(f"Error getting online music URL for {name}: {e}") + import traceback + self.log.error(f"Full traceback: {traceback.format_exc()}") + return "", None + ``` + +10. **播放**: [_playmusic](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L1931-L1972) 获取到 URL 后,调用 [XiaoMusicDevice.group_player_play(url, name)](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L2193-L2200),进而通过 `MiNAService` 或 `MiIOService` 发送播放指令给音箱。 + +**总结**: + +在线搜索播放的核心在于: + +* **混合搜索**: [searchmusic](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\httpserver.py#L250-L251) 方法整合了本地模糊搜索 ([fuzzyfinder](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\utils.py#L116-L119)) 和通过 [JSPluginManager](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\js_plugin_manager.py#L18-L528) 调用插件进行的在线搜索。 +* **预加载信息**: 成功搜索到的在线歌曲会被预先添加到 `XiaoMusic.all_music` 字典中,并带有 `source='online'`, `plugin_name`, `original_data` 等特殊标记和数据。 +* **动态获取 URL**: 当真正要播放 ([_get_online_music_url](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L621-L662)) 时,才利用预存的 `plugin_name` 和 `original_data`,通过 [JSPluginManager.get_media_source](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\js_plugin_manager.py#L324-L342) 调用对应插件的函数来实时获取播放链接。 +* **插件适配**: [JSAdapter](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\js_adapter.py#L11-L216) 负责在搜索结果和播放请求两个环节,将 [XiaoMusic](file://C:\dev\boluofan\xiaomusic-online\xiaomusic\xiaomusic.py#L72-L1713) 内部的数据结构与各 JS 插件可能使用的不同数据结构进行转换。