From 50f7869a05fcb89f6b594454320f6ee3e6e21e62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B2=E6=82=A6?= Date: Mon, 12 Jan 2026 20:03:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=BD=91=E6=98=93?= =?UTF-8?q?=E4=BA=91=E9=9F=B3=E4=B9=90=E5=90=8C=E6=AD=A5=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E5=8A=9F=E8=83=BD=E4=B8=8EUI=E6=94=B9?= =?UTF-8?q?=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加完整的网易云音乐同步到Navidrome的功能实现,包括: 1. 新增Docker支持与相关配置文件 2. 实现歌单同步逻辑与Navidrome API集成 3. 改进前端UI界面与交互体验 4. 添加状态监控与错误处理机制 5. 实现定时同步功能与进度显示 --- .gitignore | 11 + Netease-sync/.dockerignore | 8 + Netease-sync/Dockerfile | 18 + Netease-sync/README.md | 150 +++++ Netease-sync/package-lock.json | 976 +++++++++++++++++++++++++++++++++ Netease-sync/package.json | 3 +- Netease-sync/public/app.js | 333 ++++++----- Netease-sync/public/index.html | 392 +++++++------ Netease-sync/server.js | 585 +++++++++++++++++--- api.md | 218 ++++++++ docker-compose.yml | 23 + 11 files changed, 2331 insertions(+), 386 deletions(-) create mode 100644 .gitignore create mode 100644 Netease-sync/.dockerignore create mode 100644 Netease-sync/Dockerfile create mode 100644 Netease-sync/README.md create mode 100644 Netease-sync/package-lock.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d73b1b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules +.env +.env.* +data +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.DS_Store +.vscode +.idea \ No newline at end of file diff --git a/Netease-sync/.dockerignore b/Netease-sync/.dockerignore new file mode 100644 index 0000000..0c9a711 --- /dev/null +++ b/Netease-sync/.dockerignore @@ -0,0 +1,8 @@ +node_modules +npm-debug.log +.git +.gitignore +README.md +需求.md +.env +data \ No newline at end of file diff --git a/Netease-sync/Dockerfile b/Netease-sync/Dockerfile new file mode 100644 index 0000000..e48bc40 --- /dev/null +++ b/Netease-sync/Dockerfile @@ -0,0 +1,18 @@ +FROM node:18-alpine + +# Install FFmpeg for audio processing +RUN apk add --no-cache ffmpeg + +WORKDIR /app + +COPY package*.json ./ + +RUN npm install --production + +COPY . . + +RUN mkdir -p /app/data + +EXPOSE 3000 + +CMD ["node", "server.js"] \ No newline at end of file diff --git a/Netease-sync/README.md b/Netease-sync/README.md new file mode 100644 index 0000000..5f0c761 --- /dev/null +++ b/Netease-sync/README.md @@ -0,0 +1,150 @@ +# Netease Sync to Navidrome + +将网易云音乐歌单同步到 Navidrome 的服务。 + +## 功能特性 + +- 支持通过歌单ID或分享链接添加网易云音乐歌单 +- 自动下载歌单中的歌曲(通过 sync-server) +- 在 Navidrome 中创建同名歌单 +- 自动匹配并添加已下载的歌曲到 Navidrome 歌单 +- 定时自动同步(默认每300秒) +- 增量同步,只添加新歌曲 +- 实时同步状态反馈 +- Web 界面管理 + +## 环境变量 + +在启动服务前,需要配置以下环境变量: + +| 变量名 | 说明 | 默认值 | +|--------|------|--------| +| `PORT` | 服务端口 | 3000 | +| `DATA_DIR` | 数据存储目录 | /app/data | +| `NAVIDROME_URL` | Navidrome 服务地址 | http://navidrome:4533 | +| `NAVIDROME_USERNAME` | Navidrome 用户名 | admin | +| `NAVIDROME_PASSWORD` | Navidrome 密码 | - | +| `SYNC_INTERVAL` | 定时同步间隔(秒) | 300 | +| `SYNC_SERVER_URL` | Sync-Server 服务地址 | http://sync-service:3001 | +| `SYNC_SERVER_TOKEN` | Sync-Server 认证令牌 | default | +| `TUNEHUB_API_URL` | TuneHub API 地址 | https://music-dl.sayqz.com | + +## 使用方法 + +### 1. 配置环境变量 + +复制 `.env.example` 为 `.env` 并修改配置: + +```bash +cp .env.example .env +``` + +编辑 `.env` 文件,设置 Navidrome 的连接信息。 + +### 2. 使用 Docker Compose 启动 + +在项目根目录运行: + +```bash +docker-compose up -d netease-sync +``` + +### 3. 访问 Web 界面 + +打开浏览器访问:`http://localhost:7483` + +### 4. 添加歌单 + +在输入框中输入网易云音乐歌单ID或分享链接,例如: +- 歌单ID:`123456789` +- 分享链接:`https://music.163.com/#/playlist?id=123456789` + +点击"添加歌单"按钮,系统会自动: +1. 获取歌单信息 +2. 下载所有歌曲 +3. 在 Navidrome 中创建歌单 +4. 添加歌曲到歌单 + +### 5. 管理歌单 + +- **立即同步**:点击"立即同步"按钮手动触发同步 +- **删除歌单**:点击"删除"按钮移除歌单(不会删除 Navidrome 中的歌单) +- **查看状态**:实时查看同步状态和上次同步时间 + +## API 接口 + +### 获取所有歌单 + +``` +GET /api/playlists +``` + +### 添加歌单 + +``` +POST /api/playlists +Content-Type: application/json + +{ + "url": "歌单ID或分享链接" +} +``` + +### 删除歌单 + +``` +DELETE /api/playlists/:id +``` + +### 手动同步歌单 + +``` +POST /api/playlists/:id/sync +``` + +### 获取歌单状态 + +``` +GET /api/status/:id +``` + +## 同步逻辑 + +1. **获取歌单信息**:通过 TuneHub API 获取网易云音乐歌单信息 +2. **下载歌曲**:将歌曲列表发送到 sync-server 进行下载 +3. **等待下载完成**:等待 5 秒让 sync-server 处理下载 +4. **匹配歌曲**:根据文件名格式 `Artist - Name [netease_id].ext` 在 Navidrome 中搜索歌曲 +5. **更新歌单**: + - 如果是首次同步,在 Navidrome 中创建新歌单 + - 如果已存在,只添加新匹配到的歌曲 +6. **记录映射**:保存网易云音乐歌曲ID到 Navidrome歌曲ID的映射关系 + +## 数据存储 + +所有数据存储在 `DATA_DIR` 目录下的 `playlists.json` 文件中,包括: +- 歌单信息 +- Navidrome 歌单ID +- 歌曲映射关系 +- 同步状态和时间 + +## 故障排除 + +### 歌单同步失败 + +1. 检查 Navidrome 连接配置是否正确 +2. 查看 Docker 容器日志:`docker logs netease-sync` +3. 确认 sync-server 正常运行 +4. 检查网络连接 + +### 歌曲未匹配到 + +1. 确认歌曲已成功下载到 music 目录 +2. 检查文件名格式是否正确 +3. Navidrome 可能需要时间扫描新文件 +4. 尝试手动触发 Navidrome 媒体库扫描 + +### 定时同步不工作 + +1. 检查 `SYNC_INTERVAL` 环境变量设置 +2. 查看容器日志确认定时任务是否启动 +3. 确认服务正常运行 \ No newline at end of file diff --git a/Netease-sync/package-lock.json b/Netease-sync/package-lock.json new file mode 100644 index 0000000..20116c0 --- /dev/null +++ b/Netease-sync/package-lock.json @@ -0,0 +1,976 @@ +{ + "name": "netease-sync", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "netease-sync", + "version": "1.0.0", + "dependencies": { + "axios": "^1.6.0", + "dotenv": "^17.2.3", + "express": "^4.18.2", + "node-cron": "^3.0.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://mirrors.cloud.tencent.com/npm/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/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/body-parser": { + "version": "1.20.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/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/combined-stream": { + "version": "1.0.8", + "resolved": "https://mirrors.cloud.tencent.com/npm/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/content-disposition": { + "version": "0.5.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://mirrors.cloud.tencent.com/npm/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/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/ee-first": { + "version": "1.1.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/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/escape-html": { + "version": "1.0.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/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/forwarded": { + "version": "0.2.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://mirrors.cloud.tencent.com/npm/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/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/ms": { + "version": "2.0.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/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/on-finished": { + "version": "2.4.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/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://mirrors.cloud.tencent.com/npm/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/statuses": { + "version": "2.0.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://mirrors.cloud.tencent.com/npm/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/Netease-sync/package.json b/Netease-sync/package.json index ba5ee9b..6af710b 100644 --- a/Netease-sync/package.json +++ b/Netease-sync/package.json @@ -7,8 +7,9 @@ "start": "node server.js" }, "dependencies": { - "express": "^4.18.2", "axios": "^1.6.0", + "dotenv": "^17.2.3", + "express": "^4.18.2", "node-cron": "^3.0.3" }, "engines": { diff --git a/Netease-sync/public/app.js b/Netease-sync/public/app.js index 39e7314..f6816d7 100644 --- a/Netease-sync/public/app.js +++ b/Netease-sync/public/app.js @@ -1,30 +1,59 @@ const API_BASE = '/api'; let playlists = []; +let pollingInterval = null; -function showError(message) { - const errorDiv = document.getElementById('errorMessage'); - errorDiv.textContent = message; - errorDiv.style.display = 'block'; - setTimeout(() => { - errorDiv.style.display = 'none'; - }, 5000); -} +// Toast Notification System +function showToast(message, type = 'info') { + const container = document.getElementById('toast-container'); + const toast = document.createElement('div'); + + const colors = { + info: 'bg-blue-500/80 border-blue-500/50', + success: 'bg-green-500/80 border-green-500/50', + error: 'bg-red-500/80 border-red-500/50', + warning: 'bg-yellow-500/80 border-yellow-500/50' + }; -function showSuccess(message) { - const successDiv = document.getElementById('successMessage'); - successDiv.textContent = message; - successDiv.style.display = 'block'; + const icons = { + info: 'fa-circle-info', + success: 'fa-circle-check', + error: 'fa-circle-exclamation', + warning: 'fa-triangle-exclamation' + }; + + toast.className = `glass-panel ${colors[type] || colors.info} border text-white px-4 py-3 rounded-xl flex items-center gap-3 shadow-xl transform transition-all duration-300 translate-x-full opacity-0`; + toast.innerHTML = ` + + ${message} + `; + + container.appendChild(toast); + + // Animation in + requestAnimationFrame(() => { + toast.classList.remove('translate-x-full', 'opacity-0'); + }); + + // Remove after delay setTimeout(() => { - successDiv.style.display = 'none'; + toast.classList.add('translate-x-full', 'opacity-0'); + setTimeout(() => toast.remove(), 300); }, 3000); } +// Format Utilities function formatTimestamp(timestamp) { if (!timestamp) return '从未同步'; const date = new Date(timestamp); + const now = new Date(); + const diff = now - date; + + if (diff < 60000) return '刚刚'; + if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`; + return date.toLocaleString('zh-CN', { - year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', @@ -32,98 +61,182 @@ function formatTimestamp(timestamp) { }); } -function getStatusClass(status) { +function getStatusConfig(status) { switch (status) { case 'syncing': - return 'status-syncing'; + return { text: '同步中', class: 'bg-yellow-500/80 text-white border-yellow-400/50 animate-pulse' }; case 'success': - return 'status-success'; + return { text: '已同步', class: 'bg-green-500/80 text-white border-green-400/50' }; + case 'failed': case 'error': - return 'status-error'; + return { text: '失败', class: 'bg-red-500/80 text-white border-red-400/50' }; default: - return 'status-idle'; + return { text: '待同步', class: 'bg-gray-500/50 text-gray-300 border-gray-400/30' }; } } -function getStatusText(status) { - switch (status) { - case 'syncing': - return '同步中'; - case 'success': - return '同步成功'; - case 'error': - return '同步失败'; - default: - return '待同步'; - } -} - -function renderPlaylists() { - const container = document.getElementById('playlistList'); - - if (playlists.length === 0) { - container.innerHTML = ` -
- - - - - -

还没有添加任何歌单

-

在上方输入网易云音乐歌单ID或分享链接开始同步

-
- `; - return; - } - - container.innerHTML = playlists.map(playlist => ` -
-
-
${playlist.name}
-
- 🆔 ${playlist.neteaseId} - 🎵 ${playlist.songs.length} 首歌曲 - ⏰ ${formatTimestamp(playlist.lastSyncTime)} - ${getStatusText(playlist.status)} -
-
-
- - -
-
- `).join(''); -} - +// Core Logic async function loadPlaylists() { try { const response = await fetch(`${API_BASE}/playlists`); if (!response.ok) throw new Error('加载歌单失败'); - playlists = await response.json(); + const data = await response.json(); + + // Deep compare to avoid unnecessary re-renders if nothing changed + // For simplicity, we just check length and status of syncing items + // But to ensure progress bars update smoothly, we re-render or update existing + // Here we'll re-render for simplicity as the list is likely small + playlists = data; + updateStats(); renderPlaylists(); + + // Adjust polling based on activity + const anySyncing = playlists.some(p => p.status === 'syncing'); + adjustPolling(anySyncing); + } catch (error) { console.error('加载歌单失败:', error); - showError('加载歌单失败: ' + error.message); + showToast(error.message, 'error'); } } +function adjustPolling(isActive) { + if (isActive && !pollingInterval) { + pollingInterval = setInterval(loadPlaylists, 2000); + } else if (!isActive && pollingInterval) { + clearInterval(pollingInterval); + pollingInterval = null; + // Poll infrequently when idle just in case + pollingInterval = setInterval(loadPlaylists, 10000); + } else if (!pollingInterval) { + // Default idle polling + pollingInterval = setInterval(loadPlaylists, 10000); + } +} + +function updateStats() { + const total = playlists.length; + const synced = playlists.filter(p => p.status === 'success').length; + const syncing = playlists.filter(p => p.status === 'syncing').length; + const songs = playlists.reduce((acc, curr) => acc + (curr.songs?.length || 0), 0); + + // Animate numbers + animateValue('stat-total', parseInt(document.getElementById('stat-total').innerText), total, 500); + animateValue('stat-synced', parseInt(document.getElementById('stat-synced').innerText), synced, 500); + animateValue('stat-songs', parseInt(document.getElementById('stat-songs').innerText), songs, 500); + animateValue('stat-syncing', parseInt(document.getElementById('stat-syncing').innerText), syncing, 500); + + const statsContainer = document.getElementById('stats-container'); + if (total > 0) { + statsContainer.classList.remove('hidden'); + } else { + statsContainer.classList.add('hidden'); + } +} + +function animateValue(id, start, end, duration) { + if (start === end) return; + const obj = document.getElementById(id); + const range = end - start; + let current = start; + const increment = end > start ? 1 : -1; + const stepTime = Math.abs(Math.floor(duration / range)); + + const timer = setInterval(() => { + current += increment; + obj.innerHTML = current; + if (current == end) { + clearInterval(timer); + } + }, Math.max(stepTime, 20)); // Cap speed +} + +function renderPlaylists() { + const grid = document.getElementById('playlist-grid'); + const emptyState = document.getElementById('empty-state'); + const template = document.getElementById('playlist-card-template'); + + if (playlists.length === 0) { + grid.innerHTML = ''; + emptyState.classList.remove('hidden'); + return; + } + + emptyState.classList.add('hidden'); + + // We want to preserve existing elements to keep animations smooth if possible + // But full re-render is safer for state consistency. + // Optimization: Diffing by ID could be done here. + + grid.innerHTML = ''; // Simple clear for now + + playlists.forEach(playlist => { + const clone = template.content.cloneNode(true); + const card = clone.querySelector('div'); // The root div + + // Data binding + const img = clone.querySelector('.playlist-cover'); + img.src = playlist.cover || 'https://p2.music.126.net/6y-UleORITEDbvrOLV0Q8A==/5639395138885805.jpg'; // Fallback image + img.alt = playlist.name; + + clone.querySelector('.playlist-name').textContent = playlist.name; + clone.querySelector('.playlist-count').textContent = (playlist.songs || []).length; + clone.querySelector('.playlist-date').textContent = formatTimestamp(playlist.lastSyncTime); + clone.querySelector('.playlist-id').textContent = `ID: ${playlist.neteaseId}`; + clone.querySelector('.playlist-link').href = `https://music.163.com/#/playlist?id=${playlist.neteaseId}`; + + // Status Badge + const statusConfig = getStatusConfig(playlist.status); + const badge = clone.querySelector('.playlist-status'); + badge.textContent = statusConfig.text; + badge.className = `playlist-status px-2.5 py-1 rounded-full text-xs font-medium backdrop-blur-md border ${statusConfig.class}`; + + // Actions + const syncBtn = clone.querySelector('.action-sync'); + const deleteBtn = clone.querySelector('.action-delete'); + + syncBtn.onclick = () => syncPlaylist(playlist.id); + deleteBtn.onclick = () => deletePlaylist(playlist.id); + + if (playlist.status === 'syncing') { + syncBtn.disabled = true; + syncBtn.classList.add('opacity-50', 'cursor-not-allowed'); + syncBtn.querySelector('i').classList.add('fa-spin'); + + // Show Progress Overlay + const overlay = clone.querySelector('.sync-overlay'); + overlay.classList.remove('translate-y-full'); + + clone.querySelector('.sync-message').textContent = playlist.syncMessage || '同步中...'; + clone.querySelector('.sync-percent').textContent = `${playlist.syncProgress || 0}%`; + clone.querySelector('.sync-progress-bar').style.width = `${playlist.syncProgress || 0}%`; + } else { + // Hide overlay is default by CSS class + } + + grid.appendChild(clone); + }); +} + +// Actions async function addPlaylist() { const input = document.getElementById('playlistInput'); const value = input.value.trim(); if (!value) { - showError('请输入歌单ID或分享链接'); + showToast('请输入歌单 ID 或链接', 'warning'); return; } + const btn = document.querySelector('button[onclick="addPlaylist()"]'); + const originalIcon = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ' 处理中...'; + try { const response = await fetch(`${API_BASE}/playlists`, { method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: value }) }); @@ -132,33 +245,36 @@ async function addPlaylist() { throw new Error(error.error || '添加歌单失败'); } - const playlist = await response.json(); - playlists.push(playlist); - renderPlaylists(); + await loadPlaylists(); // Refresh list input.value = ''; - showSuccess('歌单添加成功'); + showToast('歌单添加成功', 'success'); } catch (error) { console.error('添加歌单失败:', error); - showError('添加歌单失败: ' + error.message); + showToast(error.message, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = originalIcon; } } async function deletePlaylist(id) { - if (!confirm('确定要删除这个歌单吗?')) return; + if (!confirm('确定要移除这个歌单吗?')) return; try { const response = await fetch(`${API_BASE}/playlists/${id}`, { method: 'DELETE' }); - if (!response.ok) throw new Error('删除歌单失败'); + if (!response.ok) throw new Error('删除失败'); + showToast('歌单已移除', 'success'); + // Optimistic update playlists = playlists.filter(p => p.id !== id); + updateStats(); renderPlaylists(); - showSuccess('歌单删除成功'); } catch (error) { - console.error('删除歌单失败:', error); - showError('删除歌单失败: ' + error.message); + showToast(error.message, 'error'); + loadPlaylists(); // Revert on error } } @@ -170,50 +286,19 @@ async function syncPlaylist(id) { if (!response.ok) throw new Error('启动同步失败'); - const playlist = await response.json(); - const index = playlists.findIndex(p => p.id === id); - if (index !== -1) { - playlists[index] = playlist; - } - renderPlaylists(); - showSuccess('同步已启动'); + showToast('已启动同步任务', 'info'); + loadPlaylists(); // Update UI immediately to show syncing state } catch (error) { - console.error('启动同步失败:', error); - showError('启动同步失败: ' + error.message); + showToast(error.message, 'error'); } } -async function updatePlaylistStatus(id) { - try { - const response = await fetch(`${API_BASE}/status/${id}`); - if (!response.ok) return; - - const playlist = await response.json(); - const index = playlists.findIndex(p => p.id === id); - if (index !== -1) { - playlists[index] = playlist; - renderPlaylists(); - } - } catch (error) { - console.error('更新状态失败:', error); - } -} - -function startStatusPolling() { - setInterval(() => { - playlists.forEach(playlist => { - if (playlist.status === 'syncing') { - updatePlaylistStatus(playlist.id); - } - }); - }, 3000); -} - +// Input Handler document.getElementById('playlistInput').addEventListener('keypress', (e) => { if (e.key === 'Enter') { addPlaylist(); } }); -loadPlaylists(); -startStatusPolling(); \ No newline at end of file +// Initial Load +loadPlaylists(); \ No newline at end of file diff --git a/Netease-sync/public/index.html b/Netease-sync/public/index.html index 85e40ae..7d8e19f 100644 --- a/Netease-sync/public/index.html +++ b/Netease-sync/public/index.html @@ -1,229 +1,223 @@ - + - - 网易云音乐同步到 Navidrome + + + 网易云音乐同步 + + + - -
-

🎵 网易云音乐同步到 Navidrome

- -
-
- -
-
- - + +
+ +
+
+
+ +
+
+

网易云音乐同步

+

Sync Netease Cloud Music to Navidrome

+
-
-
加载中...
+
+ + +
+
+ + +
+ + +
+ + + + + + + +
+ +
+
+
+ + + diff --git a/Netease-sync/server.js b/Netease-sync/server.js index 89d2e0b..f51ccb8 100644 --- a/Netease-sync/server.js +++ b/Netease-sync/server.js @@ -1,13 +1,56 @@ +require('dotenv').config(); const express = require('express'); const axios = require('axios'); +const https = require('https'); +const http = require('http'); const cron = require('node-cron'); const fs = require('fs'); const path = require('path'); +const { spawn } = require('child_process'); + +// Configure Axios with retry and robust settings +const apiClient = axios.create({ + timeout: 30000, + httpsAgent: new https.Agent({ + keepAlive: true, + rejectUnauthorized: false // Ignore self-signed certs if any, helps with some proxy/network issues + }), + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + } +}); + +// Add retry interceptor +apiClient.interceptors.response.use(null, async (error) => { + const { config, message } = error; + if (!config || !config.retry) { + return Promise.reject(error); + } + + // Retry count + config.retryCount = config.retryCount || 0; + + if (config.retryCount >= config.retry) { + return Promise.reject(error); + } + + config.retryCount += 1; + console.log(`[API] Retrying request (${config.retryCount}/${config.retry}): ${config.url} - Error: ${message}`); + + // Exponential backoff + const delay = new Promise(resolve => { + setTimeout(resolve, config.retryDelay || 1000); + }); + + await delay; + return apiClient(config); +}); const app = express(); const PORT = process.env.PORT || 3000; const DATA_DIR = process.env.DATA_DIR || './data'; +const MUSIC_DIR = process.env.MUSIC_DIR || '/music'; // Default to /music for Docker shared volume const PLAYLISTS_FILE = path.join(DATA_DIR, 'playlists.json'); const NAVIDROME_URL = process.env.NAVIDROME_URL || 'http://navidrome:4533'; @@ -24,6 +67,14 @@ app.use(express.static('public')); if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } +if (!fs.existsSync(MUSIC_DIR)) { + // Only try to create if we have permissions, otherwise assume it's a mounted volume + try { + fs.mkdirSync(MUSIC_DIR, { recursive: true }); + } catch (e) { + console.warn(`Could not create MUSIC_DIR ${MUSIC_DIR}, assuming it exists or is mounted: ${e.message}`); + } +} let playlists = loadPlaylists(); let syncStatus = {}; @@ -50,10 +101,19 @@ function savePlaylists() { } function extractPlaylistId(input) { - const urlMatch = input.match(/playlist[/?]id=(\d+)/); - if (urlMatch) { - return urlMatch[1]; + // Match ?id=123123 or &id=123123 + const idParamMatch = input.match(/[?&]id=(\d+)/); + if (idParamMatch) { + return idParamMatch[1]; } + + // Match /playlist/123123 + const pathMatch = input.match(/\/playlist\/(\d+)/); + if (pathMatch) { + return pathMatch[1]; + } + + // Match pure number const idMatch = input.match(/^\d+$/); if (idMatch) { return input; @@ -81,7 +141,7 @@ async function getSubsonicUrl(endpoint, params = {}) { async function callSubsonicAPI(endpoint, params = {}) { try { const url = await getSubsonicUrl(endpoint, params); - const response = await axios.get(url); + const response = await apiClient.get(url, { retry: 3, retryDelay: 1000 }); if (response.data['subsonic-response'].status === 'ok') { return response.data['subsonic-response']; } else { @@ -96,10 +156,40 @@ async function callSubsonicAPI(endpoint, params = {}) { async function getPlaylistInfo(neteaseId) { try { const url = `${TUNEHUB_API_URL}/api/?source=netease&id=${neteaseId}&type=playlist`; - const response = await axios.get(url); + console.log(`[API] Fetching playlist info: ${url}`); + // Add retry for external API + const response = await apiClient.get(url, { retry: 3, retryDelay: 2000 }); + if (response.data.code === 200) { - return response.data.data; + const data = response.data.data; + + // Normalize data structure + // TuneHub API structure per api.md: data.list (tracks) and data.info (metadata) + + // 1. Extract Tracks + if (!data.tracks) { + if (data.list) { + data.tracks = data.list; + } else if (data.playlist && data.playlist.tracks) { + // Fallback for raw Netease API structure + data.tracks = data.playlist.tracks; + } + } + + // 2. Extract Metadata (Name, Cover, etc.) + // Priority: data.info (TuneHub) > data.playlist (Netease Raw) > data.result (Search/Other) + const info = data.info || data.playlist || data.result || {}; + + data.name = info.name || data.name || 'Unknown Playlist'; + data.cover = info.pic || info.coverImgUrl || info.picUrl || data.cover || ''; + data.description = info.desc || info.description || data.description || ''; + + console.log(`[API] Raw Info Name: ${info.name}, Final Name: ${data.name}`); + console.log(`[API] Resolved playlist info: ${data.name} (Tracks: ${data.tracks?.length || 0})`); + + return data; } else { + console.error('[API] Error response:', response.data); throw new Error(response.data.message || 'Failed to get playlist info'); } } catch (error) { @@ -108,17 +198,251 @@ async function getPlaylistInfo(neteaseId) { } } -async function sendToSyncServer(songs) { +async function triggerNavidromeScan() { try { - const url = `${SYNC_SERVER_URL}/kv/netease_sync_songs?token=${SYNC_SERVER_TOKEN}`; - await axios.post(url, songs); - console.log(`Sent ${songs.length} songs to sync-server`); + console.log('[Sync] Triggering Navidrome scan...'); + await callSubsonicAPI('startScan'); + // Give it some time to scan + await new Promise(resolve => setTimeout(resolve, 5000)); } catch (error) { - console.error('Sync-server error:', error.message); - throw error; + console.warn('[Sync] Failed to trigger Navidrome scan (might be auto-scanning):', error.message); } } +// --- Download Logic (Ported from sync-server) --- + +function sanitizeFilename(str) { + if (!str) return 'unknown'; + // Normalize Unicode (NFC) to ensure consistent encoding + const normalized = str.normalize('NFC'); + // Replace Windows illegal characters and control characters + return normalized + .replace(/[\\/:*?"<>|\x00-\x1f\x7f]/g, '_') + .trim() + .substring(0, 200); // Limit length to avoid path too long errors +} + +function downloadFile(url, dest) { + return new Promise((resolve, reject) => { + const protocol = url.startsWith('https') ? https : http; + protocol.get(url, (res) => { + // Handle Redirects + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + downloadFile(res.headers.location, dest).then(resolve).catch(reject); + return; + } + if (res.statusCode !== 200) { + reject(new Error(`Status code ${res.statusCode}`)); + return; + } + const file = fs.createWriteStream(dest); + res.pipe(file); + file.on('finish', () => { + file.close(() => resolve()); + }); + file.on('error', (err) => { + fs.unlink(dest, () => {}); + reject(err); + }); + }).on('error', (err) => { + fs.unlink(dest, () => {}); + reject(err); + }); + }); +} + +function writeMetadata(inputPath, outputPath, metadata) { + return new Promise((resolve, reject) => { + const args = ['-i', inputPath]; + + // Add cover input if available + if (metadata.cover) { + args.push('-i', metadata.cover); + args.push('-map', '0:0'); // Map audio from first input + args.push('-map', '1:0'); // Map image from second input + args.push('-c:v', 'mjpeg'); // Convert cover to jpeg + + // ID3v2 metadata for MP3 (cover art) + if (outputPath.endsWith('.mp3')) { + args.push('-id3v2_version', '3'); + args.push('-metadata:s:v', 'title="Album cover"'); + args.push('-metadata:s:v', 'comment="Cover (front)"'); + } else if (outputPath.endsWith('.flac')) { + args.push('-disposition:v', 'attached_pic'); + } + } else { + args.push('-c', 'copy'); + } + + // Add metadata tags + args.push( + '-metadata', `title=${metadata.title}`, + '-metadata', `artist=${metadata.artist}`, + '-metadata', `album=${metadata.album}` + ); + + // Required for FLAC + Cover to work properly without re-encoding audio + if (outputPath.endsWith('.flac') && metadata.cover) { + args.push('-c:a', 'copy'); + } else if (outputPath.endsWith('.mp3') && metadata.cover) { + args.push('-c:a', 'copy'); + } + + args.push('-y', outputPath); + + const ffmpeg = spawn('ffmpeg', args); + + let stderr = ''; + ffmpeg.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + ffmpeg.on('close', (code) => { + if (code === 0) resolve(); + else { + console.error(`FFmpeg Error Output: ${stderr}`); + reject(new Error(`FFmpeg exited with code ${code}`)); + } + }); + + ffmpeg.on('error', (err) => { + reject(err); + }); + }); +} + +function processSong(song) { + return new Promise((resolve) => { + const safeName = sanitizeFilename(song.name); + const safeArtist = sanitizeFilename(song.artist); + const source = song.platform || song.source; + // Filename: Artist - Name [source_id] + const baseName = `${safeArtist} - ${safeName} [${source}_${song.id}]`; + + // Check if file exists (fuzzy match for extension) + try { + const files = fs.readdirSync(MUSIC_DIR); + const exists = files.some(f => { + // 1. Exact Match: Artist - Name [source_id].ext + if (f.startsWith(baseName)) return true; + + // 2. Simple Format: Artist - Name.ext + if (f.startsWith(`${safeArtist} - ${safeName}.`)) return true; + + // 3. Simple Format w/o Artist: Name.ext + if (f.startsWith(`${safeName}.`)) return true; + + // 4. Match prefix ignoring ID: Artist - Name [ + if (f.startsWith(`${safeArtist} - ${safeName} [`)) return true; + + return false; + }); + + if (exists) { + // console.log(`[Sync] Skipped (Exists): ${song.name}`); + resolve(true); // Exists + return; + } + } catch (e) { + console.error('Error reading music dir:', e); + } + + // Get URL (prefer FLAC) + const apiUrl = `${TUNEHUB_API_URL}/api/?source=${source}&id=${song.id}&type=url&br=flac`; + + https.get(apiUrl, (res) => { + const handleDownload = (url) => { + let ext = 'mp3'; + if (url.includes('.flac')) ext = 'flac'; + else if (url.includes('.m4a')) ext = 'm4a'; + else if (url.includes('.ogg')) ext = 'ogg'; + else if (url.includes('.wav')) ext = 'wav'; + + const tempFile = path.join(MUSIC_DIR, `temp_${Date.now()}_${song.id}.${ext}`); + const finalFile = path.join(MUSIC_DIR, `${baseName}.${ext}`); + + downloadFile(url, tempFile) + .then(async () => { + try { + // Try to download cover image + let coverPath = null; + try { + const coverUrl = `${TUNEHUB_API_URL}/api/?source=${source}&id=${song.id}&type=pic`; + coverPath = path.join(MUSIC_DIR, `temp_cover_${Date.now()}_${song.id}.jpg`); + await downloadFile(coverUrl, coverPath); + } catch (e) { + console.warn(`[Sync] Failed to download cover for ${song.name}:`, e.message); + } + + // Write Metadata (Title, Artist, Album, Cover) + await writeMetadata(tempFile, finalFile, { + title: song.name, + artist: song.artist, + album: song.album || song.name, + cover: coverPath + }); + + console.log(`[Sync] Downloaded & Tagged: ${baseName}.${ext}`); + + // Cleanup temp files + if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile); + if (coverPath && fs.existsSync(coverPath)) fs.unlinkSync(coverPath); + resolve(true); // Downloaded + } catch (err) { + console.error(`[Sync] Metadata Error for ${song.name}:`, err.message); + // Fallback: Just rename temp to final + if (!fs.existsSync(finalFile)) { + fs.renameSync(tempFile, finalFile); + } + resolve(true); // Downloaded (fallback) + } + }) + .catch(err => { + console.error(`[Sync] Download failed for ${song.name}:`, err.message); + if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile); + resolve(false); // Failed + }); + }; + + // Handle 302 Redirect (Standard API behavior) + if (res.statusCode === 302 && res.headers.location) { + res.resume(); + handleDownload(res.headers.location); + return; + } + + if (res.statusCode !== 200) { + console.error(`[Sync] API Request Failed for ${song.name}: Status ${res.statusCode}`); + res.resume(); + resolve(false); + return; + } + + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + const apiRes = JSON.parse(data); + const url = apiRes.url; + + if (!url) { + console.log(`[Sync] No URL found for ${song.name}`); + resolve(false); + return; + } + handleDownload(url); + } catch (e) { + console.error('[Sync] API Parse Error:', e); + resolve(false); + } + }); + }).on('error', (e) => { + console.error(`[Sync] API Request Error: ${e.message}`); + resolve(false); + }); + }); +} + async function searchSongInNavidrome(filename) { try { const result = await callSubsonicAPI('search3', { query: filename }); @@ -165,14 +489,43 @@ async function updateNavidromePlaylist(playlistId, songIdsToAdd) { } } -async function syncPlaylist(playlist) { +async function findNavidromePlaylistByName(name) { + try { + const result = await callSubsonicAPI('getPlaylists'); + const playlists = result.playlists?.playlist || []; + // Subsonic returns single object if only one result, array otherwise. Normalize it. + const list = Array.isArray(playlists) ? playlists : [playlists]; + + const found = list.find(p => p.name === name); + return found ? found.id : null; + } catch (error) { + console.error('Find playlist error:', error.message); + return null; + } +} + +async function syncPlaylist(playlist, cachedInfo = null) { const playlistId = playlist.id; syncStatus[playlistId] = { status: 'syncing', progress: 0, message: '开始同步...' }; try { console.log(`[Sync] Syncing playlist: ${playlist.name} (${playlist.neteaseId})`); - const playlistInfo = await getPlaylistInfo(playlist.neteaseId); + let playlistInfo = cachedInfo; + if (!playlistInfo) { + playlistInfo = await getPlaylistInfo(playlist.neteaseId); + } + + // Update playlist metadata if available + if (playlistInfo.name && playlistInfo.name !== 'Unknown Playlist') { + playlist.name = playlistInfo.name; + } + if (playlistInfo.cover) { + playlist.cover = playlistInfo.cover; + } + if (playlistInfo.description) { + playlist.description = playlistInfo.description; + } if (!playlistInfo || !playlistInfo.tracks) { throw new Error('Failed to get playlist tracks'); @@ -194,59 +547,125 @@ async function syncPlaylist(playlist) { source: 'netease' })); - await sendToSyncServer(songs); + // --- Integrated Download Logic --- + let processedCount = 0; + const total = songs.length; - syncStatus[playlistId] = { status: 'syncing', progress: 30, message: '歌曲下载中...' }; + // Process songs sequentially to avoid overwhelming the server/API + // Or with limited concurrency (e.g., 3) + const BATCH_SIZE = 3; + for (let i = 0; i < total; i += BATCH_SIZE) { + const batch = songs.slice(i, i + BATCH_SIZE); + await Promise.all(batch.map(async (song) => { + try { + await processSong(song); + } catch (e) { + console.error(`[Sync] Failed to process song ${song.name}:`, e); + } + })); + + processedCount += batch.length; + const progress = 10 + Math.floor((processedCount / total) * 60); // 10% -> 70% + syncStatus[playlistId] = { + status: 'syncing', + progress: progress, + message: `下载/检查中 ${processedCount}/${total}` + }; + } + + // Trigger Scan after downloads + syncStatus[playlistId] = { status: 'syncing', progress: 75, message: '触发 Navidrome 扫描...' }; + await triggerNavidromeScan(); - await new Promise(resolve => setTimeout(resolve, 5000)); - - syncStatus[playlistId] = { status: 'syncing', progress: 50, message: '匹配歌曲到 Navidrome...' }; + syncStatus[playlistId] = { status: 'syncing', progress: 80, message: '匹配歌曲到 Navidrome...' }; const newSongIds = []; + const allNavidromeIds = []; for (let i = 0; i < songs.length; i++) { const song = songs[i]; const neteaseSongId = song.id; + let navidromeSongId = null; + if (playlist.songMapping && playlist.songMapping[neteaseSongId]) { - syncedCount++; - continue; - } - - const safeName = song.name.replace(/[\\/:*?"<>|\x00-\x1f\x7f]/g, '_'); - const safeArtist = song.artist.replace(/[\\/:*?"<>|\x00-\x1f\x7f]/g, '_'); - const filename = `${safeArtist} - ${safeName} [netease_${neteaseSongId}]`; - - const navidromeSongId = await searchSongInNavidrome(filename); - - if (navidromeSongId) { - newSongIds.push(navidromeSongId); + navidromeSongId = playlist.songMapping[neteaseSongId]; + } else { + const safeName = sanitizeFilename(song.name); + const safeArtist = sanitizeFilename(song.artist); + const filename = `${safeArtist} - ${safeName} [netease_${neteaseSongId}]`; - if (!playlist.songMapping) { - playlist.songMapping = {}; + navidromeSongId = await searchSongInNavidrome(filename); + + if (navidromeSongId) { + newSongIds.push(navidromeSongId); + + if (!playlist.songMapping) { + playlist.songMapping = {}; + } + playlist.songMapping[neteaseSongId] = navidromeSongId; } - playlist.songMapping[neteaseSongId] = navidromeSongId; + } + + if (navidromeSongId) { + allNavidromeIds.push(navidromeSongId); syncedCount++; } else { failedCount++; } - const progress = 50 + Math.floor((i + 1) / songs.length * 40); - syncStatus[playlistId] = { - status: 'syncing', - progress: progress, - message: `已匹配 ${syncedCount}/${totalTracks} 首歌曲` + const progress = 80 + Math.floor((i + 1) / songs.length * 15); // 80% -> 95% + syncStatus[playlistId] = { + status: 'syncing', + progress: progress, + message: `已匹配 ${syncedCount}/${totalTracks} 首歌曲` }; } - syncStatus[playlistId] = { status: 'syncing', progress: 90, message: '更新 Navidrome 歌单...' }; + syncStatus[playlistId] = { status: 'syncing', progress: 95, message: '更新 Navidrome 歌单...' }; - if (newSongIds.length > 0) { - if (playlist.navidromePlaylistId) { + // 1. Try to link existing Navidrome playlist by name if ID is missing + if (!playlist.navidromePlaylistId) { + console.log(`[Sync] searching for existing Navidrome playlist with name: ${playlist.name}`); + const existingId = await findNavidromePlaylistByName(playlist.name); + if (existingId) { + console.log(`[Sync] Found existing Navidrome playlist: ${existingId}`); + playlist.navidromePlaylistId = existingId; + } + } + + console.log(`[Sync] Matched ${allNavidromeIds.length} songs (New: ${newSongIds.length}). PlaylistID: ${playlist.navidromePlaylistId}`); + + if (playlist.navidromePlaylistId) { + // Playlist exists (either linked just now or before) + // Just append new songs if any + if (newSongIds.length > 0) { + console.log(`[Sync] Adding ${newSongIds.length} new songs to existing playlist ${playlist.navidromePlaylistId}`); await updateNavidromePlaylist(playlist.navidromePlaylistId, newSongIds); } else { - const navidromePlaylistId = await createNavidromePlaylist(playlist.name, newSongIds); + console.log(`[Sync] No new songs to add to playlist ${playlist.navidromePlaylistId}`); + } + } else { + // Playlist does NOT exist + // Create it with ALL matched songs (if any) + if (allNavidromeIds.length > 0) { + console.log(`[Sync] Creating new playlist '${playlist.name}' with ${allNavidromeIds.length} songs`); + const navidromePlaylistId = await createNavidromePlaylist(playlist.name, allNavidromeIds); playlist.navidromePlaylistId = navidromePlaylistId; + } else { + // Try creating empty playlist? Subsonic API usually requires songId. + // We will try with empty array, but it might fail or be rejected. + // Some servers support creating empty playlist. + try { + console.log(`[Sync] No songs matched, but attempting to create empty playlist '${playlist.name}'`); + const navidromePlaylistId = await createNavidromePlaylist(playlist.name, []); + if (navidromePlaylistId) { + playlist.navidromePlaylistId = navidromePlaylistId; + console.log(`[Sync] Created empty playlist: ${navidromePlaylistId}`); + } + } catch (e) { + console.warn(`[Sync] Failed to create empty playlist (server might require at least one song): ${e.message}`); + } } } @@ -259,12 +678,19 @@ async function syncPlaylist(playlist) { } savePlaylists(); - syncStatus[playlistId] = { - status: 'success', - progress: 100, - message: `同步完成: ${syncedCount} 首成功, ${failedCount} 首失败` + syncStatus[playlistId] = { + status: 'success', + progress: 100, + message: `同步完成: ${syncedCount} 首成功, ${failedCount} 首失败` }; + // Force save updated metadata + const finalPlaylistIndex = playlists.playlists.findIndex(p => p.id === playlistId); + if (finalPlaylistIndex !== -1) { + playlists.playlists[finalPlaylistIndex] = playlist; + } + savePlaylists(); + console.log(`[Sync] Playlist sync completed: ${playlist.name}`); } catch (error) { @@ -288,21 +714,27 @@ async function syncPlaylist(playlist) { } app.get('/api/playlists', (req, res) => { - const playlistsWithStatus = playlists.playlists.map(p => ({ - ...p, - currentStatus: syncStatus[p.id] || { status: p.syncStatus || 'idle', message: '' } - })); - res.json({ playlists: playlistsWithStatus }); + const playlistsWithStatus = playlists.playlists.map(p => { + const currentSyncStatus = syncStatus[p.id]; + return { + ...p, + status: currentSyncStatus?.status || p.syncStatus || 'idle', + syncProgress: currentSyncStatus?.progress || 0, + syncMessage: currentSyncStatus?.message || '', + songs: Object.keys(p.songMapping || {}) + }; + }); + res.json(playlistsWithStatus); }); app.post('/api/playlists', async (req, res) => { - const { input } = req.body; + const { url } = req.body; - if (!input) { - return res.status(400).json({ error: 'Input is required' }); + if (!url) { + return res.status(400).json({ error: 'URL is required' }); } - const neteaseId = extractPlaylistId(input); + const neteaseId = extractPlaylistId(url); if (!neteaseId) { return res.status(400).json({ error: 'Invalid playlist ID or URL' }); @@ -331,9 +763,15 @@ app.post('/api/playlists', async (req, res) => { playlists.playlists.push(newPlaylist); savePlaylists(); - res.json({ success: true, playlist: newPlaylist }); + const responsePlaylist = { + ...newPlaylist, + songs: [], + status: 'idle' + }; - syncPlaylist(newPlaylist); + res.json(responsePlaylist); + + syncPlaylist(newPlaylist, playlistInfo); } catch (error) { console.error('Add playlist error:', error); @@ -354,7 +792,7 @@ app.delete('/api/playlists/:id', (req, res) => { delete syncStatus[id]; - res.json({ success: true }); + res.json({ success: true, message: 'Playlist deleted successfully' }); }); app.post('/api/playlists/:id/sync', async (req, res) => { @@ -365,15 +803,38 @@ app.post('/api/playlists/:id/sync', async (req, res) => { return res.status(404).json({ error: 'Playlist not found' }); } - res.json({ success: true }); + playlist.syncStatus = 'syncing'; + savePlaylists(); + + syncStatus[id] = { status: 'syncing', progress: 0, message: '开始同步...' }; + + res.json(playlist); syncPlaylist(playlist); }); app.get('/api/status/:id', (req, res) => { const { id } = req.params; - const status = syncStatus[id] || { status: 'idle', message: '' }; - res.json(status); + const playlist = playlists.playlists.find(p => p.id === id); + + if (!playlist) { + return res.status(404).json({ error: 'Playlist not found' }); + } + + const status = syncStatus[id]; + if (status) { + playlist.status = status.status; + playlist.syncProgress = status.progress; + playlist.syncMessage = status.message; + } else { + playlist.status = playlist.syncStatus || 'idle'; + playlist.syncProgress = 0; + playlist.syncMessage = ''; + } + + playlist.songs = Object.keys(playlist.songMapping || {}); + + res.json(playlist); }); cron.schedule(`*/${SYNC_INTERVAL} * * * *`, () => { diff --git a/api.md b/api.md index 281108a..448822f 100644 --- a/api.md +++ b/api.md @@ -148,6 +148,224 @@ TuneHub 是一个统一的音乐信息解析服务。它打破了不同音乐平 * `source`: `string` - **必需**. 平台标识. * `id`: `string` - **必需**. 歌单 ID. * `type`: `string` - **必需**. 固定为 `playlist`. +结果示例: +{ + "code": 200, + "message": "success", + "data": { + "list": [ + { + "id": "2722391361", + "name": "天后 (live)", + "artist": "李佳薇", + "album": "歌手2025 第8期", + "info": "https://music-dl.sayqz.com/api/?source=netease&id=2722391361&type=info", + "url": "https://music-dl.sayqz.com/api/?source=netease&id=2722391361&type=url", + "pic": "https://music-dl.sayqz.com/api/?source=netease&id=2722391361&type=pic", + "lrc": "https://music-dl.sayqz.com/api/?source=netease&id=2722391361&type=lrc", + "types": [ + "flac24bit", + "flac", + "320k", + "128k" + ] + }, + { + "id": "1368753797", + "name": "法兰西多士", + "artist": "告五人", + "album": "我肯定在几百年前就说过爱你", + "info": "https://music-dl.sayqz.com/api/?source=netease&id=1368753797&type=info", + "url": "https://music-dl.sayqz.com/api/?source=netease&id=1368753797&type=url", + "pic": "https://music-dl.sayqz.com/api/?source=netease&id=1368753797&type=pic", + "lrc": "https://music-dl.sayqz.com/api/?source=netease&id=1368753797&type=lrc", + "types": [ + "flac24bit", + "flac", + "320k", + "128k" + ] + }, + { + "id": "155886", + "name": "光明", + "artist": "汪峰", + "album": "信仰在空中飘扬", + "info": "https://music-dl.sayqz.com/api/?source=netease&id=155886&type=info", + "url": "https://music-dl.sayqz.com/api/?source=netease&id=155886&type=url", + "pic": "https://music-dl.sayqz.com/api/?source=netease&id=155886&type=pic", + "lrc": "https://music-dl.sayqz.com/api/?source=netease&id=155886&type=lrc", + "types": [ + "flac", + "320k", + "128k" + ] + }, + { + "id": "569214247", + "name": "平凡的一天", + "artist": "毛不易", + "album": "平凡的一天", + "info": "https://music-dl.sayqz.com/api/?source=netease&id=569214247&type=info", + "url": "https://music-dl.sayqz.com/api/?source=netease&id=569214247&type=url", + "pic": "https://music-dl.sayqz.com/api/?source=netease&id=569214247&type=pic", + "lrc": "https://music-dl.sayqz.com/api/?source=netease&id=569214247&type=lrc", + "types": [ + "flac", + "320k", + "128k" + ] + }, + { + "id": "1810392410", + "name": "回音", + "artist": "神秘的小鸡蛋", + "album": "感觉", + "info": "https://music-dl.sayqz.com/api/?source=netease&id=1810392410&type=info", + "url": "https://music-dl.sayqz.com/api/?source=netease&id=1810392410&type=url", + "pic": "https://music-dl.sayqz.com/api/?source=netease&id=1810392410&type=pic", + "lrc": "https://music-dl.sayqz.com/api/?source=netease&id=1810392410&type=lrc", + "types": [ + "flac", + "320k", + "128k" + ] + }, + { + "id": "569200213", + "name": "消愁", + "artist": "毛不易", + "album": "平凡的一天", + "info": "https://music-dl.sayqz.com/api/?source=netease&id=569200213&type=info", + "url": "https://music-dl.sayqz.com/api/?source=netease&id=569200213&type=url", + "pic": "https://music-dl.sayqz.com/api/?source=netease&id=569200213&type=pic", + "lrc": "https://music-dl.sayqz.com/api/?source=netease&id=569200213&type=lrc", + "types": [ + "flac", + "320k", + "128k" + ] + }, + { + "id": "2083191501", + "name": "郁郁而终", + "artist": "马英杰", + "album": "郁郁而终(重制版)", + "info": "https://music-dl.sayqz.com/api/?source=netease&id=2083191501&type=info", + "url": "https://music-dl.sayqz.com/api/?source=netease&id=2083191501&type=url", + "pic": "https://music-dl.sayqz.com/api/?source=netease&id=2083191501&type=pic", + "lrc": "https://music-dl.sayqz.com/api/?source=netease&id=2083191501&type=lrc", + "types": [ + "flac24bit", + "flac", + "320k", + "128k" + ] + }, + { + "id": "1838919030", + "name": "王招君", + "artist": "任素汐", + "album": "TA·说", + "info": "https://music-dl.sayqz.com/api/?source=netease&id=1838919030&type=info", + "url": "https://music-dl.sayqz.com/api/?source=netease&id=1838919030&type=url", + "pic": "https://music-dl.sayqz.com/api/?source=netease&id=1838919030&type=pic", + "lrc": "https://music-dl.sayqz.com/api/?source=netease&id=1838919030&type=lrc", + "types": [ + "flac24bit", + "flac", + "320k", + "128k" + ] + }, + { + "id": "2105734399", + "name": "风过千里", + "artist": "雪域任运", + "album": "风过千里", + "info": "https://music-dl.sayqz.com/api/?source=netease&id=2105734399&type=info", + "url": "https://music-dl.sayqz.com/api/?source=netease&id=2105734399&type=url", + "pic": "https://music-dl.sayqz.com/api/?source=netease&id=2105734399&type=pic", + "lrc": "https://music-dl.sayqz.com/api/?source=netease&id=2105734399&type=lrc", + "types": [ + "flac", + "320k", + "128k" + ] + }, + { + "id": "1974443814", + "name": "我记得", + "artist": "赵雷", + "album": "署前街少年", + "info": "https://music-dl.sayqz.com/api/?source=netease&id=1974443814&type=info", + "url": "https://music-dl.sayqz.com/api/?source=netease&id=1974443814&type=url", + "pic": "https://music-dl.sayqz.com/api/?source=netease&id=1974443814&type=pic", + "lrc": "https://music-dl.sayqz.com/api/?source=netease&id=1974443814&type=lrc", + "types": [ + "flac", + "320k", + "128k" + ] + }, + { + "id": "155899", + "name": "勇敢的心", + "artist": "汪峰", + "album": "勇敢的心", + "info": "https://music-dl.sayqz.com/api/?source=netease&id=155899&type=info", + "url": "https://music-dl.sayqz.com/api/?source=netease&id=155899&type=url", + "pic": "https://music-dl.sayqz.com/api/?source=netease&id=155899&type=pic", + "lrc": "https://music-dl.sayqz.com/api/?source=netease&id=155899&type=lrc", + "types": [ + "flac", + "320k", + "128k" + ] + }, + { + "id": "1308081071", + "name": "作曲家 (Live)", + "artist": "刘郡格", + "album": "2018中国好声音 第8期", + "info": "https://music-dl.sayqz.com/api/?source=netease&id=1308081071&type=info", + "url": "https://music-dl.sayqz.com/api/?source=netease&id=1308081071&type=url", + "pic": "https://music-dl.sayqz.com/api/?source=netease&id=1308081071&type=pic", + "lrc": "https://music-dl.sayqz.com/api/?source=netease&id=1308081071&type=lrc", + "types": [ + "flac", + "320k", + "128k" + ] + }, + { + "id": "488641876", + "name": "有一个地方叫远方", + "artist": "曾昭玮", + "album": "有一个地方叫远方", + "info": "https://music-dl.sayqz.com/api/?source=netease&id=488641876&type=info", + "url": "https://music-dl.sayqz.com/api/?source=netease&id=488641876&type=url", + "pic": "https://music-dl.sayqz.com/api/?source=netease&id=488641876&type=pic", + "lrc": "https://music-dl.sayqz.com/api/?source=netease&id=488641876&type=lrc", + "types": [ + "flac", + "320k", + "128k" + ] + } + ], + "total": 13, + "source": "netease", + "info": { + "name": "为你降次元喜欢的音乐", + "pic": "https://p1.music.126.net/Qara552B1Q3VBJnwggJA2A==/109951171404861681.jpg", + "desc": "", + "author": "为你降次元", + "playCount": 1 + } + }, + "timestamp": "2026-01-12T18:32:10.516+08:00" +} ### 8. 获取排行榜列表 diff --git a/docker-compose.yml b/docker-compose.yml index 3e09a0f..e736ec1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,4 +16,27 @@ services: volumes: - ./data:/app/data - ./music:/app/music + restart: unless-stopped + + netease-sync: + container_name: netease-sync + build: ./Netease-sync + ports: + - "7483:3000" + environment: + - PORT=3000 + - DATA_DIR=/app/data + - MUSIC_DIR=/app/music + - NAVIDROME_URL=${NAVIDROME_URL:-http://navidrome:4533} + - NAVIDROME_USERNAME=${NAVIDROME_USERNAME:-admin} + - NAVIDROME_PASSWORD=${NAVIDROME_PASSWORD:-} + - SYNC_INTERVAL=${SYNC_INTERVAL:-300} + - SYNC_SERVER_URL=http://sync-service:3001 + - SYNC_SERVER_TOKEN=default + - TUNEHUB_API_URL=https://music-dl.sayqz.com + volumes: + - ./data:/app/data + - ./music:/app/music + depends_on: + - sync-service restart: unless-stopped \ No newline at end of file