From 67d93feec5b1926bbf2da246c349f7bd660184f2 Mon Sep 17 00:00:00 2001 From: Boluofan <13588211351@163.com> Date: Tue, 9 Dec 2025 16:21:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0musicfree=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E9=9B=86=E6=88=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + Dockerfile | 19 +- Dockerfile.builder | 8 +- Dockerfile.runtime | 2 + README.md | 1 + check_plugins.py | 60 ++ package-lock.json | 731 ++++++++++++++ package.json | 14 + pyproject.toml | 1 + xiaomusic/config.py | 2 + xiaomusic/httpserver.py | 257 ++++- xiaomusic/js_adapter.py | 215 +++++ xiaomusic/js_plugin_manager.py | 1015 ++++++++++++++++++++ xiaomusic/js_plugin_runner.js | 657 +++++++++++++ xiaomusic/plugins-config-example.json | 10 + xiaomusic/static/default/setting.html | 10 +- xiaomusic/static/index.html | 3 + xiaomusic/static/onlineSearch/index.html | 939 ++++++++++++++++++ xiaomusic/static/onlineSearch/setting.html | 605 ++++++++++++ xiaomusic/static/search.mp3 | Bin 0 -> 14855 bytes xiaomusic/static/silence.mp3 | Bin 0 -> 12247 bytes xiaomusic/static/tailwind/index.html | 13 +- xiaomusic/static/tailwind/md.js | 156 +-- xiaomusic/utils.py | 67 +- xiaomusic/xiaomusic.py | 587 ++++++++++- 25 files changed, 5189 insertions(+), 185 deletions(-) create mode 100644 check_plugins.py create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 xiaomusic/js_adapter.py create mode 100644 xiaomusic/js_plugin_manager.py create mode 100644 xiaomusic/js_plugin_runner.js create mode 100644 xiaomusic/plugins-config-example.json create mode 100644 xiaomusic/static/onlineSearch/index.html create mode 100644 xiaomusic/static/onlineSearch/setting.html create mode 100644 xiaomusic/static/search.mp3 create mode 100644 xiaomusic/static/silence.mp3 diff --git a/.gitignore b/.gitignore index 5012ba2..aa92e0b 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 6efe36e..446c10f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,35 @@ FROM hanxi/xiaomusic:builder AS builder + 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 + 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 +39,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 7b67315..c654d25 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 679f650..5b3114f 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 4782b78..fa81bbd 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,7 @@ docker build -t xiaomusic . - [一个第三方的主题](https://github.com/DarrenWen/xiaomusicui) - [Umami 统计](https://github.com/umami-software/umami) - [Sentry 报错监控](https://github.com/getsentry/sentry) +- [JS在线播放插件](https://github.com/boluofan/xiaomusic-online) - 所有帮忙调试和测试的朋友 - 所有反馈问题和建议的朋友 diff --git a/check_plugins.py b/check_plugins.py new file mode 100644 index 0000000..3b843e4 --- /dev/null +++ b/check_plugins.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +""" +检查所有插件的加载状态 +""" + +import sys + +sys.path.append('.') + +from xiaomusic.config import Config +from xiaomusic.js_plugin_manager import JSPluginManager + + +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() diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2b77c17 --- /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 0000000..c7284d7 --- /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 ec7524c..8260fef 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 29d6f08..0e57b41 100644 --- a/xiaomusic/config.py +++ b/xiaomusic/config.py @@ -152,6 +152,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", "播放列表,播放歌单" @@ -247,6 +248,7 @@ class Config: 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 a196229..e3de744 100644 --- a/xiaomusic/httpserver.py +++ b/xiaomusic/httpserver.py @@ -95,7 +95,7 @@ security = HTTPBasic() 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") @@ -253,6 +253,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): @@ -344,7 +577,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 = { @@ -359,9 +592,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: @@ -724,7 +957,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: @@ -769,7 +1002,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: @@ -790,7 +1023,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": @@ -821,7 +1054,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: @@ -832,7 +1065,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() @@ -1017,8 +1250,8 @@ JWT_EXPIRE_SECONDS = 60 * 5 # 5 分钟有效期(足够前端连接和重连 @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 0000000..267302a --- /dev/null +++ b/xiaomusic/js_adapter.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +""" +JS 插件适配器 +将 MusicFree JS 插件的数据格式转换为 xiaomusic 接口规范 +""" + +import logging + + +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 0000000..10a01e2 --- /dev/null +++ b/xiaomusic/js_plugin_manager.py @@ -0,0 +1,1015 @@ +#!/usr/bin/env python3 +""" +JS 插件管理器 +负责加载、管理和运行 MusicFree JS 插件 +""" + +import json +import logging +import os +import shutil +import subprocess +import threading +import time +from typing import Any + + +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, 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, 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, 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, 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, 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, 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 asyncio + + import aiohttp + + 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, 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, 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, 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, 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 0000000..018e53c --- /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 0000000..836b910 --- /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 bbffa69..325ecf3 100644 --- a/xiaomusic/static/default/setting.html +++ b/xiaomusic/static/default/setting.html @@ -108,7 +108,7 @@ var vConsole = new window.VConsole(); - + @@ -231,6 +231,8 @@ var vConsole = new window.VConsole(); + + + + + + + +
+
+ 请输入关键词开始搜索 +
+
+ + + +
+
+ +
+ +
+
+
+
未知歌曲
+
未知艺术家
+
+ +
+
+
+
+ + + + +
+
+
+ + + + + diff --git a/xiaomusic/static/onlineSearch/setting.html b/xiaomusic/static/onlineSearch/setting.html new file mode 100644 index 0000000..a8e6c58 --- /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 0000000000000000000000000000000000000000..8e1f200c959473e1fd9265424d27e030fdf9e8dd GIT binary patch literal 14855 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 literal 0 HcmV?d00001 diff --git a/xiaomusic/static/silence.mp3 b/xiaomusic/static/silence.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..97de37e6b6e16397f9a5c7c0c3ef3c31ba1434a5 GIT binary patch literal 12247 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 literal 0 HcmV?d00001 diff --git a/xiaomusic/static/tailwind/index.html b/xiaomusic/static/tailwind/index.html index 917e310..36e8d26 100644 --- a/xiaomusic/static/tailwind/index.html +++ b/xiaomusic/static/tailwind/index.html @@ -254,7 +254,8 @@
- + +
@@ -484,7 +485,7 @@ 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 @@ // 创建新的音频播放器 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 @@ 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 @@ break; } } - + showMessage(errorMessage, 'alert-error'); isPlaying.value = false; }); diff --git a/xiaomusic/static/tailwind/md.js b/xiaomusic/static/tailwind/md.js index 341135c..2078818 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 5236675..be975d4 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 @@ class MusicUrlCache: 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 1e26be0..30d6617 100644 --- a/xiaomusic/xiaomusic.py +++ b/xiaomusic/xiaomusic.py @@ -93,6 +93,7 @@ class XiaoMusic: 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 @@ class XiaoMusic: # 计划任务 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 @@ class XiaoMusic: 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 @@ class XiaoMusic: 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 @@ class XiaoMusic: 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 @@ class XiaoMusic: 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 @@ class XiaoMusic: 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 @@ class XiaoMusic: # 处理文件路径 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 @@ class XiaoMusic: # 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 @@ class XiaoMusic: 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 @@ class XiaoMusic: 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 @@ class XiaoMusic: 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 @@ class XiaoMusic: # 播放一个 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,366 @@ class XiaoMusic: # 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("在线获取歌曲列表!") + """ + 在线获取歌曲列表 + + 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等信息的字典 + """ + from urllib.parse import urlparse + + import aiohttp + + 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("通过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 +1628,14 @@ class XiaoMusic: 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 +1688,37 @@ class XiaoMusic: 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" + 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 +2104,9 @@ class XiaoMusicDevice: 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 +2114,7 @@ class XiaoMusicDevice: # 更新总播放列表,为了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 +2156,7 @@ class XiaoMusicDevice: 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 +2187,15 @@ class XiaoMusicDevice: 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 +2205,18 @@ class XiaoMusicDevice: 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 +2228,10 @@ class XiaoMusicDevice: 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 +2282,7 @@ class XiaoMusicDevice: return await self._playmusic(name) - async def _playmusic(self, name): + async def _playmusic(self, name, true_url=None): # 取消组内所有的下一首歌曲的定时器 self.cancel_group_next_timer() @@ -1812,20 +2291,22 @@ class XiaoMusicDevice: self.device.playlist2music[self.device.cur_playlist] = 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() @@ -1879,8 +2360,8 @@ class XiaoMusicDevice: 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 @@ -1999,8 +2480,8 @@ class XiaoMusicDevice: 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 "" @@ -2087,7 +2568,7 @@ class XiaoMusicDevice: 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 @@ -2296,7 +2777,7 @@ class XiaoMusicDevice: 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("所有歌曲", []): @@ -2344,3 +2825,5 @@ class XiaoMusicPathWatch(FileSystemEventHandler): self._debounce_handle = self.loop.call_later( self.debounce_delay, _execute_callback ) + + # ===================================================================