Compare commits

..
30 Commits
Author SHA1 Message Date
MoeFurina c3e0258b85 feat: 新增维度音乐相关接口 2026-05-22 19:44:56 +08:00
MoeFurina 843c19f089 update 4.31.0 2026-04-17 20:14:15 +08:00
MoeFurina c50aeddf5f update deps 2026-03-20 19:55:24 +08:00
MoeFurina b8af386082 update to 4.30.0 2026-03-20 19:54:16 +08:00
MoeFurinaandbinaryify d10296ccd8 feat(module): add user playlist collect
Co-Authored-By: binaryify <binaryify@gmail.com>
2026-02-11 16:25:30 +08:00
MoeFurinaandbinaryify 805d0bccc5 ci: update Node.js requirement and improve code consistency
Updated the README to require Node.js 18+ and added Docker usage instructions. Refactored util/client-sign.js for consistent code style and improved variable naming. Fixed the login_cellphone module to use 'weapi' in createOption. Removed deprecated code from server.js and improved CORS header formatting. Minor fix in util/request.js for user agent selection.

Co-Authored-By: binaryify <binaryify@gmail.com>
2026-01-16 22:35:16 +08:00
MoeFurina 5fc9346e0e chore(deps): update dependences 2025-11-15 00:29:32 +08:00
ImFurina e79490327d chore(deps): update dependence 2025-10-06 14:13:01 +08:00
ImFurina a30df5ae7f chore(dep): update dependence 2025-09-30 19:50:03 +08:00
ImFurina 06e4722225 chore(deps): 更新必要的依赖 2025-09-23 20:29:09 +08:00
ImFurina e0bcb214cb chore(deps): 更新部分依赖 2025-09-12 20:40:13 +08:00
ImFurinaandbinaryify 5478041ddd chore(version): 更新api到4.28.0
Co-Authored-By: binaryify <binaryify@gmail.com>
2025-09-07 13:20:37 +08:00
IamFurina 5105a9a09d feat. 更新API到4.27.0 2025-04-18 21:52:00 +08:00
IamFurina e3a43a807a perf. 加快访问速度 2025-03-24 12:29:41 +08:00
IamFurina 18715ca75d Update server.js 2025-03-23 15:07:05 +08:00
IamFurina e72f0dfbff Update server.js 2025-03-23 15:03:12 +08:00
IamFurina 54bdcf9482 Fix: 修复API不正常工作 2025-03-15 15:04:02 +08:00
IamFurina c4ad7a25c5 reapply. 重新加入解灰功能 2025-03-15 14:26:56 +08:00
IamFurina 03eb677b27 temp. 暂时移除解灰功能 2025-03-15 14:23:39 +08:00
IamFurina a22c798a8d revert. 回退操作 2025-03-15 14:21:04 +08:00
IamFurina 712c26d3da fix. 添加选择条件以防止多次调用接口 2025-03-15 14:16:01 +08:00
IamFurina b725db1380 feat. 接口支持无损音质 2025-03-13 18:49:09 +08:00
IamFurina 0de5526d17 chore. 加入.env配置文件 2025-03-13 18:47:17 +08:00
IamFurina 78fef459c8 feat. 接口支持无损音质 2025-03-13 18:44:53 +08:00
IamFurina 090eb4e085 Patch: 更新API 2025-02-26 21:33:07 +08:00
IamFurina 619fd10f4b Fix: 修复API不正常工作 2025-02-26 21:11:15 +08:00
IamFurina e219072f3c Patch: 更新API 2025-02-22 19:49:48 +08:00
IamFurina a345848533 Patch: 更新API 2025-02-22 19:38:19 +08:00
IamFurina 4e1307166a Patch: 更新todo 2025-02-22 19:28:30 +08:00
IamFurina 8f9d38529c Initial commit
Created from https://vercel.com/new
2025-02-22 11:16:58 +00:00
107 changed files with 18511 additions and 12244 deletions
+1 -2
View File
@@ -7,9 +7,8 @@
!/app.js
!/server.js
!/package.json
!/package-lock.json
!/index.js
!/generateConfig.js
!/main.js
!/data
!/.env
!/pnpm-lock.yaml
+2
View File
@@ -0,0 +1,2 @@
# 解灰接口支持无损音质
ENABLE_FLAC = true
-24
View File
@@ -1,24 +0,0 @@
### CORS资源共享设置
## 如果你需要配置, 请取消下面的注释并设置具体的域名
# CORS_ALLOW_ORIGIN = "*"
### 代理设置
## 启用反向代理
ENABLE_PROXY = false
## 代理配置
PROXY_URL = "https://your-proxy-url.com/?proxy="
### UnblockNeteaseMusic 设置项
## 启用全局解灰, 无论是否调用参数都会使用解灰(不推荐开启)
ENABLE_GENERAL_UNBLOCK = false
## 歌曲启用无损音质
ENABLE_FLAC = true
## 启用无损音质时,是否选择音质最高的
SELECT_MAX_BR = false
## 严格按照配置音源设置顺序进行匹配
FOLLOW_SOURCE_ORDER = true
## Cookie设置项; 推荐在Vercel等平台进行环境变量的设置或者设置github secret
# 网易云 cookie
# 格式:MUSIC_U=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# NETEASE_COOKIE = ""
+57
View File
@@ -0,0 +1,57 @@
module.exports = {
root: true,
ignorePatterns: ['public/'],
parserOptions: {
parser: 'babel-eslint',
ecmaVersion: 2018,
sourceType: 'module',
},
plugins: ['html'],
extends: ['plugin:prettier/recommended'],
env: {
browser: true,
node: true,
},
rules: {
'prettier/prettier': [
'error',
{
endOfLine: 'auto',
},
],
indent: ['error', 2, { SwitchCase: 1 }],
'space-infix-ops': ['error', { int32Hint: false }],
'key-spacing': [
2,
{
beforeColon: false,
afterColon: true,
},
],
'no-octal': 2,
'no-redeclare': 2,
'comma-spacing': 2,
'no-new-object': 2,
'arrow-spacing': 2,
quotes: [
2,
'single',
{
avoidEscape: true,
allowTemplateLiterals: true,
},
],
},
overrides: [
{
files: ['**/*.ts'],
parser: '@typescript-eslint/parser',
extends: [
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
// 'prettier/@typescript-eslint',
],
},
],
}
-1
View File
@@ -1 +0,0 @@
custom: ["https://rewards.focalors.ltd"]
+42
View File
@@ -0,0 +1,42 @@
---
name: 创建问题反馈
about: 问题反馈
title: "<不按照模版格式或者删除模版信息将得不到处理,确认看完了调用前须知再发 issues>"
labels: ''
assignees: ''
---
## 环境
- 系统/平台: <你的系统和平台>
- nodejs 版本: <你的 NodeJS 版本号>
- API版本: <运行的云音乐 API 的版本号, 对应 package.json 里面的 version>
## 出现问题
<请清晰描述出现的问题,不要使用xxx不行之类的模糊不清的语句,我没有读心术>
## 重现步骤
<请清晰描述重现步骤,调用的接口链接和参数以及命令行错误信息,最好贴上截图(命令行错误信息和接口返回内容)>
## 期待效果
<请清晰描述现在的效果和期待的效果>
---
>维护项目都是业余时间,精力有限,我只能挑容易解决的issues处理,为了节约双方时间,请尽可能提供足够的有用的信息,给的信息不够我只能根据精力和时间看情况处理,如果模板信息看都不看就删掉,我不会进行任何回复,并且一个月后close掉issue
>先看文档有没有相关说明,调用前须知必看,确认看完了调用前须知再发 issues
>先在 issues 搜一下是否有相似问题,没有再发,否则直接关闭
>不处理别人搭建的线上服务的问题,此项目不提供任何线上服务,请自行搭建
>重现步骤尽量详细,不能含糊不清,包含请求地址和对应参数以及操作过程描述,不是每个人都喜欢猜别人遇到了什么问题和找参数一个个试,也比较浪费时间
>如果不是提建议,提 issues 如果不照着模版来将不会优先处理或放着不管
>460 cheating 的问题把 `utils/request.js` 里面的 `headers['X-Real-IP']` 的注释取消掉就好
+11
View File
@@ -0,0 +1,11 @@
---
name: 功能需求
about: 希望添加新接口
title: ''
labels: 功能需求
assignees: ''
---
**功能描述**
- 请清晰描述你所希望添加的功能,最好贴上对应客户端界面的截图
-132
View File
@@ -1,132 +0,0 @@
name: 🐛 Bug报告
description: 报告API其中的错误或问题
title: "[Bug] "
labels: ["bug"]
assignees: []
body:
- type: markdown
attributes:
value: |
感谢您报告Bug!请填写以下信息帮助我们快速定位和修复问题。
- type: checkboxes
id: terms
attributes:
label: 确认事项
description: 在提交Bug报告前,请确认以下事项
options:
- label: 我已经搜索了现有的issues,确认这不是重复问题
required: true
- label: 我使用的是最新版本的API, 而且是官方发布的版本,而不是fork或修改版
required: true
- label: 不处理别人搭建的线上服务的问题,此项目提供任何线上服务不保证质量
required: true
- label: 如果不是提建议,提 issues 如果不照着模版来将不会优先处理或放着不管
required: true
- label: 维护项目都是业余时间,精力有限,我只能挑容易解决的issues处理,为了节约双方时间,请尽可能提供足够的有用的信息,给的信息不够我只能根据精力和时间看情况处理,如果模板信息看都不看就删掉,我不会进行任何回复,并且一个月后close掉issue
required: true
- type: input
id: version
attributes:
label: API版本
description: 请提供您使用的API版本以及nodejs版本
placeholder: "v4.2x.x, Node.js v22.x.x"
validations:
required: true
- type: dropdown
id: os
attributes:
label: 操作系统或平台
description: 您在哪个操作系统上遇到了这个问题?
options:
- Windows 10
- Windows 11
- Ubuntu 20.04
- Ubuntu 22.04
- macOS
- 其他 Linux 发行版
- 使用部署平台或其他 (请在描述中说明)
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: 部署平台
description:
options:
- 我使用的自己的服务器部署
- Vercel
- Heroku
- Railway
- 其他云平台 (请在描述中说明)
validations:
required: true
- type: textarea
id: bug-description
attributes:
label: Bug描述
description: 请详细描述您遇到的问题
placeholder: |
清晰简洁地描述bug的具体表现...
validations:
required: true
- type: textarea
id: reproduction-steps
attributes:
label: 重现步骤
description: 请提供详细的重现步骤
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: 期望行为
description: 请描述您期望发生的行为
placeholder: 清晰简洁地描述您期望应该发生什么...
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: 实际行为
description: 请描述实际发生的行为
placeholder: 清晰简洁地描述实际发生了什么...
validations:
required: true
- type: textarea
id: error-output
attributes:
label: 错误输出日志
description: 请提供完整的错误信息日志
render: shell
placeholder: |
错误信息输出...
- type: textarea
id: additional-context
attributes:
label: 额外信息
description: 添加任何其他有助于理解问题的信息
placeholder: |
- 截图
- 相关配置文件
- 其他相关信息
- type: checkboxes
id: help
attributes:
label: 愿意协助
description: 您是否愿意帮助修复这个bug?
options:
- label: 我愿意提交PR来修复这个bug
- label: 我可以提供更多测试用例
- label: 我可以帮助测试修复方案
-5
View File
@@ -1,5 +0,0 @@
blank_issues_enabled: true
contact_links:
- name: 提问的艺术
url: https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md
about: 默认所有 Issues 发起者均已了解此处的内容
@@ -1,72 +0,0 @@
name: ✨ 接口添加请求
description: 希望添加新接口
title: "[Feature] "
labels: ["enhancement"]
assignees: []
body:
- type: markdown
attributes:
value: |
感谢您提出新功能建议!请填写以下信息帮助我们理解您的需求。
- type: checkboxes
id: terms
attributes:
label: 确认事项
description: 在提交功能请求前,请确认以下事项
options:
- label: 我已经搜索了现有的issues,确认这不是重复请求
required: true
- label: 我已经查看了项目文档
required: true
- label: 该项目符合该项目的目标和范围
required: true
- label: 我确认我提供的接口请求是合理且有意义的
required: true
- type: textarea
id: proposed-solution
attributes:
label: 功能描述
description: 请详细描述您希望添加的功能,最好贴上对应客户端界面的截图
placeholder: |
请详细描述您期望的功能:
- 新的网易云音乐api 接口?
validations:
required: true
- type: dropdown
id: priority
attributes:
label: 优先级
description: 您认为这个功能的优先级如何?
options:
- 低 (可有可无的改进)
- 中 (会显著改善使用体验)
- 高 (严重影响了我使用)
- 紧急 (阻塞了重要工作)
validations:
required: true
- type: textarea
id: compatibility
attributes:
label: 兼容性考虑
description: 这个功能是否会影响现有代码的兼容性?
placeholder: |
- 是否是破坏性变更?
- 是否需要语法变更?
- 对性能的影响?
- type: checkboxes
id: implementation
attributes:
label: 实现意愿
description: 您是否愿意参与这个功能的实现?
options:
- label: 我愿意提交PR来实现这个功能
- label: 我可以协助设计API接口
- label: 我可以提供测试用例
- label: 我可以帮助编写文档
-41
View File
@@ -1,41 +0,0 @@
# .github/dependabot.yml
version: 2
updates:
# 更新 npm (JavaScript) 依赖
- package-ecosystem: "npm"
directory: "/" # package.json 所在的位置
schedule:
interval: "weekly" # 每周一检查
# 默认情况下,Dependabot 会自动获取最新版本。
# 你可以使用 `ignore` 来排除某些特定更新
ignore:
- dependency-name: "eslint"
# 对于 ESLint,忽略主版本 8 以上的所有更新
versions: ["> 8"]
# 自动合并小版本更新的 PR (可选)
# 可以设置针对 patch 或 minor 版本更新自动合并
commit-message:
prefix: "build(packages):" # PR 合并提交信息的前缀
# 更新 GitHub Actions 依赖
- package-ecosystem: "github-actions"
directory: "/" # .github/workflows/ 目录所在的位置
schedule:
interval: "monthly"
# 更新 Python (pip) 依赖,如果你的项目有 requirements.txt
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
# 如果你有多个目录(例如一个 monorepo),可以为每个目录单独配置
# - package-ecosystem: "npm"
# directory: "/packages/frontend"
# schedule:
# interval: "weekly"
# - package-ecosystem: "npm"
# directory: "/packages/backend"
# schedule:
# interval: "weekly"
-55
View File
@@ -1,55 +0,0 @@
name: Publish Docker image
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build-and-push:
if: startsWith(github.event.release.tag_name, 'v')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
# 读取 package.json 的版本号
- name: Read package version
id: pkg
run: echo "VERSION=$(jq -r .version package.json)" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
moefurina/ncm-api:latest
moefurina/ncm-api:${{ env.VERSION }}
ghcr.io/neteasecloudmusicapienhanced/ncm-api:latest
ghcr.io/neteasecloudmusicapienhanced/ncm-api:${{ env.VERSION }}
platforms: linux/amd64,linux/arm64/v8
-109
View File
@@ -1,109 +0,0 @@
name: Build and Create PR
on:
workflow_dispatch: # 手动触发
push:
branches: [main]
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
include:
- os: ubuntu-latest
platform: linux
target: node18-linux-x64
output: precompiled/app
- os: windows-latest
platform: win
target: node18-win-x64
output: precompiled/app.exe
- os: macos-latest
platform: macos
target: node18-macos-x64
output: precompiled/app
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '18'
- name: Install dependencies
run: |
npm install -g pnpm
pnpm install
- name: Build for ${{ matrix.platform }}
run: |
npm run pkg${{ matrix.platform }}
env:
PKG_TARGET: ${{ matrix.target }}
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
name: app-${{ matrix.platform }}
path: ${{ matrix.output }}
if-no-files-found: error
create-pr:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' # 只在手动触发时创建PR
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Download all artifacts
uses: actions/download-artifact@v8
with:
path: precompiled
- name: Display structure of downloaded files
run: ls -R
- name: Setup Node.js
uses: actions/setup-node@v6
- name: Create new branch and commit
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
BRANCH_NAME="auto-build-$(date +%Y%m%d-%H%M%S)"
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
git checkout -b $BRANCH_NAME
# 复制并整理下载的文件
mkdir -p precompiled
cp app-win/* precompiled/ || true
cp app-linux/* precompiled/ || true
cp app-macos/* precompiled/ || true
# 提交更改
git add precompiled/
git commit -m "Auto-build: Add compiled binaries for win, linux, macos" || exit 0
# 推送到远程仓库
git push origin $BRANCH_NAME
- name: Create Pull Request
uses: actions/github-script@v8
with:
script: |
const { data: pullRequest } = await github.rest.pulls.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: 'Auto-build: Add compiled binaries for win, linux, macos',
head: '${{ env.BRANCH_NAME }}',
base: 'main',
body: 'This PR contains newly built binaries for Windows, Linux, and macOS platforms.'
});
console.log(`Created PR #${pullRequest.number}: ${pullRequest.html_url}`);
-79
View File
@@ -1,79 +0,0 @@
name: Issue Management
permissions:
issues: write
contents: read
on:
issues:
types: [opened, labeled]
issue_comment:
types: [created]
jobs:
welcome-new-issues:
if: github.event.action == 'opened'
runs-on: ubuntu-latest
steps:
- name: Welcome new issue
uses: peter-evans/create-or-update-comment@v5
with:
issue-number: ${{ github.event.issue.number }}
body: |
## 快速链接
在我们处理您的问题之前,您可以先查看以下资源:
- 📖 查看[项目文档](https://neteasecloudmusicapienhanced.js.org)
- 💬 加入[QQ交流群](https://qm.qq.com/q/TpeP9Uv2yk)
- 🔍 搜索[现有issues](https://github.com/neteasecloudmusicapienhanced/api-enhanced/issues) 看是否有类似问题
handle-help-wanted:
if: contains(github.event.issue.labels.*.name, 'question')
runs-on: ubuntu-latest
steps:
- name: Add help resources
uses: peter-evans/create-or-update-comment@v5
with:
issue-number: ${{ github.event.issue.number }}
body: |
🆘 需要帮助?
这里有一些可能对您有用的资源:
📚 **文档资源**
- [项目文档](https://neteasecloudmusicapienhanced.js.org)
🔍 **常见问题**
- 搜索[已关闭的issues](https://github.com/neteasecloudmusicapienhanced/api-enhanced/issues?q=is%3Aissue+is%3Aclosed) 看看是否有类似的问题已经被解答。
💬 **即时帮助**
- 加入QQ群:https://qm.qq.com/q/TpeP9Uv2yk
如果以上资源无法解决您的问题,请提供更多详细信息,我们会尽快为您解答!
stale-issues:
if: github.event.action == 'opened'
runs-on: ubuntu-latest
steps:
- name: Mark stale issues
uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: |
这个issue已经30天没有活动了。
如果这个问题仍然存在,请回复此评论说明当前状态。
否则,这个issue将在7天后自动关闭。
您可以随时重新打开已关闭的issue。
stale-pr-message: |
这个PR已经30天没有活动了。
如果您仍在处理这个PR,请回复此评论。
否则,这个PR将在7天后自动关闭。
days-before-stale: 30
days-before-close: 7
stale-issue-label: "stale"
stale-pr-label: "stale"
exempt-issue-labels: "pinned,enhancement,priority:high,ongoing,fatal"
exempt-pr-labels: "pinned,priority:high,ongoing,fatal"
-56
View File
@@ -1,56 +0,0 @@
name: Node.js CI
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
contents: read
jobs:
test:
name: Test
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x, 18.x, 22.x]
steps:
- uses: actions/checkout@v5
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
# Use built-in package manager cache for pnpm
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install pnpm
run: npm install -g pnpm
- name: Install dependencies (pnpm)
run: pnpm install --frozen-lockfile
- name: Test
env:
NCM_API_TEST_LOGIN_COUNTRY_CODE: ${{ secrets.NCM_API_TEST_LOGIN_COUNTRY_CODE }}
NCM_API_TEST_LOGIN_PHONE: ${{ secrets.NCM_API_TEST_LOGIN_PHONE }}
NCM_API_TEST_LOGIN_PASSWORD: ${{ secrets.NCM_API_TEST_LOGIN_PASSWORD }}
run: pnpm test
lint:
name: Lint
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
steps:
- uses: actions/checkout@v5
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install pnpm
run: npm install -g pnpm
- name: Install dependencies (pnpm)
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm run lint
-39
View File
@@ -1,39 +0,0 @@
# .github/workflows/release.yml
name: Release
on:
release:
types: [published]
workflow_dispatch:
jobs:
release:
if: startsWith(github.event.release.tag_name, 'v')
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
packages: write
steps:
- uses: actions/checkout@v6
# 发布到 NPM Registry
- name: Setup Node.js for NPM
uses: actions/setup-node@v6
with:
node-version: 20
registry-url: 'https://registry.npmjs.org/'
- run: npm install
- name: Publish to NPM
run: npm publish --provenance --access public
# 发布到 GitHub Packages
- name: Setup Node.js for GitHub Packages
uses: actions/setup-node@v6
with:
node-version: 20
registry-url: 'https://npm.pkg.github.com'
- name: Publish to GitHub Packages
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ github.token }}
-36
View File
@@ -1,36 +0,0 @@
name: Upstream Sync
permissions:
contents: write
on:
schedule:
- cron: "0 4 * * *" # At 12PM UTC+8
workflow_dispatch:
jobs:
sync_latest_from_upstream:
name: Sync latest commits from upstream repo
runs-on: ubuntu-latest
if: ${{ github.event.repository.fork }}
steps:
# Step 1: run a standard checkout action
- name: Checkout target repo
uses: actions/checkout@v6
# Step 2: run the sync action
- name: Sync upstream changes
id: sync
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.2
with:
upstream_sync_repo: NeteaseCloudMusicApiEnhanced/api-enhanced
upstream_sync_branch: main
target_sync_branch: main
target_repo_token: ${{ secrets.GITHUB_TOKEN }}
- name: Sync check
if: failure()
run: |
echo "[Error] Due to a change in the workflow file of the upstream repository, GitHub has automatically suspended the scheduled automatic update. You need to manually sync your fork."
exit 1
-2
View File
@@ -8,5 +8,3 @@ examples/moddef.json
bin
anonymous_token
.vercel
.env
IFLOW.md
+1 -1
View File
@@ -1,4 +1,4 @@
language: node_js
node_js:
- 22
- 12
-28
View File
@@ -1,32 +1,4 @@
# 更新日志
### 4.30.2 | 2026.02.22
- feat(cloud): 添加云盘上传模式选择功能并优化上传配置 (#116)
- refactor: Refactor random Chinese IP generator (#110)
- fix(server): Correctly inject client IP into request parameters (#108)
- refactor(ui): More elegant display of URL and debug buttons (#109)
- feat: 添加评论统计数据 (#122)
### 4.30.1 | 2026.02.11
- feat: Add user playlist endpoints & domain overrides (#105)
### 4.30.0 | 2026.02.06
- feat: 新增音乐人黑胶会员任务接口 `/musician/vip/tasks` (#95)
- feat: 自动构建: 添加Windows、Linux、macOS预编译二进制文件 (#88)
- fix: 修复模块未定义问题
- chore: 更新依赖项 (music-metadata: ^11.11.1 -> ^11.11.2, ansi-escapes: ^7.2.0 -> ^7.3.0, commander: ^14.0.2 -> ^14.0.3)
- chore: 更新GitHub Actions (checkout: v4 -> v6, setup-node: v4 -> v6, upload-artifact: v4 -> v6, download-artifact: v4 -> v7, github-script: v7 -> v8)
- refactor: 注释掉IP地址日志输出以提升隐私保护
- refactor: 重构前端测试页面, 主要改进:
- 统一使用简洁现代的设计风格
- 去除渐变背景和复杂动画
- 使用纯色背景(#f5f5f5)和白色卡片
- 优化表单布局和交互体验
- 增强错误处理和加载状态
- 移除第三方框架依赖(Tailwind、Bootstrap、MDUI)
- 升级Vue 2到Vue 3
- 将硬编码的配置项移至前端表单
- 所有页面现在都保持一致的设计语言,简洁清爽,功能完整。
### 4.25.0 | 2024.11.16
- feat: 增加副歌时间、相关歌单推荐接口,原有相关歌单接口已废弃;fix: 将部分易盾白名单接口替换为eapi [#30](https://gitlab.com/Binaryify/neteasecloudmusicapi/-/merge_requests/30)
- fix: 播客上传接口 [#32](https://gitlab.com/Binaryify/neteasecloudmusicapi/-/merge_requests/32)
+495 -173
View File
@@ -1,90 +1,76 @@
<p align="center">
<img src="./public/static/neteaselogo.png" alt="网易云音乐 API Enhanced Logo" />
# 网易云音乐 API
网易云音乐 Node.js API service
<p>
<a href="https://www.npmjs.com/package/NeteaseCloudMusicApi"><img src="https://img.shields.io/npm/v/NeteaseCloudMusicApi.svg" alt="Version"></a>
<a href="https://www.npmjs.com/package/NeteaseCloudMusicApi"><img src="https://img.shields.io/npm/l/NeteaseCloudMusicApi.svg" alt="License"></a>
<a href="https://www.npmjs.com/package/NeteaseCloudMusicApi"><img src="https://img.shields.io/david/dev/binaryify/NeteaseCloudMusicApi.svg" alt="devDependencies" ></a>
<a href="https://www.npmjs.com/package/NeteaseCloudMusicApi"><img src="https://img.shields.io/david/binaryify/NeteaseCloudMusicApi.svg" alt="devDependencies" ></a>
<a href="https://codeclimate.com/github/Binaryify/NeteaseCloudMusicApi"><img src="https://codeclimate.com/github/Binaryify/NeteaseCloudMusicApi/badges/gpa.svg" /></a>
</p>
<p align="center">
<a href="https://www.npmjs.com/package/@neteasecloudmusicapienhanced/api">
<img src="https://img.shields.io/npm/v/@neteasecloudmusicapienhanced/api?style=flat-square" alt="NPM Version" />
</a>
<a href="https://www.npmjs.com/package/@neteasecloudmusicapienhanced/api">
<img src="https://img.shields.io/npm/dm/@neteasecloudmusicapienhanced/api?style=flat-square" alt="NPM Downloads" />
</a>
<a href="https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced">
<img src="https://img.shields.io/github/license/NeteaseCloudMusicApiEnhanced/api-enhanced?style=flat-square" alt="License" />
</a>
<a href="https://nodejs.org/">
<img src="https://img.shields.io/node/v/@neteasecloudmusicapienhanced/api?style=flat-square" alt="Node Version" />
</a>
<a href="https://hub.docker.com/r/moefurina/ncm-api">
<img src="https://img.shields.io/docker/v/moefurina/ncm-api?style=flat-square" alt="Docker Version" />
</a>
<a href="https://hub.docker.com/r/moefurina/ncm-api">
<img src="https://img.shields.io/docker/pulls/moefurina/ncm-api?style=flat-square" alt="Docker Pulls" />
</a>
<a href="https://www.typescriptlang.org/">
<img src="https://img.shields.io/badge/TypeScript-Ready-blue?style=flat-square&logo=typescript" alt="TypeScript" />
</a>
</p>
## 灵感来自
[disoul/electron-cloud-music](https://github.com/disoul/electron-cloud-music)
[darknessomi/musicbox](https://github.com/darknessomi/musicbox)
## 项目简介
[sqaiyan/netmusic-node](https://github.com/sqaiyan/netmusic-node)
网易云音乐第三方 Node.js API, 支持丰富的音乐相关接口,适合自建服务、二次开发和多平台部署(如果原版诈尸, 我会及时同步 or 归档)。
[greats3an/pyncm](https://github.com/greats3an/pyncm)
> [!IMPORTANT]
>
> ## 注意
>
> - 本项目是自由项目, 不带任何担保, 在基于`MIT License`的许可下, 任何人都可以自由使用、修改和分发本项目的代码, 包括用于商业目的.
> - 该项目是个合作计划, 有很多人参与了此次贡献, 前往 [Contributors](https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced/graphs/contributors) 页面查看所有贡献者
> - 原作者项目 [Binaryify/NeteaseCloudMusicApi](https://github.com/binaryify/NeteaseCloudMusicApi) 并非完全停止维护, 你可以在 [NeteaseCloudMusicApi 的 NPMJS 页面](https://www.npmjs.com/package/NeteaseCloudMusicApi) 查看最新版本信息。
> - 感谢尊重和理解!
## 环境要求
## 快速开始
需要 NodeJS 18+ 环境
### 环境要求
## 安装
- Node.js 18 及以上
- 推荐使用 pnpm 进行依赖管理
### 安装
```bash
git clone https://github.com/neteasecloudmusicapienhanced/api-enhanced.git
cd api
pnpm i
```shell
$ git clone git@gitlab.com:Binaryify/NeteaseCloudMusicApi.git
$ cd NeteaseCloudMusicApi
$ npm install
```
### 启动服务
或者
```bash
# 默认端口 3000
node app.js
# 指定端口(如 4000)
PORT=4000 node app.js # Mac/Linux
set PORT=4000 && node app.js # Windows
```shell
$ git clone https://gitlab.com/Binaryify/NeteaseCloudMusicApi.git
$ cd NeteaseCloudMusicApi
$ npm install
```
### 重要提示
## 运行
- 调用前请务必阅读文档的 [调用前须知](https://neteasecloudmusicapienhanced.js.org/#/?id=%e8%b0%83%e7%94%a8%e5%89%8d%e9%a1%bb%e7%9f%a5) 部分。
- 推荐将敏感信息(如 cookie)通过部署平台的环境变量进行配置。
调用前务必阅读文档的`调用前须知`
## 在线体验与文档
```shell
$ node app.js
```
- [在线文档](https://neteasecloudmusicapienhanced.js.org/)
- [NPM 包地址](https://www.npmjs.com/package/@neteasecloudmusicapienhanced/api)
- [Dockerhub 主页](https://hub.docker.com/r/moefurina/ncm-api)
服务器启动默认端口为 3000,若不想使用 3000 端口,可使用以下命令: Mac/Linux
## 常见部署方式
```shell
$ PORT=4000 node app.js
```
- 推荐参考[在线文档](https://neteasecloudmusicapienhanced.js.org/)
windows 下使用 git-bash 或者 cmder 等终端执行以下命令:
## Docker 部署说明
```shell
$ set PORT=4000 && node app.js
```
本项目支持通过 Docker 一键部署,无需手动安装 Node.js 或依赖。
## npx 方式运行
可在终端直接执行
```bash
npx NeteaseCloudMusicApi@latest
```
使用此命令,可直接启动服务,无需下载或者 clone 项目
## Docker 容器运行
> 注意: 在 docker 中运行的时候, 由于使用了 request 来发请求, 所以会检查几个 proxy 相关的环境变量(如下所列), 这些环境变量 会影响到 request 的代理, 详情请参考[request 的文档](https://github.com/request/request#proxies), 如果这些环境变量 指向的代理不可用, 那么就会造成错误, 所以在使用 docker 的时候一定要注意这些环境变量. 不过, 要是你在 query 中加上了 proxy 参数, 那么环境变量会被覆盖, 就会用你通过 proxy 参数提供的代理了.
@@ -98,151 +84,487 @@ request 相关的环境变量
6. NO_PROXY
```shell
docker pull moefurina/ncm-api:latest
docker pull binaryify/netease_cloud_music_api
docker run -d -p 3000:3000 --name ncm-api moefurina/ncm-api:latest
docker run -d -p 3000:3000 --name netease_cloud_music_api binaryify/netease_cloud_music_api
// 或者
docker run -d -p 3000:3000 moefurina/ncm-api:latest
docker run -d -p 3000:3000 binaryify/netease_cloud_music_api
// 去掉或者设置相关的环境变量
docker run -d -p 3000:3000 --name ncm-api -e http_proxy= -e https_proxy= -e no_proxy= -e HTTP_PROXY= -e HTTPS_PROXY= -e NO_PROXY= moefurina/ncm-api:latest
docker run -d -p 3000:3000 --name netease_cloud_music_api -e http_proxy= -e https_proxy= -e no_proxy= -e HTTP_PROXY= -e HTTPS_PROXY= -e NO_PROXY= binaryify/netease_cloud_music_api
// 或者
docker run -d -p 3000:3000 -e http_proxy= -e https_proxy= -e no_proxy= -e HTTP_PROXY= -e HTTPS_PROXY= -e NO_PROXY= moefurina/ncm-api:latest
docker run -d -p 3000:3000 -e http_proxy= -e https_proxy= -e no_proxy= -e HTTP_PROXY= -e HTTPS_PROXY= -e NO_PROXY= binaryify/netease_cloud_music_api
```
> 以下是自行 build docker 镜像方式
```shell
$ git clone https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced && cd api-enhanced
```
$ git clone https://gitlab.com/Binaryify/neteasecloudmusicapi.git && cd NeteaseCloudMusicApi
$ sudo docker build . -t ncm-api
$ sudo docker build . -t netease-music-api
$ sudo docker run -d -p 3000:3000 ncm-api
$ sudo docker run -d -p 3000:3000 netease-music-api
## Vercel 部署
v4.0.8 加入了 Vercel 配置文件,可以直接在 Vercel 下部署了,不需要自己的服务器
### 操作方法
1. fork 此项目
2. 在 Vercel 官网点击 `New Project`
3. 点击 `Import Git Repository` 并选择你 fork 的此项目并点击`import`
4. 点击 `PERSONAL ACCOUNT` 的 `select`
5. 直接点`Continue`
6. `PROJECT NAME`自己填,`FRAMEWORK PRESET` 选 `Other` 然后直接点 `Deploy` 接着等部署完成即可
## 腾讯云 serverless 部署
因 `Vercel` 在国内访问太慢,在此提供腾讯云 serverless 部署方法(注意:腾讯云 serverless 并不是免费的,前三个月有免费额度,之后收费)
### 操作方法
1. fork 此项目
2. 在腾讯云 serverless 应用管理页面( https://console.cloud.tencent.com/sls ),点击`新建应用`
3. 顶部`创建方式`选择 `Web 应用`
4. 选择 `Express框架`,点击底部`下一步按钮`
5. 输入`应用名`,上传方式选择`代码仓库`,进行 GitHub 授权(如已授权可跳过这一步),代码仓库选择刚刚 fork 的项目
6. 启动文件填入:
```
#!/bin/bash
export PORT=9000
/var/lang/node16/bin/node app.js
```
## 3. 环境变量
7. 点击`完成`,等待部署完成,点击`资源列表`的 `API网关` 里的 `URL`,正常情况会打开文档地址,点击文档`例子`可查看接口调用效果
| 变量名 | 默认值 | 说明 |
|----------------------------|--------------------------------------|----------------------------------------------------|
| **CORS_ALLOW_ORIGIN** | `*` | 允许跨域请求的域名。可填写单个源,或使用逗号分隔多个源(例如 `https://a.com,https://b.com`)。 |
| **ENABLE_PROXY** | `false` | 是否启用反向代理功能。 |
| **PROXY_URL** | `https://your-proxy-url.com/?proxy=` | 代理服务地址。仅当 `ENABLE_PROXY=true` 时生效。 |
| **ENABLE_GENERAL_UNBLOCK** | `true` | 是否启用全局解灰(推荐开启)。开启后所有歌曲都尝试自动解锁。 |
| **ENABLE_FLAC** | `true` | 是否启用无损音质(FLAC)。 |
| **SELECT_MAX_BR** | `false` | 启用无损音质时,是否选择最高码率音质。 |
| **FOLLOW_SOURCE_ORDER** | `true` | 是否严格按照音源列表顺序进行匹配。 |
## 可以在 Node.js 调用
---
### 4. 更新镜像
更新到最新版本:
```bash
docker pull moefurina/ncm-api:latest
docker stop ncm-api && docker rm ncm-api
docker run -d -p 3000:3000 moefurina/ncm-api:latest
```
## Vercel 一键部署
1. fork 本项目
2. 在 Vercel 官网新建项目,导入 fork 的仓库
3. 直接 Deploy
## 腾讯云 Serverless 部署
1. fork 本项目
2. 在腾讯云 serverless 控制台新建 Web 应用,选择 Express 框架
3. 代码仓库选择 fork 的项目,启动文件填写:
```bash
#!/bin/bash
export PORT=9000
/var/lang/node16/bin/node app.js
```
4. 完成部署后,访问 API 网关的 URL 即可
## Node.js 方式调用
支持直接在 Node.js 项目中引入和调用,返回 Promise:
v3.31.0 后支持 Node.js 调用,导入的方法为`module`内的文件名,返回内容包含`status`和`body`,`status`为状态码,`body`为请求返回内容,参考`module_example` 文件夹下的 `test.js`
```js
const {
login_cellphone,
user_cloud,
} = require('@neteasecloudmusicapienhanced/api')
const { login_cellphone, user_cloud } = require('NeteaseCloudMusicApi')
async function main() {
const result = await login_cellphone({ phone: '手机号', password: '密码' })
console.log(result)
const result2 = await user_cloud({ cookie: result.body.cookie })
console.log(result2.body)
try {
const result = await login_cellphone({
phone: '手机号',
password: '密码',
})
console.log(result)
const result2 = await user_cloud({
cookie: result.body.cookie, // 凭证
})
console.log(result2.body)
} catch (error) {
console.log(error)
}
}
main()
```
## TypeScript 支持
## 支持 TypeScript
```ts
import { banner } from '@neteasecloudmusicapienhanced/api'
banner({ type: 0 }).then((res) => console.log(res))
// test.ts
import { banner } from 'NeteaseCloudMusicApi'
banner({ type: 0 }).then((res) => {
console.log(res)
})
```
## 使用文档
[文档地址](https://docs-neteasecloudmusicapi.vercel.app)
## 调试工具
- `eapi` 请求参数或返回内容可在 `/eapi_decrypt.html` 里解析
- 请求参数模式下, 解密结果可直接带到 `/api.html` 继续调试
- 需要返回值加密时, 可传 `e_r=1`, `weapi` 和 `eapi` 都支持
## 功能特性
1. 登录
2. 刷新登录
3. 发送验证码
4. 校验验证码
5. 注册(修改密码)
6. 获取用户信息 , 歌单,收藏,mv, dj 数量
7. 获取用户歌单
8. 获取用户电台
9. 获取用户关注列表
10. 获取用户粉丝列表
11. 获取用户动态
12. 获取用户播放记录
13. 获取精品歌单
14. 获取歌单详情
15. 搜索
16. 搜索建议
17. 获取歌词
18. 歌曲评论
19. 收藏单曲到歌单
20. 专辑评论
21. 歌单评论
22. mv 评论
23. 电台节目评论
24. banner
25. 获取歌曲详情
26. 获取专辑内容
27. 获取歌手单曲
28. 获取歌手 mv
29. 获取歌手专辑
30. 获取歌手描述
31. 获取相似歌手
32. 获取相似歌单
33. 相似 mv
34. 获取相似音乐
35. 获取最近 5 个听了这首歌的用户
36. 获取每日推荐歌单
37. 获取每日推荐歌曲
38. 私人 FM
39. 签到
40. 喜欢音乐
41. 垃圾桶
42. 歌单 ( 网友精选碟 )
43. 新碟上架
44. 热门歌手
45. 最新 mv
46. 推荐 mv
47. 推荐歌单
48. 推荐新音乐
49. 推荐电台
50. 推荐节目
51. 独家放送
52. mv 排行
53. 获取 mv 数据
54. 播放 mv/视频
55. 排行榜
56. 歌手榜
57. 云盘
58. 电台 - 推荐
59. 电台 - 分类
60. 电台 - 分类推荐
61. 电台 - 订阅
62. 电台 - 详情
63. 电台 - 节目
64. 给评论点赞
65. 获取动态列表
66. 热搜列表(简略)
67. 发送私信
68. 发送私信歌单
69. 新建歌单
70. 收藏/取消收藏歌单
71. 歌单分类
72. 收藏的歌手列表
73. 订阅的电台列表
74. 相关歌单
75. 付费精选接口
76. 音乐是否可用检查接口
77. 登录状态
78. 获取视频播放地址
79. 发送/删除评论
80. 热门评论
81. 视频评论
82. 退出登录
83. 所有榜单
84. 所有榜单内容摘要
85. 收藏视频
86. 收藏 MV
87. 视频详情
88. 相关视频
89. 关注用户
90. 新歌速递
91. 喜欢音乐列表(无序)
92. 收藏的 MV 列表
93. 获取最新专辑
94. 听歌打卡
95. 获取视频标签/分类下的视频
96. 已收藏专辑列表
97. 获取动态评论
98. 歌单收藏者列表
99. 云盘歌曲删除
100. 热门话题
101. 电台 - 推荐类型
102. 电台 - 非热门类型
103. 电台 - 今日优选
104. 心动模式/智能播放
105. 转发动态
106. 删除动态
107. 分享歌曲、歌单、mv、电台、电台节目到动态
108. 通知-私信
109. 通知-评论
110. 通知-@我
111. 通知-通知
112. 设置
113. 云盘数据详情
114. 私信内容
115. 我的数字专辑
116. batch 批量请求接口
117. 获取视频标签列表
118. 全部 mv
119. 网易出品 mv
120. 收藏/取消收藏专辑
121. 专辑动态信息
122. 热搜列表(详细)
123. 更换绑定手机
124. 检测手机号码是否已注册
125. 初始化昵称
126. 更新歌单描述
127. 更新歌单名
128. 更新歌单标签
129. 默认搜索关键词
130. 删除歌单
131. 电台 banner
132. 用户电台
133. 热门电台
134. 电台 - 节目详情
135. 电台 - 节目榜
136. 电台 - 新晋电台榜/热门电台榜
137. 类别热门电台
138. 云村热评(官方下架,暂不能用)
139. 电台 24 小时节目榜
140. 电台 24 小时主播榜
141. 电台最热主播榜
142. 电台主播新人榜
143. 电台付费精品榜
144. 歌手热门 50 首歌曲
145. 购买数字专辑
146. 获取 mv 点赞转发评论数数据
147. 获取视频点赞转发评论数数据
148. 调整歌单顺序
149. 调整歌曲顺序
150. 独家放送列表
151. 获取推荐视频
152. 获取视频分类列表
153. 获取全部视频列表接口
154. 获取历史日推可用日期列表
155. 获取历史日推详细数据
156. 国家编码列表
157. 首页-发现
158. 首页-发现-圆形图标入口列表
159. 全部新碟
160. 数字专辑-新碟上架
161. 数字专辑&数字单曲-榜单
162. 数字专辑-语种风格馆
163. 数字专辑详情
164. 更新头像
165. 歌单封面上传
166. 楼层评论
167. 歌手全部歌曲
168. 精品歌单标签列表
169. 用户等级信息
170. 电台个性推荐
171. 用户绑定信息
172. 用户绑定手机
173. 新版评论
174. 点赞过的视频
175. 收藏视频到视频歌单
176. 删除视频歌单里的视频
177. 最近播放的视频
178. 音乐日历
179. 电台订阅者列表
180. 云贝签到信息
181. 云贝签到
182. 云贝所有任务
183. 云贝 todo 任务
184. 云贝今日签到信息
185. 云贝完成任务
186. 云贝收入
187. 云贝支出
188. 云贝账户信息
189. 账号信息
190. 最近联系人
191. 私信音乐
192. 抱一抱评论
193. 评论抱一抱列表
194. 收藏的专栏
195. 关注歌手新歌
196. 关注歌手新 MV
197. 歌手详情
198. 云盘上传
199. 二维码登录
200. 话题详情
201. 话题详情热门动态
202. 歌单详情动态
203. 绑定手机
204. 一起听状态
205. 用户历史评论
206. 云盘歌曲信息匹配纠正
207. 云贝推歌
208. 云贝推歌历史记录
209. 已购单曲
210. 获取 mlog 播放地址
211. 将 mlog id 转为视频 id
212. vip 成长值
213. vip 成长值获取记录
214. vip 任务
215. 领取 vip 成长值
216. 歌手粉丝
217. 数字专辑详情
218. 数字专辑销量
219. 音乐人数据概况
220. 音乐人播放趋势
221. 音乐人任务
222. 账号云豆数
223. 领取云豆
224. 获取 VIP 信息
225. 音乐人签到
226. 获取客户端歌曲下载 url
227. 获取歌单所有歌曲
228. 乐签信息
229. 获取歌手视频
230. 最近播放-歌曲
231. 最近播放-视频
232. 最近播放-声音
233. 最近播放-歌单
234. 最近播放-专辑
235. 最近播放-播客
236. 签到进度
237. 重复昵称检测
238. 歌手粉丝数量
239. 音乐人任务(新)
240. 内部版本接口
241. 歌单更新播放量
242. 黑胶时光机
243. 音乐百科 - 简要信息
244. 乐谱列表
245. 乐谱内容
246. 曲风列表
247. 曲风偏好
248. 曲风详情
249. 曲风-歌曲
250. 曲风-专辑
251. 曲风-歌单
252. 曲风-歌手
253. 私信和通知接口
254. 回忆坐标
255. 播客搜索
256. 播客声音上传
257. 验证接口-二维码生成
258. 验证接口-二维码检测
259. 听歌识曲
260. 根据nickname获取userid接口
261. 播客声音列表
262. 专辑简要百科信息
263. 歌曲简要百科信息
264. 歌手简要百科信息
265. mv简要百科信息
266. 搜索歌手
267. 用户贡献内容
268. 用户贡献条目、积分、云贝数量
269. 年度听歌报告
270. 播客声音搜索
271. 播客声音排序
272. 播客列表详情
273. 本地歌曲文件匹配网易云歌曲信息
274. 歌曲音质详情
275. 歌曲红心数量
276. 私人 FM 模式选择
277. 播客删除
278. 电台排行榜获取
279. 获取声音歌词
280. 获取专辑歌曲的音质
281. 歌手动态信息
282. 最近听歌列表
283. 云盘导入歌曲
284. 获取客户端歌曲下载链接 - 新版
285. 当前账号关注的用户/歌手
286. 会员下载歌曲记录
287. 会员本月下载歌曲记录
288. 已购买单曲
289. 歌曲是否喜爱
290. 用户是否互相关注
291. 歌曲动态封面
292. 用户徽章
293. 用户状态
294. 用户状态 - 支持设置的状态
295. 用户状态 - 相同状态的用户
296. 用户状态 - 编辑
297. 听歌足迹 - 年度听歌足迹
298. 听歌足迹 - 今日收听
299. 听歌足迹 - 总收听时长
300. 听歌足迹 - 本周/本月收听时长
301. 听歌足迹 - 周/月/年收听报告
302. 歌单导入 - 元数据/文字/链接导入
303. 歌单导入 - 任务状态
304. 副歌时间
305. 相关歌单推荐
306. 歌词摘录 - 歌词摘录信息
307. 歌词摘录 - 我的歌词本
308. 歌词摘录 - 添加/修改摘录歌词
309. 歌词摘录 - 删除摘录歌词
310. 广播电台 - 分类/地区信息
311. 广播电台 - 我的收藏
312. 广播电台 - 电台信息
313. 广播电台 - 全部电台
314. 广播电台 - 收藏/取消收藏电台
315. 用户的创建歌单列表
316. 用户的收藏歌单列表
317. DIFM电台 - 分类
318. DIFM电台 - 收藏列表
319. DIFM电台 - 收藏频道
320. DIFM电台 - 取消收藏频道
321. DIFM电台 - 播放列表
322. 助眠解压 - 特定时间场景下的推荐资源
323. 助眠解压 - 标签列表
324. 助眠解压 - 获取标签下资源列表
325. 助眠解压 - 查看同类推荐
326. 助眠解压 - 收藏
327. 助眠解压 - 收藏列表
328. 跑步漫游
329. 歌曲创作者信息
330. 获取用户详情
331. 更新用户信息
332. 更新歌单
333. 歌手分类列表
334. 收藏/取消收藏歌手
335. 获取音乐 url
336. 获取音乐 url - 新版
337. 搜索多重匹配
338. 对歌单添加或删除歌曲
339. 获取逐字歌词
340. 资源点赞( MV,电台,视频)
341. 每日推荐歌曲-不感兴趣
342. mv 地址
343. 排行榜详情
344. 电台 - 付费精品
345. 电台 - 付费精选
346. 发送私信(带专辑)
347. 云贝
348. 一起听相关
349. 获取 VIP 信息(app端)
350. 公开隐私歌单
351. 云村星评馆 - 简要评论
352. 私人 DJ
353. 播客列表
354. 播客声音详情
355. 多级行政区划数据
356. 指定维度音乐排行榜详情
357. 指定维度音乐排行榜列表
## 单元测试
```bash
pnpm test
```shell
$ npm test
```
## 主要功能特性
## SDK
- 登录/注册/验证码
- 用户信息、歌单、动态、播放记录
- 歌曲、专辑、歌手、MV、歌词、评论、排行榜
- 搜索、推荐、私人 FM、签到、云盘
- 歌曲解锁(解灰)
| 语言 | 作者 | 地址 | 类型 |
| :----: | :-----------------------------------------: | :--------------------------------------------------------------------------------------------------------: | :----: |
| Java | [JackuXL](https://github.com/JackuXL) | [https://github.com/JackuXL/NeteaseCloudMusicApi-SDK](https://github.com/JackuXL/NeteaseCloudMusicApi-SDK) | 第三方 |
| Java | [1015770492](https://github.com/1015770492) | https://github.com/1015770492/yumbo-music-utils | 第三方 |
| Python | [盧瞳](https://github.com/2061360308) | [NeteaseCloudMusic_PythonSDK](https://github.com/2061360308/NeteaseCloudMusic_PythonSDK) | 第三方 |
## 贡献与社区
## 贡献者
- 欢迎提交 PR、Issue 参与维护
- 要贡献新接口, 请参考[这篇文章](https://www.focalors.ltd/post/how-to-contribute-ncm-api)
### 致谢
原作者 [Binaryify/NeteaseCloudMusicApi](https://github.com/binaryify/NeteaseCloudMusicApi) 项目为本项目基础 (该项目在`npmjs`网站上仍持续维护, 但 github 仓库已不再更新)
感谢大佬们为逆向eapi, weapi等加密算法所做的贡献
项目参考:
[trazyn/ieaseMusic](https://github.com/trazyn/ieaseMusic)
[listen1/listen1_chrome_extension](https://github.com/listen1/listen1_chrome_extension)
[chaunsin/netease-cloud-music](https://github.com/chaunsin/netease-cloud-music)
### SDK 生态
| 语言 | 作者 | 地址 | 类型 |
|--------|---------------------------------------------|------------------------------------------------------------------------------------------|-----|
| Java | [JackuXL](https://github.com/JackuXL) | [NeteaseCloudMusicApi-SDK](https://github.com/JackuXL/NeteaseCloudMusicApi-SDK) | 第三方 |
| Java | [1015770492](https://github.com/1015770492) | [yumbo-music-utils](https://github.com/1015770492/yumbo-music-utils) | 第三方 |
| Python | [盧瞳](https://github.com/2061360308) | [NeteaseCloudMusic_PythonSDK](https://github.com/2061360308/NeteaseCloudMusic_PythonSDK) | 第三方 |
| Swift | [Lincb522](https://github.com/Lincb522) | [NeteaseCloudMusicApi-Swift](https://github.com/Lincb522/NeteaseCloudMusicAPI-Swift) | 第三方 |
| Rust | [SPlayer-Dev](https://github.com/splayer-dev) | [ncm-api-rs](https://github.com/SPlayer-Dev/ncm-api-rs) | 第三方 |
### 依赖此项目的优秀开源项目
- [qier2222/YesPlayMusic](https://github.com/qier222/YesPlayMusic)
- [MaigoLabs/amaoke.app](https://github.com/MaigoLabs/amaoke.app)
- [Yueby/music-together](https://github.com/Yueby/music-together)
![](https://opencollective.com/NeteaseCloudMusicApi/contributors.svg?width=890)
## License
[MIT License](https://github.com/MoeFurina/NeteaseCloudMusicApiEnhanced/blob/main/LICENSE)
[The MIT License (MIT)](https://gitlab.com/Binaryify/NeteaseCloudMusicApi/blob/main/LICENSE)
File diff suppressed because it is too large Load Diff
-91
View File
@@ -1,91 +0,0 @@
const { defineConfig, globalIgnores } = require('eslint/config')
const html = require('eslint-plugin-html')
const globals = require('globals')
const tsParser = require('@typescript-eslint/parser')
const js = require('@eslint/js')
const { FlatCompat } = require('@eslint/eslintrc')
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
})
module.exports = defineConfig([
{
languageOptions: {
ecmaVersion: 2020,
sourceType: 'module',
parserOptions: {
parser: 'babel-eslint',
},
globals: {
...globals.browser,
...globals.node,
},
},
plugins: {
html,
},
extends: compat.extends('plugin:prettier/recommended'),
rules: {
'prettier/prettier': [
'error',
{
endOfLine: 'auto',
},
],
indent: [
'error',
2,
{
SwitchCase: 1,
},
],
'space-infix-ops': [
'error',
{
int32Hint: false,
},
],
'key-spacing': [
2,
{
beforeColon: false,
afterColon: true,
},
],
'no-octal': 2,
'no-redeclare': 2,
'comma-spacing': 2,
'no-new-object': 2,
'arrow-spacing': 2,
quotes: [
2,
'single',
{
avoidEscape: true,
allowTemplateLiterals: true,
},
],
},
},
globalIgnores(['**/public/']),
{
files: ['**/*.ts'],
languageOptions: {
parser: tsParser,
},
extends: compat.extends('plugin:@typescript-eslint/recommended'),
},
])
+1 -2
View File
@@ -1,7 +1,6 @@
const fsPromises = require('fs/promises')
const path = require('path')
const server = require('../server')
const logger = require('../util/logger.js')
const exportFile = path.join(__dirname, 'moddef.json')
@@ -17,7 +16,7 @@ async function main() {
)
fsPromises.writeFile(exportFile, JSON.stringify(def, null, 4))
logger.info(`👍 Get your own definition at: ${exportFile}`)
console.log(`👍 Get your own definition at: ${exportFile}`)
}
main()
-1
View File
@@ -1 +0,0 @@
import './app.js'
-10
View File
@@ -2,7 +2,6 @@ export interface RequestBaseConfig {
cookie?: string
realIP?: string // IPv4/IPv6 filled in X-Real-IP
proxy?: string // HTTP proxy
randomCNIP?: boolean // Whether to use a random Chinese IP address filled in X-Real-IP
}
export interface MultiPageConfig {
@@ -1166,13 +1165,6 @@ export function song_url_v1(
params: { id: string | number; level: SoundQualityType } & RequestBaseConfig,
): Promise<Response>
export function song_copyright_rcmd(
params: {
songid?: string | number
id?: string | number
} & RequestBaseConfig,
): Promise<Response>
export function top_album(
params: {
area?: AlbumListArea
@@ -1722,8 +1714,6 @@ export function nickname_check(
export function musician_tasks_new(params: RequestBaseConfig): Promise<Response>
export function musician_vip_tasks(params: RequestBaseConfig): Promise<Response>
export function playlist_update_playcount(
params: {
id?: number | string
+29
View File
@@ -0,0 +1,29 @@
## 环境
- 系统/平台: <你的系统和平台>
- nodejs 版本: <你的 NodeJS 版本号>
- API版本:<运行的云音乐 API 的版本号, 对应 package.json 里面的 version>
## 出现问题
<出现的问题>
## 重现步骤
<重现步骤>
## 期待效果
<现在的效果,期待的效果>
>先看文档有没有相关说明,调用前须知必看
>先在 issues 搜一下是否有相似问题,没有再发,否则直接关闭
>不处理别人搭建的线上服务的问题,此项目不提供任何线上服务,请自行搭建
>重现步骤尽量详细,不能含糊不清,包含请求地址和对应参数以及操作过程描述,不是每个人都喜欢猜别人遇到了什么问题和找参数一个个试,也比较浪费时间
>如果不是提建议,提 issues 如果不照着模版来将不会优先处理或直接关闭
>460 cheating 的问题把 `util/request.js` 里面的 `headers['X-Real-IP']` 的注释取消掉就好
+1 -2
View File
@@ -4,7 +4,6 @@
// 可按需修改此 API 的代码
/* {"extInfo":"{\"lastRequestTimestamp\":1692358373509,\"lbsInfoList\":[{\"lat\":40.23076381,\"lon\":129.07545186,\"time\":1692358543},{\"lat\":40.23076381,\"lon\":129.07545186,\"time\":1692055283}],\"listenedTs\":false,\"noAidjToAidj\":true}","header":"{}"} */
const logger = require('../util/logger.js')
const createOption = require('../util/option.js')
module.exports = (query, request) => {
var extInfo = {}
@@ -23,6 +22,6 @@ module.exports = (query, request) => {
const data = {
extInfo: JSON.stringify(extInfo),
}
// logger.info(data)
// console.log(data)
return request(`/api/aidj/content/rcmd/info`, data, createOption(query))
}
+11
View File
@@ -0,0 +1,11 @@
// 获取指定维度音乐排行榜详情
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
chartCode: query.chartCode,
targetId: query.targetId,
targetType: query.targetType,
}
return request(`/api/chart/detail`, data, createOption(query))
}
+11
View File
@@ -0,0 +1,11 @@
// 获取指定维度音乐排行榜列表
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
chartCode: query.chartCode,
targetId: query.targetId,
targetType: query.targetType,
}
return request(`/api/chart/song/detail`, data, createOption(query))
}
+120 -139
View File
@@ -1,26 +1,23 @@
const mm = require('music-metadata')
const uploadPlugin = require('../plugins/songUpload')
const md5 = require('md5')
const createOption = require('../util/option.js')
const logger = require('../util/logger.js')
const {
isTempFile,
getFileSize,
getFileMd5,
cleanupTempFile,
getFileExtension,
sanitizeFilename,
} = require('../util/fileHelper')
let mm
module.exports = async (query, request) => {
mm = require('music-metadata')
let ext = 'mp3'
// if (query.songFile.name.indexOf('flac') > -1) {
// ext = 'flac'
// }
if (query.songFile.name.includes('.')) {
ext = query.songFile.name.split('.').pop()
}
query.songFile.name = Buffer.from(query.songFile.name, 'latin1').toString(
'utf-8',
)
const ext = getFileExtension(query.songFile.name)
const filename = sanitizeFilename(query.songFile.name)
const filename = query.songFile.name
.replace('.' + ext, '')
.replace(/\s/g, '')
.replace(/\./g, '_')
const bitrate = 999000
if (!query.songFile) {
return Promise.reject({
status: 500,
@@ -30,135 +27,119 @@ module.exports = async (query, request) => {
},
})
}
const useTemp = isTempFile(query.songFile)
let fileSize = await getFileSize(query.songFile)
let fileMd5 = await getFileMd5(query.songFile)
query.songFile.md5 = fileMd5
query.songFile.size = fileSize
if (!query.songFile.md5) {
// 命令行上传没有md5和size信息,需要填充
query.songFile.md5 = md5(query.songFile.data)
query.songFile.size = query.songFile.data.byteLength
}
const res = await request(
`/api/cloud/upload/check`,
{
bitrate: String(bitrate),
ext: '',
length: query.songFile.size,
md5: query.songFile.md5,
songId: '0',
version: 1,
},
createOption(query),
)
let artist = ''
let album = ''
let songName = ''
try {
const res = await request(
`/api/cloud/upload/check`,
{
bitrate: String(bitrate),
ext: '',
length: fileSize,
md5: fileMd5,
songId: '0',
version: 1,
},
createOption(query),
const metadata = await mm.parseBuffer(
query.songFile.data,
query.songFile.mimetype,
)
const info = metadata.common
let artist = ''
let album = ''
let songName = ''
try {
let metadata
if (useTemp) {
metadata = await mm.parseFile(query.songFile.tempFilePath)
} else {
metadata = await mm.parseBuffer(
query.songFile.data,
query.songFile.mimetype,
)
}
const info = metadata.common
if (info.title) songName = info.title
if (info.album) album = info.album
if (info.artist) artist = info.artist
} catch (error) {
logger.info('元数据解析错误:', error.message)
if (info.title) {
songName = info.title
}
const tokenRes = await request(
`/api/nos/token/alloc`,
{
bucket: '',
ext: ext,
filename: filename,
local: false,
nos_product: 3,
type: 'audio',
md5: fileMd5,
},
createOption(query),
)
if (!tokenRes.body.result || !tokenRes.body.result.resourceId) {
logger.error('Token分配失败:', tokenRes.body)
return Promise.reject({
status: 500,
body: {
code: 500,
msg: '获取上传token失败',
detail: tokenRes.body,
},
})
if (info.album) {
album = info.album
}
if (res.body.needUpload) {
logger.info('需要上传,开始上传流程...')
try {
const uploadInfo = await uploadPlugin(query, request)
logger.info('上传完成:', uploadInfo?.body?.result?.resourceId)
} catch (uploadError) {
logger.error('上传失败:', uploadError)
return Promise.reject(uploadError)
}
} else {
logger.info('文件已存在,跳过上传')
if (info.artist) {
artist = info.artist
}
// if (metadata.native.ID3v1) {
// metadata.native.ID3v1.forEach((item) => {
// // console.log(item.id, item.value)
// if (item.id === 'title') {
// songName = item.value
// }
// if (item.id === 'artist') {
// artist = item.value
// }
// if (item.id === 'album') {
// album = item.value
// }
// })
// // console.log({
// // songName,
// // album,
// // songName,
// // })
// }
// console.log({
// songName,
// album,
// songName,
// })
} catch (error) {
console.log(error)
}
const tokenRes = await request(
`/api/nos/token/alloc`,
{
bucket: '',
ext: ext,
filename: filename,
local: false,
nos_product: 3,
type: 'audio',
md5: query.songFile.md5,
},
createOption(query),
)
const res2 = await request(
`/api/upload/cloud/info/v2`,
{
md5: fileMd5,
songid: res.body.songId,
filename: query.songFile.name,
song: songName || filename,
album: album || '未知专辑',
artist: artist || '未知艺术家',
bitrate: String(bitrate),
resourceId: tokenRes.body.result.resourceId,
},
createOption(query),
)
if (res2.body.code !== 200) {
logger.error('云盘信息上传失败:', res2.body)
return Promise.reject({
status: res2.status || 500,
body: {
code: res2.body.code || 500,
msg: res2.body.msg || '上传云盘信息失败',
detail: res2.body,
},
})
}
const res3 = await request(
`/api/cloud/pub/v2`,
{
songid: res2.body.songId,
},
createOption(query),
)
return {
status: 200,
body: {
...res.body,
...res3.body,
},
cookie: res.cookie,
}
} finally {
if (useTemp) {
await cleanupTempFile(query.songFile.tempFilePath)
}
if (res.body.needUpload) {
const uploadInfo = await uploadPlugin(query, request)
// console.log('uploadInfo', uploadInfo.body.result.resourceId)
}
// console.log(tokenRes.body.result)
const res2 = await request(
`/api/upload/cloud/info/v2`,
{
md5: query.songFile.md5,
songid: res.body.songId,
filename: query.songFile.name,
song: songName || filename,
album: album || '未知专辑',
artist: artist || '未知艺术家',
bitrate: String(bitrate),
resourceId: tokenRes.body.result.resourceId,
},
createOption(query),
)
// console.log({ res2, privateCloud: res2.body.privateCloud })
// console.log(res.body.songId, 'songid')
const res3 = await request(
`/api/cloud/pub/v2`,
{
songid: res2.body.songId,
},
createOption(query),
)
// console.log({ res3 })
return {
status: 200,
body: {
...res.body,
...res3.body,
// ...uploadInfo,
},
cookie: res.cookie,
}
}
-11
View File
@@ -1,11 +0,0 @@
// 获取云盘歌词
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
userId: query.uid,
songId: query.sid,
lv: -1,
kv: -1,
}
return request(`/api/cloud/lyric/get`, data, createOption(query, 'eapi'))
}
-72
View File
@@ -1,72 +0,0 @@
const createOption = require('../util/option.js')
module.exports = async (query, request) => {
const {
songId,
resourceId,
md5,
filename,
song,
artist,
album,
bitrate = 999000,
} = query
if (!songId || !resourceId || !md5 || !filename) {
return Promise.reject({
status: 400,
body: {
code: 400,
msg: '缺少必要参数: songId, resourceId, md5, filename',
},
})
}
const songName = song || filename.replace(/\.[^.]+$/, '')
const res2 = await request(
`/api/upload/cloud/info/v2`,
{
md5: md5,
songid: songId,
filename: filename,
song: songName,
album: album || '未知专辑',
artist: artist || '未知艺术家',
bitrate: String(bitrate),
resourceId: resourceId,
},
createOption(query),
)
if (res2.body.code !== 200) {
return Promise.reject({
status: res2.status || 500,
body: {
code: res2.body.code || 500,
msg: res2.body.msg || '上传云盘信息失败',
detail: res2.body,
},
})
}
const res3 = await request(
`/api/cloud/pub/v2`,
{
songid: res2.body.songId,
},
createOption(query),
)
return {
status: 200,
body: {
code: 200,
data: {
songId: res2.body.songId,
...res3.body,
},
},
cookie: res2.cookie,
}
}
-111
View File
@@ -1,111 +0,0 @@
const { default: axios } = require('axios')
const createOption = require('../util/option.js')
module.exports = async (query, request) => {
const { md5, fileSize, filename, bitrate = 999000 } = query
if (!md5 || !fileSize || !filename) {
return Promise.reject({
status: 400,
body: {
code: 400,
msg: '缺少必要参数: md5, fileSize, filename',
},
})
}
const ext = filename.includes('.') ? filename.split('.').pop() : 'mp3'
const checkRes = await request(
`/api/cloud/upload/check`,
{
bitrate: String(bitrate),
ext: '',
length: fileSize,
md5: md5,
songId: '0',
version: 1,
},
createOption(query),
)
const bucket = 'jd-musicrep-privatecloud-audio-public'
const tokenRes = await request(
`/api/nos/token/alloc`,
{
bucket: bucket,
ext: ext,
filename: filename
.replace(/\.[^.]+$/, '')
.replace(/\s/g, '')
.replace(/\./g, '_'),
local: false,
nos_product: 3,
type: 'audio',
md5: md5,
},
createOption(query, 'weapi'),
)
if (!tokenRes.body.result || !tokenRes.body.result.objectKey) {
return Promise.reject({
status: 500,
body: {
code: 500,
msg: '获取上传token失败',
detail: tokenRes.body,
},
})
}
let lbs
try {
lbs = (
await axios({
method: 'get',
url: `https://wanproxy.127.net/lbs?version=1.0&bucketname=${bucket}`,
timeout: 10000,
})
).data
} catch (error) {
return Promise.reject({
status: 500,
body: {
code: 500,
msg: '获取上传服务器地址失败',
detail: error.message,
},
})
}
if (!lbs || !lbs.upload || !lbs.upload[0]) {
return Promise.reject({
status: 500,
body: {
code: 500,
msg: '获取上传服务器地址无效',
detail: lbs,
},
})
}
return {
status: 200,
body: {
code: 200,
data: {
needUpload: checkRes.body.needUpload,
songId: checkRes.body.songId,
uploadToken: tokenRes.body.result.token,
objectKey: tokenRes.body.result.objectKey,
resourceId: tokenRes.body.result.resourceId,
uploadUrl: `${lbs.upload[0]}/${bucket}/${tokenRes.body.result.objectKey.replace(/\//g, '%2F')}?offset=0&complete=true&version=1.0`,
bucket: bucket,
md5: md5,
fileSize: fileSize,
filename: filename,
},
},
cookie: checkRes.cookie,
}
}
-30
View File
@@ -1,30 +0,0 @@
// 评论统计数据
// type: 0=歌曲 1=MV 2=歌单 3=专辑 4=电台节目 5=视频 6=动态 7=电台
// ids: 资源 ID 列表,多个用逗号分隔,如 "123,456"
const { resourceTypeMap } = require('../util/config.json')
const createOption = require('../util/option.js')
// 从 resourceTypeMap 的前缀值中提取网易云内部资源类型编号
// 例如 "R_SO_4_" -> "4", "A_DR_14_" -> "14"
const resourceTypeIdMap = Object.fromEntries(
Object.entries(resourceTypeMap).map(([key, prefix]) => [
key,
prefix.replace(/_$/, '').split('_').pop(),
]),
)
module.exports = (query, request) => {
const ids = String(query.ids || query.id || '')
.split(',')
.map((id) => id.trim())
.filter(Boolean)
return request(
`/api/resource/commentInfo/list`,
{
resourceType: resourceTypeIdMap[String(query.type || 0)],
resourceIds: JSON.stringify(ids),
},
createOption(query, 'weapi'),
)
}
-11
View File
@@ -1,11 +0,0 @@
// 举报评论
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
threadId: 'R_SO_4_' + query.id,
commentId: query.cid,
reason: query.reason,
}
return request(`/api/report/reportcomment`, data, createOption(query))
}
+9
View File
@@ -0,0 +1,9 @@
// 多级行政区划数据获取接口
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
bizCode: query.bizCode || '',
}
return request(`/api/lbs/city/code`, data, createOption(query))
}
-11
View File
@@ -1,11 +0,0 @@
// 获取音乐人任务
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {}
return request(
`/api/nmusician/workbench/special/right/vip/info`,
data,
createOption(query, 'eapi'),
)
}
+1 -2
View File
@@ -1,5 +1,4 @@
const createOption = require('../util/option.js')
const logger = require('../util/logger.js')
module.exports = async (query, request) => {
query.ids = query.ids || ''
const data = {
@@ -10,7 +9,7 @@ module.exports = async (query, request) => {
}),
),
}
logger.info(data)
console.log(data)
return request(`/api/playlist/track/add`, data, createOption(query, 'weapi'))
}
+1 -3
View File
@@ -2,9 +2,7 @@
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
afresh: query.afresh,
}
const data = {}
return request(
`/api/v3/discovery/recommend/songs`,
data,
+1 -2
View File
@@ -2,7 +2,6 @@ const CryptoJS = require('crypto-js')
const path = require('path')
const fs = require('fs')
const ID_XOR_KEY_1 = '3go8&$8*3*3h0k(2)2'
const logger = require('../util/logger.js')
const createOption = require('../util/option.js')
const { generateDeviceId } = require('../util/index')
@@ -24,7 +23,7 @@ function cloudmusic_dll_encode_id(some_id) {
module.exports = async (query, request) => {
const deviceId = generateDeviceId()
logger.info(`Successfully registered anonimous token, deviceId: ${deviceId}`)
console.log(`[register_anonimous] deviceId: ${deviceId}`)
global.deviceId = deviceId
const encodedId = CryptoJS.enc.Base64.stringify(
CryptoJS.enc.Utf8.parse(
-13
View File
@@ -1,13 +0,0 @@
// 搜索建议pc端
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
keyword: query.keyword || '',
}
return request(
`/api/search/pc/suggest/keyword/get`,
data,
createOption(query),
)
}
-9
View File
@@ -1,9 +0,0 @@
// 灰色歌曲的其他版本推荐
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
songid: query.songid || query.id,
}
return request(`/api/song/copyright/rcmd`, data, createOption(query, 'eapi'))
}
-12
View File
@@ -1,12 +0,0 @@
// 喜欢歌曲
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const like = query.like !== 'false'
const data = {
trackId: query.id,
userid: query.uid,
like: like,
}
return request(`/api/song/like`, data, createOption(query))
}
-38
View File
@@ -1,38 +0,0 @@
// 网易云歌曲解灰(适配SPlayer的UNM-Server)
// 支持qq音乐、酷狗音乐、酷我音乐、咪咕音乐、第三方网易云API等等(来自GD音乐台)
const createOption = require('../util/option.js')
const logger = require('../util/logger.js')
module.exports = async (query, request) => {
try {
const {
matchID,
} = require('@neteasecloudmusicapienhanced/unblockmusic-utils')
const result = await matchID(query.id, query.source)
const proxy = process.env.PROXY_URL
logger.info('开始解灰', query.id, result)
const useProxy = process.env.ENABLE_PROXY || 'false'
if (result.data.url && result.data.url.includes('kuwo')) {
result.proxyUrl =
useProxy === 'true' ? proxy + result.data.url : result.data.url
}
return {
status: 200,
body: {
code: 200,
data: result.data.url,
proxyUrl: result.proxyUrl || '',
},
}
} catch (e) {
return {
status: 500,
body: {
code: 500,
msg: e.message || 'unblock error',
data: [],
},
}
}
}
-5
View File
@@ -1,5 +0,0 @@
// 夹带私货的东西就不要放在这里了
module.exports = async (query, request) => {
return { status: 200, body: { code: 200, data: [] } }
}
+1 -42
View File
@@ -1,55 +1,14 @@
// 歌曲链接 - v1
// 此版本不再采用 br 作为音质区分的标准
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), jymaster(超清母带) 进行音质判断
// 当unblock为true时, 会尝试使用unblockmusic-utils进行解锁, 同时音质设置不会生效, 但仍然为必须传入参数
const logger = require('../util/logger.js')
const createOption = require('../util/option.js')
module.exports = async (query, request) => {
const {
matchID,
} = require('@neteasecloudmusicapienhanced/unblockmusic-utils')
require('dotenv').config()
module.exports = (query, request) => {
const data = {
ids: '[' + query.id + ']',
level: query.level,
encodeType: 'flac',
}
if (query.unblock === 'true') {
try {
const result = await matchID(query.id, query.source)
logger.info('Starting unblock(uses modules unblock):', query.id, result)
const useProxy = process.env.ENABLE_PROXY || 'false'
let proxyUrl = ''
if (result.data.url && result.data.url.includes('kuwo')) {
proxyUrl =
useProxy === 'true' && process.env.PROXY_URL
? process.env.PROXY_URL + result.data.url
: result.data.url
}
return {
status: 200,
body: {
code: 200,
msg: 'Warning: Customizing unblock sources is not supported on this endpoint. Please use `/song/url/match` instead.',
data: [
{
id: Number(query.id),
url: result.data.url,
type: 'flac',
level: query.level,
freeTrialInfo: 'null',
fee: 0,
proxyUrl: proxyUrl || '',
},
],
},
cookie: [],
}
} catch (e) {
console.error('Error in unblocking music:', e)
}
}
if (data.level == 'sky') {
data.immerseType = 'c51'
}
-53
View File
@@ -1,53 +0,0 @@
// 获取客户端歌曲下载链接 - v1
// 此版本不再采用 br 作为音质区分的标准
// 而是采用 standard, exhigh, lossless, hires, jyeffect(高清环绕声), sky(沉浸环绕声), jymaster(超清母带) 进行音质判断
const createOption = require('../util/option.js')
module.exports = async (query, request) => {
const data = {
id: query.id,
immerseType: 'c51',
level: query.level,
}
const response = await request(
`/api/song/enhance/download/url/v1`,
data,
createOption(query),
)
let url = response?.body?.data?.[0]?.url
if (!url) {
const fallbackData = {
ids: `[${query.id}]`,
level: query.level,
encodeType: 'flac',
}
if (query.level === 'sky') {
fallbackData.immerseType = 'c51'
}
const fallback = await request(
`/api/song/enhance/player/url/v1`,
fallbackData,
createOption(query),
)
url = fallback?.body?.data?.[0]?.url
if (!url) {
return fallback
}
return {
status: 302,
body: '',
cookie: fallback.cookie || [],
redirectUrl: url,
}
}
return {
status: 302,
body: '',
cookie: response.cookie || [],
redirectUrl: url,
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ module.exports = (query, request) => {
const data = {
userId: query.uid,
time: '0',
limit: query.limit || 20,
limit: query.limit || 30,
offset: query.offset || 0,
getcounts: 'true',
}
-7
View File
@@ -1,7 +0,0 @@
// 黑胶乐签打卡
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {}
return request(`/api/vip-center-bff/task/sign`, data, createOption(query))
}
-11
View File
@@ -1,11 +0,0 @@
// 黑胶乐签签到信息
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {}
return request(
`/api/vipnewcenter/app/user/sign/info`,
data,
createOption(query, 'weapi'),
)
}
+22 -36
View File
@@ -1,26 +1,25 @@
const { default: axios } = require('axios')
const fs = require('fs')
var xml2js = require('xml2js')
const createOption = require('../util/option.js')
const { getFileExtension, readFileChunk } = require('../util/fileHelper')
var parser = new xml2js.Parser()
var parser = new xml2js.Parser(/* options */)
function createDupkey() {
// 格式:3b443c7c-a87f-468d-ba38-46d407aaf23a
var s = []
var hexDigits = '0123456789abcdef'
for (var i = 0; i < 36; i++) {
s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1)
}
s[14] = '4'
s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1)
s[14] = '4' // bits 12-15 of the time_hi_and_version field to 0010
s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1) // bits 6-7 of the clock_seq_hi_and_reserved to 01
s[8] = s[13] = s[18] = s[23] = '-'
return s.join('')
}
module.exports = async (query, request) => {
const ext = getFileExtension(query.songFile.name)
let ext = 'mp3'
if (query.songFile.name.indexOf('flac') > -1) {
ext = 'flac'
}
const filename =
query.songName ||
query.songFile.name
@@ -51,58 +50,43 @@ module.exports = async (query, request) => {
createOption(query, 'weapi'),
)
const objectKey = tokenRes.body.result.objectKey.replace(/\//g, '%2F')
const objectKey = tokenRes.body.result.objectKey.replace('/', '%2F')
const docId = tokenRes.body.result.docId
const res = await axios({
method: 'post',
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploads`,
headers: {
'x-nos-token': tokenRes.body.result.token,
'X-Nos-Meta-Content-Type': query.songFile.mimetype || 'audio/mpeg',
'X-Nos-Meta-Content-Type': 'audio/mpeg',
},
data: null,
})
// return xml
const res2 = await parser.parseStringPromise(res.data)
const useTempFile = !!query.songFile.tempFilePath
let fileSize = query.songFile.size
if (useTempFile) {
const stats = await fs.promises.stat(query.songFile.tempFilePath)
fileSize = stats.size
}
const blockSize = 10 * 1024 * 1024
const fileSize = query.songFile.data.length
const blockSize = 10 * 1024 * 1024 // 10MB
let offset = 0
let blockIndex = 1
let etags = []
while (offset < fileSize) {
let chunk
if (useTempFile) {
chunk = await readFileChunk(
query.songFile.tempFilePath,
offset,
Math.min(blockSize, fileSize - offset),
)
} else {
chunk = query.songFile.data.slice(
offset,
Math.min(offset + blockSize, fileSize),
)
}
const chunk = query.songFile.data.slice(
offset,
Math.min(offset + blockSize, fileSize),
)
const res3 = await axios({
method: 'put',
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?partNumber=${blockIndex}&uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
headers: {
'x-nos-token': tokenRes.body.result.token,
'Content-Type': query.songFile.mimetype || 'audio/mpeg',
'Content-Type': 'audio/mpeg',
},
data: chunk,
})
// get etag
const etag = res3.headers.etag
etags.push(etag)
offset += blockSize
@@ -117,17 +101,19 @@ module.exports = async (query, request) => {
}
completeStr += '</CompleteMultipartUpload>'
// 文件处理
await axios({
method: 'post',
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
headers: {
'Content-Type': 'text/plain;charset=UTF-8',
'X-Nos-Meta-Content-Type': query.songFile.mimetype || 'audio/mpeg',
'X-Nos-Meta-Content-Type': 'audio/mpeg',
'x-nos-token': tokenRes.body.result.token,
},
data: completeStr,
})
// preCheck
await request(
`/api/voice/workbench/voice/batch/upload/preCheck`,
{
-13
View File
@@ -1,13 +0,0 @@
// 我创建的播客声音
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
limit: query.limit || 20,
}
return request(
`/api/social/my/created/voicelist/v1`,
data,
createOption(query, 'weapi'),
)
}
+9 -6
View File
@@ -1,11 +1,14 @@
const createOption = require('../util/option.js')
module.exports = (query, request) => {
const data = {
keyword: query.keyword || '',
scene: 'normal',
limit: query.limit || '10',
offset: query.offset || '30',
e_r: true,
fee: '-1',
limit: query.limit || '200',
offset: query.offset || '0',
podcastName: query.podcastName || '',
}
return request(`/api/search/voicelist/get`, data, createOption(query))
return request(
`/api/voice/workbench/voicelist/search`,
data,
createOption(query),
)
}
+3 -4
View File
@@ -1,5 +1,4 @@
const { eapiResDecrypt, eapiReqDecrypt } = require('../util/crypto')
const logger = require('../util/logger.js')
let reqParamsHex =
'6E0B1C712DCB3648589D7C950C296204072A88C3E080C4CFFD0A71A553EC2533BA88E11B1E1C6DF3BE8EFA26177FCB6FCA34EB3FAFAB4671B2BBAFA9781AFDA2BF53A3DC423722493837B9BC6E80CED5BBD2DDC2856920E4D4E3E7F3EB77ECF265217A66AE677BE36F2D6FB203F721CA250E1453EA61A34904E33D5FCB9D483601D744BE0AE979AC911A00F25828538844F4B1C24F6C34880A4AB257F530C7FB10A81FED32B18D09D70C0B1B9D34A2E58A3C3FAD382C6F958077059C4F801AD7B3B248FDB9D7A59B6A9EEFF8C781A84315B33A7AFD48BE9FCFCBE1902CCC27949ACF2BDE3FA34D116E230C3597E8320B8C42BBBF371A00C03EC428E0440EB94C1540F3FD4173D29E310AFE43AB0EF449852904103EF305FC435AD43B7D8673642F74C89CCB2F1A6A79B3BE14F1235D3843C3B241D12C05DBDDF37B68CA8B5D0230AF1FCF2A9705886F4D126B33FFF6948DE1E4046DB6423D687E96C5B65122464D2E71AEC7722935FF2C3796FAE253A16AA3B102FBE7296AB0DB9EA5C46AD12B'.replaceAll(
@@ -11,6 +10,6 @@ const resHex =
' ',
'',
)
logger.info(reqParamsHex)
logger.info(eapiReqDecrypt(reqParamsHex))
logger.info(eapiResDecrypt(resHex))
console.log(reqParamsHex)
console.log(eapiReqDecrypt(reqParamsHex))
console.log(eapiResDecrypt(resHex))
+7 -8
View File
@@ -2,7 +2,6 @@ const { cloud, login_cellphone } = require('../main')
const fs = require('fs')
const path = require('path')
const yargs = require('yargs')
const logger = require('../util/logger.js')
const MUSIC_FILE_EXTENSIONS = new Set(['.mp3', '.flac'])
@@ -38,11 +37,11 @@ async function uploadArrayOfFile(token, arrayOfFiles) {
cookie: token.body.cookie,
})
} catch (error) {
logger.info(error)
console.log(error)
failed += 1
failedFiles.push(file)
}
logger.info(`Uploaded ${k + 1}/${fileCount} songs`)
console.log(`Uploaded ${k + 1}/${fileCount} songs`)
}
return { failedFiles, failed }
}
@@ -87,19 +86,19 @@ async function main() {
const files = args.file ? [args.file] : getAllMusicFiles(args.dir)
const fileCount = files.length
logger.info(`Found ${fileCount} files, uploading...`)
console.log(`Found ${fileCount} files, uploading...`)
let res = await uploadArrayOfFile(token, files)
if (res.failed) {
logger.info(`Failed to upload ${res.failed} songs, retrying...`)
console.log(`Failed to upload ${res.failed} songs, retrying...`)
res = await uploadArrayOfFile(token, res.failedFiles)
}
logger.info(`Uploaded ${fileCount - res.failed} songs`)
logger.info(
console.log(`Uploaded ${fileCount - res.failed} songs`)
console.log(
`Failed to upload ${res.failed} songs, you can reupload the files below`,
)
for (let k in res.failedFiles) {
logger.info(res.failedFiles[k])
console.log(res.failedFiles[k])
}
}
main()
+1 -2
View File
@@ -1,7 +1,6 @@
const { cloud, login_cellphone } = require('../main')
const fs = require('fs')
const path = require('path')
const logger = require('../util/logger.js')
async function main() {
const result = await login_cellphone({
@@ -18,7 +17,7 @@ async function main() {
cookie: result.body.cookie,
})
} catch (error) {
logger.info(error, 'error')
console.log(error, 'error')
}
}
main()
+5 -6
View File
@@ -1,4 +1,3 @@
const logger = require('../util/logger.js')
const {
login_cellphone,
user_cloud,
@@ -11,22 +10,22 @@ async function test() {
phone: '手机号',
password: '密码',
})
logger.info(result)
console.log(result)
const result2 = await user_cloud({
cookie: result.body.cookie,
})
logger.info(result2.body)
console.log(result2.body)
const result3 = await album_sublist({
cookie: result.body.cookie,
})
logger.info(result3.body)
console.log(result3.body)
const result4 = await song_url({
cookie: result.body.cookie,
id: 33894312,
})
logger.info(result4.body)
console.log(result4.body)
} catch (error) {
logger.info(error)
console.log(error)
}
}
test()
+3 -4
View File
@@ -1,10 +1,9 @@
import { banner, lyric } from '@neteasecloudmusicapienhanced/api'
import { logger } from '../util/logger.js'
import { banner, lyric } from 'NeteaseCloudMusicApi'
banner({ type: 0 }).then((res) => {
logger.info(res)
console.log(res)
})
lyric({
id: '33894312',
}).then((res) => {
logger.info(res)
console.log(res)
})
+11051
View File
File diff suppressed because it is too large Load Diff
+30 -36
View File
@@ -1,17 +1,16 @@
{
"name": "@neteasecloudmusicapienhanced/api",
"version": "4.32.1",
"description": "全网最全的网易云音乐API接口 || A revival project for NeteaseCloudMusicApi Node.js Services (Half Refactor & Enhanced) || 网易云音乐 API 备份 + 增强 || 本项目自原版v4.28.0版本后开始自行维护",
"name": "NeteaseCloudMusicApi",
"version": "4.32.0",
"description": "网易云音乐 NodeJS 版 API",
"scripts": {
"dev": "nodemon app.js",
"start": "node app.js",
"test": "mocha -r intelli-espower-loader -t 60000 server.test.js main.test.js --exit",
"lint": "eslint \"**/*.{js,ts}\"",
"lint-fix": "eslint --fix \"**/*.{js,ts}\"",
"prepare": "husky install",
"pkgwin": "pkg . -t node18-win-x64 -C GZip -o precompiled/app",
"pkglinux": "pkg . -t node18-linux-x64 -C GZip -o precompiled/app",
"pkgmacos": "pkg . -t node18-macos-x64 -C GZip -o precompiled/app"
"pkgwin": "pkg . -t node18-win-x64 -C GZip -o bin/app --no-bytecode",
"pkglinux": "pkg . -t node18-linux-x64 -C GZip -o bin/app --no-bytecode",
"pkgmacos": "pkg . -t node18-macos-x64 -C GZip -o bin/app --no-bytecode"
},
"bin": "./app.js",
"pkg": {
@@ -20,6 +19,7 @@
"node_modules/axios",
"node_modules/express",
"node_modules/express-fileupload",
"node_modules/md5",
"node_modules/music-metadata",
"node_modules/pac-proxy-agent",
"node_modules/qrcode",
@@ -50,7 +50,7 @@
"git add"
]
},
"author": "MoeFurina",
"author": "binaryify",
"license": "MIT",
"files": [
"module",
@@ -65,44 +65,38 @@
"data"
],
"dependencies": {
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.3.1",
"axios": "^1.16.1",
"axios": "^1.13.6",
"crypto-js": "^4.2.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express": "^4.22.1",
"express-fileupload": "^1.5.2",
"gzip": "^0.1.0",
"music-metadata": "^11.12.3",
"node-forge": "^1.4.0",
"md5": "^2.3.0",
"music-metadata": "^7.14.0",
"node-forge": "^1.3.3",
"pac-proxy-agent": "^7.2.0",
"qrcode": "^1.5.4",
"safe-decode-uri-component": "^1.2.1",
"tunnel": "^0.0.6",
"xml2js": "^0.6.2",
"yargs": "^18.0.0"
"yargs": "^17.7.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^9.39.4",
"@types/express": "^5.0.6",
"@types/express": "^4.17.25",
"@types/express-fileupload": "^1.5.1",
"@types/mocha": "^10.0.10",
"@types/node": "25.5.0",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-html": "^8.1.4",
"eslint-plugin-prettier": "^5.5.5",
"globals": "^17.6.0",
"husky": "^9.1.7",
"intelli-espower-loader": "^1.1.0",
"lint-staged": "^16.4.0",
"mocha": "^11.7.5",
"nodemon": "^3.1.14",
"@types/mocha": "^9.1.1",
"@types/node": "16.11.19",
"@typescript-eslint/eslint-plugin": "5.0.0",
"@typescript-eslint/parser": "5.0.0",
"eslint": "8.7.0",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-html": "7.1.0",
"eslint-plugin-prettier": "4.0.0",
"husky": "7.0.4",
"intelli-espower-loader": "1.1.0",
"lint-staged": "12.1.7",
"mocha": "10.0.0",
"pkg": "^5.8.1",
"power-assert": "^1.6.1",
"prettier": "^3.8.3",
"typescript": "^5.9.3"
"power-assert": "1.6.1",
"prettier": "2.7.1",
"typescript": "4.5.2"
}
}
+19 -68
View File
@@ -1,17 +1,19 @@
const { default: axios } = require('axios')
const createOption = require('../util/option.js')
const logger = require('../util/logger.js')
const {
getUploadData,
getFileExtension,
sanitizeFilename,
} = require('../util/fileHelper')
module.exports = async (query, request) => {
const ext = getFileExtension(query.songFile.name)
const filename = sanitizeFilename(query.songFile.name)
let ext = 'mp3'
// if (query.songFile.name.indexOf('flac') > -1) {
// ext = 'flac'
// }
if (query.songFile.name.includes('.')) {
ext = query.songFile.name.split('.').pop()
}
const filename = query.songFile.name
.replace('.' + ext, '')
.replace(/\s/g, '')
.replace(/\./g, '_')
const bucket = 'jd-musicrep-privatecloud-audio-public'
// 获取key和token
const tokenRes = await request(
`/api/nos/token/alloc`,
{
@@ -26,82 +28,31 @@ module.exports = async (query, request) => {
createOption(query, 'weapi'),
)
if (!tokenRes.body.result || !tokenRes.body.result.objectKey) {
logger.error('Token分配失败:', tokenRes.body)
throw {
status: 500,
body: {
code: 500,
msg: '获取上传token失败',
detail: tokenRes.body,
},
}
}
const objectKey = tokenRes.body.result.objectKey.replace(/\//g, '%2F')
let lbs
// 上传
const objectKey = tokenRes.body.result.objectKey.replace('/', '%2F')
try {
lbs = (
const lbs = (
await axios({
method: 'get',
url: `https://wanproxy.127.net/lbs?version=1.0&bucketname=${bucket}`,
timeout: 10000,
})
).data
} catch (error) {
logger.error('LBS获取失败:', error.message)
throw {
status: 500,
body: {
code: 500,
msg: '获取上传服务器地址失败',
detail: error.message,
},
}
}
if (!lbs || !lbs.upload || !lbs.upload[0]) {
logger.error('无效的LBS响应:', lbs)
throw {
status: 500,
body: {
code: 500,
msg: '获取上传服务器地址无效',
detail: lbs,
},
}
}
try {
await axios({
method: 'post',
url: `${lbs.upload[0]}/${bucket}/${objectKey}?offset=0&complete=true&version=1.0`,
headers: {
'x-nos-token': tokenRes.body.result.token,
'Content-MD5': query.songFile.md5,
'Content-Type': query.songFile.mimetype || 'audio/mpeg',
'Content-Type': 'audio/mpeg',
'Content-Length': String(query.songFile.size),
},
data: getUploadData(query.songFile),
data: query.songFile.data,
maxContentLength: Infinity,
maxBodyLength: Infinity,
timeout: 300000,
})
logger.info('上传成功:', filename)
} catch (error) {
logger.error('上传失败:', {
status: error.response?.status,
data: error.response?.data,
message: error.message,
})
throw {
status: error.response?.status || 500,
body: {
code: error.response?.status || 500,
msg: '文件上传失败',
detail: error.response?.data || error.message,
},
}
console.log('error', error.response)
throw error.response
}
return {
...tokenRes,
+7 -5
View File
@@ -1,7 +1,5 @@
const { default: axios } = require('axios')
const createOption = require('../util/option.js')
const { getUploadData } = require('../util/fileHelper')
module.exports = async (query, request) => {
const data = {
bucket: 'yyimgs',
@@ -12,23 +10,27 @@ module.exports = async (query, request) => {
return_body: `{"code":200,"size":"$(ObjectSize)"}`,
type: 'other',
}
// 获取key和token
const res = await request(
`/api/nos/token/alloc`,
data,
createOption(query, 'weapi'),
)
// 上传图片
const res2 = await axios({
method: 'post',
url: `https://nosup-hz1.127.net/yyimgs/${res.body.result.objectKey}?offset=0&complete=true&version=1.0`,
headers: {
'x-nos-token': res.body.result.token,
'Content-Type': query.imgFile.mimetype || 'image/jpeg',
'Content-Type': 'image/jpeg',
},
data: getUploadData(query.imgFile),
data: query.imgFile.data,
})
return {
// ...res.body.result,
// ...res2.data,
// ...res3.body,
url_pre: 'https://p1.music.126.net/' + res.body.result.objectKey,
imgId: res.body.result.docId,
}
+948 -1560
View File
File diff suppressed because it is too large Load Diff
+65 -131
View File
@@ -5,148 +5,82 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API 调试界面</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
background: #f5f5f5;
padding: 20px;
font-family: Arial, sans-serif;
margin: 20px;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
flex-grow: 1;
}
h1 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 24px;
}
form {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.form-row {
display: flex;
gap: 12px;
align-items: center;
input, button {
padding: 10px;
box-sizing: border-box;
flex: 1;
}
label {
font-size: 14px;
font-weight: 500;
color: #555;
min-width: 80px;
}
input, select {
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
flex: 1;
outline: none;
}
input:focus, select:focus {
border-color: #333;
}
button {
background: #333;
color: white;
padding: 10px 24px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
background-color: #4CAF50;
color: white;
border: none;
cursor: pointer;
}
button:hover {
background: #555;
}
.data-result {
display: flex;
gap: 16px;
min-height: 400px;
display: flex;
flex-direction: row;
flex-grow: 1;
}
.data-result > div {
flex: 1;
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
flex-grow: 1;
padding: 10px;
box-sizing: border-box;
}
.data-result label {
margin-bottom: 8px;
padding: 0;
margin-bottom: 10px;
}
textarea {
flex: 1;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 13px;
resize: vertical;
min-height: 350px;
outline: none;
#data, #result {
height: 100%;
box-sizing: border-box;
}
textarea:focus {
border-color: #333;
#data {
border-right: 1px solid #ccc;
}
</style>
</head>
<body>
<div class="container">
<h1>API 调试界面</h1>
<form onsubmit="event.preventDefault(); sendRequest();">
<div class="form-row">
<label for="uri">URI</label>
<input type="text" id="uri" name="uri" value="/api/song/lyric/v1">
</div>
<div class="form-row">
<label for="crypto">加密方式</label>
<select id="crypto" name="crypto">
<option value="weapi">weapi</option>
<option value="eapi">eapi</option>
<option value="api">api</option>
<option value="linuxapi">linuxapi</option>
<option value="" selected>(默认)</option>
</select>
</div>
<div class="form-row">
<label></label>
<button type="submit">发送请求</button>
</div>
<label for="uri">uri</label>
<input type="text" id="uri" name="uri" value="/api/song/lyric/v1">
<label for="crypto">crypto</label>
<select id="crypto" name="crypto">
<option value="weapi">weapi</option>
<option value="eapi">eapi</option>
<option value="api">api</option>
<option value="linuxapi">linuxapi</option>
<option value="" selected>(默认)</option>
</select>
<button type="submit">发送</button>
</form>
<div class="data-result">
<div>
<label for="result">响应结果</label>
<textarea id="result" name="result" readonly></textarea>
<label for="result">result</label>
<textarea id="result" name="result"></textarea>
</div>
<div>
<label for="data">请求数据</label>
<label for="data">data</label>
<textarea id="data" name="data">
{
"cp": false,
@@ -196,27 +130,27 @@
const crypto = params.get('crypto');
const dataParam = params.get('data');
if (uri) {
document.getElementById('uri').value = uri;
document.getElementById('uri').value = uri;
}
if (crypto) {
const cryptoSelect = document.getElementById('crypto');
if ([...cryptoSelect.options].some((opt) => opt.value === crypto)) {
cryptoSelect.value = crypto;
}
const cryptoSelect = document.getElementById('crypto');
if ([...cryptoSelect.options].some((opt) => opt.value === crypto)) {
cryptoSelect.value = crypto;
}
}
if (dataParam) {
const decoded = dataParam;
try {
document.getElementById('data').value = JSON.stringify(
JSON.parse(decoded),
null,
2,
);
} catch (error) {
document.getElementById('data').value = decoded;
}
const decoded = dataParam;
try {
document.getElementById('data').value = JSON.stringify(
JSON.parse(decoded),
null,
2,
);
} catch (error) {
document.getElementById('data').value = decoded;
}
}
})();
})();
</script>
</body>
</html>
+2 -2
View File
@@ -1611,9 +1611,9 @@ function instantiateRuntime(){
function GenerateFP(floatArray) {
let PCMBuffer = Float32Array.from(floatArray)
console.info('[afp] input samples n=', PCMBuffer.length)
console.log('[afp] input samples n=', PCMBuffer.length)
return instantiateRuntime().then((fpRuntime) => {
console.info('[afp] begin fingerprinting')
console.log('[afp] begin fingerprinting')
let fp_vector = fpRuntime.ExtractQueryFP(PCMBuffer.buffer)
let result_buf = new Uint8Array(fp_vector.size());
for (let t = 0; t < fp_vector.size(); t++)
+43 -212
View File
@@ -1,142 +1,27 @@
<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>听歌识曲 Demo</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: sans-serif;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
h1 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.subtitle {
font-size: 13px;
color: #666;
margin-bottom: 24px;
}
hr {
border: none;
border-top: 1px solid #eee;
margin: 20px 0;
}
p {
font-size: 14px;
color: #555;
line-height: 1.6;
margin-bottom: 12px;
pre {
font-family: monospace;
}
a {
color: #333;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.section {
margin-bottom: 24px;
}
.section h3 {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
}
.control-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 16px;
}
button {
padding: 10px 20px;
background: #333;
color: white;
font-size: 14px;
font-weight: 500;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s ease;
}
button:hover {
background: #555;
}
button:disabled {
background: #999;
cursor: not-allowed;
}
input[type="file"] {
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
}
.checkbox-group input[type="checkbox"] {
cursor: pointer;
}
.checkbox-group label {
margin: 0;
cursor: pointer;
font-size: 14px;
color: #555;
font-family: sans-serif;
}
audio {
width: 100%;
margin-bottom: 16px;
}
canvas {
width: 100%;
height: 0;
transition: all linear 0.1s;
background: #f9f9f9;
border-radius: 6px;
}
.canvas-active {
@@ -144,80 +29,39 @@
}
pre {
font-family: 'Courier New', monospace;
font-size: 13px;
color: #666;
white-space: pre-wrap;
word-wrap: break-word;
max-height: 400px;
overflow-y: auto;
padding: 16px;
background: #f9f9f9;
border-radius: 6px;
border: 1px solid #eee;
}
.warning {
padding: 12px 16px;
background: #fef3c7;
border-radius: 6px;
font-size: 14px;
color: #92400e;
margin-bottom: 16px;
overflow: scroll;
}
</style>
</head>
<body>
<div class="container">
<h1>听歌识曲 Demo</h1>
<p class="subtitle">Credit: <a href="https://github.com/mos9527/ncm-afp" target="_blank">https://github.com/mos9527/ncm-afp</a></p>
<h1>听歌识曲 Demo (Credit: <a href="https://github.com/mos9527/ncm-afp" target="_blank">https://github.com/mos9527/ncm-afp</a>)</h1>
<hr>
<p><b>DISCLAIMER: </b></p>
<p>This site uses the offical NetEase audio matcher APIs (reverse engineered from <a
href="https://fn.music.163.com/g/chrome-extension-home-page-beta/">https://fn.music.163.com/g/chrome-extension-home-page-beta/</a>)
</p>
<p>And DOES NOT condone copyright infringment nor intellectual property theft.</p>
<hr>
<p><b>NOTE:</b></p>
<p>Before start using the site, you may want to access this link first:</p>
<a href="https://cors-anywhere.herokuapp.com/corsdemo">https://cors-anywhere.herokuapp.com/corsdemo</a>
<p>Since Netease APIs do not have CORS headers, this is required to alleviate this restriction.</p>
<hr>
<p>Usage:</p>
<li>Select your audio file through "Choose File" picker</li>
<li>Hit the "Clip" button and wait for the results!</li>
<hr>
<div class="warning">
<strong>免责声明:</strong>本站点使用网易云音乐官方音频识别API(逆向自 <a href="https://fn.music.163.com/g/chrome-extension-home-page-beta/" target="_blank">Chrome 扩展页面</a>),不鼓励版权侵犯或知识产权盗窃。
</div>
<div class="section">
<h3>使用说明</h3>
<p>在使用本站点之前,您可能需要先访问以下链接:</p>
<p><a href="https://cors-anywhere.herokuapp.com/corsdemo" target="_blank">https://cors-anywhere.herokuapp.com/corsdemo</a></p>
<p>由于网易云音乐API没有CORS头,这是解决此限制的必要步骤。</p>
</div>
<div class="section">
<h3>使用方法</h3>
<ul style="padding-left: 20px; font-size: 14px; color: #555;">
<li>通过"选择文件"选择您的音频文件</li>
<li>点击"识别"按钮并等待结果</li>
</ul>
</div>
<hr>
<audio id="audio" controls autoplay></audio>
<canvas id="canvas"></canvas>
<div class="control-group">
<button id="invoke">识别</button>
<input type="file" name="picker" accept="*" id="file">
</div>
<div class="control-group">
<div class="checkbox-group">
<input type="checkbox" name="use-mic" id="usemic">
<label for="usemic">混合麦克风输入</label>
</div>
</div>
<hr>
<h3 style="font-size: 16px; font-weight: 600; color: #333; margin-bottom: 12px;">日志</h3>
<pre id="logs"></pre>
</div>
<audio id="audio" controls autoplay></audio>
<canvas id="canvas"></canvas>
<button id="invoke">Clip</button>
<input type="file" name="picker" accept="*" id="file">
<hr>
<label for="use-mic">Mix in Microphone input</label>
<input type="checkbox" name="use-mic" id="usemic">
<hr>
<pre id="logs"></pre>
</body>
<script src="./afp.wasm.js"></script>
<script src="./afp.js"></script>
<script type="module">
@@ -232,17 +76,13 @@
let canvas = document.getElementById('canvas')
let canvasCtx = canvas.getContext('2d')
let logs = document.getElementById('logs')
logs.write = line => {
// Append log lines as text to avoid interpreting content as HTML
logs.appendChild(document.createTextNode(line));
logs.appendChild(document.createElement('br'));
}
logs.write = line => logs.innerHTML += line + '\n'
function RecorderCallback(channelL) {
let sampleBuffer = new Float32Array(channelL.subarray(0, duration * 8000))
GenerateFP(sampleBuffer).then(FP => {
logs.write(`[index] 生成指纹 ${FP}`)
logs.write('[index] 正在查询,请稍候...')
logs.write(`[index] Generated FP ${FP}`)
logs.write('[index] Now querying, please wait...')
fetch(
'/audio/match?' +
new URLSearchParams({
@@ -251,9 +91,9 @@
method: 'POST'
}).then(resp => resp.json()).then(resp => {
if (!resp.data.result) {
return logs.write('[index] 查询失败,无结果')
return logs.write('[index] Query failed with no results.')
}
logs.write(`[index] 查询完成。结果数量=${resp.data.result.length}`)
logs.write(`[index] Query complete. Results=${resp.data.result.length}`)
for (var song of resp.data.result) {
logs.write(
`[result] <a target="_blank" href="https://music.163.com/song?id=${song.song.id}">${song.song.name} - ${song.song.album.name} (${song.startTime / 1000}s)</a>`
@@ -264,19 +104,20 @@
}
function InitAudioCtx() {
// AFP.wasm can't do it with anything other than 8KHz
audioCtx = new AudioContext({ 'sampleRate': 8000 })
if (audioCtx.state == 'suspended')
return false
let audioNode = audioCtx.createMediaElementSource(audio)
audioCtx.audioWorklet.addModule('rec.js').then(() => {
recorderNode = new AudioWorkletNode(audioCtx, 'timed-recorder')
audioNode.connect(recorderNode)
audioNode.connect(recorderNode) // recorderNode doesn't output anything
audioNode.connect(audioCtx.destination)
recorderNode.port.onmessage = event => {
switch (event.data.message) {
case 'finished':
RecorderCallback(event.data.recording)
clip.innerHTML = '识别'
clip.innerHTML = 'Clip'
clip.disabled = false
canvas.classList.remove('canvas-active')
break
@@ -289,6 +130,7 @@
logs.write(event.data.message)
}
}
// Attempt to get user's microphone and connect it to the AudioContext.
navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
@@ -300,7 +142,7 @@
micSourceNode = audioCtx.createMediaStreamSource(micStream);
micSourceNode.connect(recorderNode)
usemic.checked = true
logs.write('[rec.js] 麦克风已连接')
logs.write('[rec.js] Microphone attached.')
});
});
return true
@@ -319,20 +161,10 @@
else
micSourceNode.connect(recorderNode)
})
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/\//g, '&#x2F;');
}
file.addEventListener('change', event => {
file.files[0].arrayBuffer().then(
async buffer => {
const safeName = escapeHtml(file.files[0].name)
logs.write(`[index] 文件 ${safeName} 已加载`)
logs.write(`[index] File ${file.files[0].name} loaded.`)
audio.src = window.URL.createObjectURL(new Blob([buffer]))
clip.disabled = false
})
@@ -356,13 +188,12 @@
UpdateCanvas()
let requestCtx = setInterval(() => {
try {
if (InitAudioCtx()) {
if (InitAudioCtx()) { // Put this here so we don't have to deal with the 'user did not interact' thing
clearInterval(requestCtx)
logs.write('[rec.js] 音频上下文已启动')
logs.write('[rec.js] Audio Context started.')
}
} catch {
// Fail silently
}
}, 100)
</script>
</html>
+43 -296
View File
@@ -5,320 +5,67 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>更新头像</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
padding: 40px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-width: 400px;
width: 100%;
text-align: center;
}
h1 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.subtitle {
font-size: 14px;
color: #666;
margin-bottom: 32px;
}
.avatar-wrapper {
position: relative;
width: 160px;
height: 160px;
margin: 0 auto 32px;
}
.avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 3px solid #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.avatar-wrapper.loading .avatar {
opacity: 0.5;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
}
.avatar-wrapper.loading .loading-overlay {
opacity: 1;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #e0e0e0;
border-top-color: #333;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.upload-btn {
display: inline-block;
position: relative;
padding: 12px 28px;
background: #333;
color: white;
font-size: 15px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s ease;
border: none;
}
.upload-btn:hover {
background: #555;
}
.upload-btn input[type="file"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.login-link {
display: block;
margin-top: 24px;
color: #666;
font-size: 14px;
text-decoration: none;
}
.login-link:hover {
color: #333;
text-decoration: underline;
}
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(100px);
padding: 12px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
opacity: 0;
transition: all 0.3s ease;
z-index: 1000;
max-width: 90%;
text-align: center;
}
.toast.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
.toast.success {
background: #10b981;
}
.toast.error {
background: #ef4444;
}
.toast.info {
background: #3b82f6;
}
.hint {
margin-top: 20px;
font-size: 12px;
color: #999;
}
</style>
</head>
<body>
<div class="container">
<h1>更新头像</h1>
<p class="subtitle">选择一张图片作为您的新头像</p>
<div class="avatar-wrapper" id="avatarWrapper">
<img id="avatar" class="avatar" src="" alt="头像" />
<div class="loading-overlay">
<div class="spinner"></div>
</div>
</div>
<label class="upload-btn">
选择图片
<input id="file" type="file" accept="image/*" />
</label>
<a href="/qrlogin-nocookie.html" class="login-link">
还没有登录?点击登录
<div>
<a href="/qrlogin-nocookie.html">
如果没登录,请先登录
</a>
<p class="hint">支持 JPG、PNG 格式,建议尺寸 200x200</p>
</div>
<div id="toast" class="toast"></div>
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js"></script>
<input id="file" type="file" />
<img id="avatar" style="height: 200px; width: 200px; border-radius: 50%" />
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js
"></script>
<script>
main()
async function main() {
const fileInput = document.querySelector('input[type="file"]');
const avatarWrapper = document.getElementById('avatarWrapper');
fileInput.addEventListener('change', function(e) {
const file = this.files[0];
if (file) {
upload(file);
}
}, false);
try {
showToast('正在加载头像...', 'info');
avatarWrapper.classList.add('loading');
const res = await axios({
url: `/user/detail?uid=32953014&timestamp=${Date.now()}`,
withCredentials: true
});
document.querySelector('#avatar').src = res.data.profile.avatarUrl;
hideToast();
} catch (error) {
hideToast();
showToast('加载头像失败,请刷新页面重试', 'error');
console.error('加载头像失败:', error);
} finally {
avatarWrapper.classList.remove('loading');
}
document.querySelector('input[type="file"]').addEventListener(
'change',
function (e) {
var file = this.files[0]
upload(file)
},
false,
)
const res = await axios({
url: `/user/detail?uid=32953014&timestamp=${Date.now()}`,
withCredentials: true, //跨域的话必须设置
})
document.querySelector('#avatar').src = res.data.profile.avatarUrl
}
async function upload(file) {
const avatarWrapper = document.getElementById('avatarWrapper');
if (!file.type.startsWith('image/')) {
showToast('请选择图片文件', 'error');
return;
}
try {
showToast('正在上传头像...', 'info');
avatarWrapper.classList.add('loading');
var formData = new FormData();
formData.append('imgFile', file);
const imgSize = await getImgSize(file);
const res = await axios({
method: 'post',
url: `/avatar/upload?cookie=${localStorage.getItem('cookie')}&imgSize=${imgSize.width}&imgX=0&imgY=0&timestamp=${Date.now()}`,
headers: {
'Content-Type': 'multipart/form-data',
},
data: formData,
});
document.querySelector('#avatar').src = res.data.data.url;
showToast('头像更新成功!', 'success');
} catch (error) {
console.error('上传失败:', error);
const errorMsg = error.response?.data?.message || error.message || '上传失败,请重试';
showToast(errorMsg, 'error');
} finally {
avatarWrapper.classList.remove('loading');
}
var formData = new FormData()
formData.append('imgFile', file)
const imgSize = await getImgSize(file)
const res = await axios({
method: 'post',
url: `/avatar/upload?cookie=${localStorage.getItem('cookie')}&imgSize=${imgSize.width
}&imgX=0&imgY=0&timestamp=${Date.now()}`,
headers: {
'Content-Type': 'multipart/form-data',
},
data: formData,
})
document.querySelector('#avatar').src = res.data.data.url
}
function getImgSize(file) {
return new Promise((resolve, reject) => {
let reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = function(theFile) {
let image = new Image();
image.src = theFile.target.result;
image.onload = function() {
let reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = function (theFile) {
let image = new Image()
image.src = theFile.target.result
image.onload = function () {
resolve({
width: this.width,
height: this.height,
});
};
image.onerror = function() {
reject(new Error('图片加载失败'));
};
};
reader.onerror = function() {
reject(new Error('文件读取失败'));
};
});
}
function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast ${type}`;
requestAnimationFrame(() => {
toast.classList.add('show');
});
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}
function hideToast() {
const toast = document.getElementById('toast');
toast.classList.remove('show');
})
}
}
})
}
</script>
</body>
+88 -567
View File
@@ -1,578 +1,99 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>云盘上传</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
background: #f5f5f5;
padding: 20px;
}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>云盘上传</title>
</head>
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
<body>
<div>
<a href="/qrlogin-nocookie.html">
如果没登录,请先登录
</a>
</div>
<input id="file" type="file" multiple />
<div id="app">
<ul>
<li v-for="(item,index) in songs" :key="index">
{{item.songName}}
</li>
</ul>
</div>
h1 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 24px;
}
.login-link {
display: block;
margin-bottom: 24px;
color: #666;
font-size: 14px;
text-decoration: none;
}
.login-link:hover {
color: #333;
text-decoration: underline;
}
.mode-section {
margin-bottom: 24px;
padding: 16px;
background: #f9f9f9;
border-radius: 8px;
}
.mode-section label {
display: block;
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 12px;
}
.mode-options {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.mode-option {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
}
.mode-option input[type="radio"] {
margin-top: 3px;
}
.mode-option-text {
display: flex;
flex-direction: column;
}
.mode-option-title {
font-size: 14px;
color: #333;
}
.mode-option-desc {
font-size: 12px;
color: #999;
margin-top: 2px;
}
.mode-option input[type="radio"]:checked + .mode-option-text .mode-option-title {
color: #333;
font-weight: 500;
}
.upload-section {
margin-bottom: 32px;
}
.upload-btn {
display: inline-block;
padding: 12px 28px;
background: #333;
color: white;
font-size: 15px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s ease;
border: none;
}
.upload-btn:hover {
background: #555;
}
.upload-btn input[type="file"] {
display: none;
}
.upload-btn.disabled {
background: #ccc;
cursor: not-allowed;
}
.songs-list {
list-style: none;
}
.song-item {
padding: 12px 16px;
border-bottom: 1px solid #eee;
font-size: 14px;
color: #333;
}
.song-item:last-child {
border-bottom: none;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #999;
font-size: 14px;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.progress-section {
margin-bottom: 24px;
display: none;
}
.progress-section.active {
display: block;
}
.progress-item {
margin-bottom: 12px;
padding: 12px;
background: #f9f9f9;
border-radius: 6px;
}
.progress-item .name {
font-size: 14px;
color: #333;
margin-bottom: 8px;
word-break: break-all;
}
.progress-item .status {
font-size: 12px;
color: #666;
margin-bottom: 6px;
}
.progress-bar {
height: 6px;
background: #e0e0e0;
border-radius: 3px;
overflow: hidden;
}
.progress-bar .fill {
height: 100%;
background: #333;
border-radius: 3px;
transition: width 0.3s ease;
width: 0%;
}
.progress-item.success .fill {
background: #4caf50;
}
.progress-item.error .fill {
background: #f44336;
}
.progress-item.error .status {
color: #f44336;
}
.info-text {
font-size: 12px;
color: #999;
margin-top: 8px;
}
.warning-text {
font-size: 12px;
color: #e65100;
margin-top: 8px;
}
</style>
</head>
<body>
<div class="container">
<h1>云盘上传</h1>
<a href="/qrlogin-nocookie.html" class="login-link">还没登录?点击登录</a>
<div class="mode-section">
<label>上传模式</label>
<div class="mode-options">
<label class="mode-option">
<input type="radio" name="uploadMode" value="direct" checked />
<span class="mode-option-text">
<span class="mode-option-title">客户端直传</span>
<span class="mode-option-desc">文件直接上传到云存储,支持大文件,适合 Vercel 等平台</span>
</span>
</label>
<label class="mode-option">
<input type="radio" name="uploadMode" value="proxy" />
<span class="mode-option-text">
<span class="mode-option-title">后端代理</span>
<span class="mode-option-desc">文件通过服务器转发,更简洁,需要服务器支持大文件</span>
</span>
</label>
</div>
</div>
<div class="upload-section">
<label class="upload-btn" id="uploadBtn">
选择文件(支持多选)
<input id="file" type="file" multiple accept="audio/*" />
</label>
<p class="info-text" id="modeInfo">支持大文件上传,文件将直接传输到云存储服务器</p>
</div>
<div id="progressSection" class="progress-section"></div>
<div id="app">
<div v-if="loading" class="loading">加载中...</div>
<ul v-else-if="songs.length > 0" class="songs-list">
<li v-for="(item, index) in songs" :key="index" class="song-item">
{{ item.songName }}
</li>
</ul>
<div v-else class="empty-state">暂无云盘歌曲</div>
</div>
</div>
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js"></script>
<script src="https://fastly.jsdelivr.net/npm/vue@3"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsmediatags/3.9.5/jsmediatags.min.js"></script>
<script>
const app = Vue.createApp({
data() {
return {
songs: [],
loading: false,
}
},
created() {
this.getData()
},
methods: {
getData() {
this.loading = true
axios({
url: `/user/cloud?time=${Date.now()}&cookie=${localStorage.getItem('cookie')}`,
})
.then((res) => {
this.songs = res.data.data || []
})
.catch((err) => {
console.error('获取云盘数据失败:', err)
})
.finally(() => {
this.loading = false
})
},
},
}).mount('#app')
let isUploading = false
let uploadMode = 'direct'
const progressSection = document.getElementById('progressSection')
const uploadBtn = document.getElementById('uploadBtn')
const fileInput = document.querySelector('input[type="file"]')
const modeInfo = document.getElementById('modeInfo')
document.querySelectorAll('input[name="uploadMode"]').forEach(radio => {
radio.addEventListener('change', function() {
uploadMode = this.value
if (uploadMode === 'direct') {
modeInfo.textContent = '支持大文件上传,文件将直接传输到云存储服务器'
modeInfo.className = 'info-text'
} else {
modeInfo.textContent = '文件将通过服务器转发,服务器需支持大文件上传(Vercel 限制 4.5MB)'
modeInfo.className = 'warning-text'
}
})
})
function main() {
fileInput.addEventListener('change', function (e) {
const files = this.files
if (files.length === 0) return
if (isUploading) return
uploadFilesSequentially(Array.from(files))
this.value = ''
})
}
main()
async function uploadFilesSequentially(files) {
isUploading = true
uploadBtn.classList.add('disabled')
progressSection.classList.add('active')
progressSection.innerHTML = ''
for (let i = 0; i < files.length; i++) {
if (uploadMode === 'direct') {
await uploadFileDirect(files[i], i + 1, files.length)
} else {
await uploadFileProxy(files[i], i + 1, files.length)
}
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js"></script>
<script src="https://fastly.jsdelivr.net/npm/vue"></script>
<script>
const app = Vue.createApp({
data() {
return {
songs: []
}
},
created() {
this.getData()
},
methods: {
getData() {
console.log('getdata');
const _this = this
axios({
url: `/user/cloud?time=${Date.now()}&cookie=${localStorage.getItem('cookie')}`,
}).then(res => {
console.log(res.data)
_this.songs = res.data.data
})
}
}
}).mount('#app')
const fileUpdateTime = {}
let fileLength = 0
isUploading = false
uploadBtn.classList.remove('disabled')
function main() {
document
.querySelector('input[type="file"]')
.addEventListener('change', function (e) {
console.log(this.files)
let currentIndx = 0
fileLength = this.files.length
for (const item of this.files) {
currentIndx += 1
upload(item, currentIndx)
}
})
}
main()
function upload(file, currentIndx) {
var formData = new FormData()
formData.append('songFile', file)
axios({
method: 'post',
url: `/cloud?time=${Date.now()}&cookie=${localStorage.getItem('cookie')}`,
headers: {
'Content-Type': 'multipart/form-data',
},
data: formData,
}).then(res => {
console.log(`${file.name} 上传成功`)
if (currentIndx >= fileLength) { console.log('上传完毕') }
app.getData()
}
function createProgressItem(file, index, total) {
const item = document.createElement('div')
item.className = 'progress-item'
item.id = `progress-${index}`
item.innerHTML = `
<div class="name">${file.name} (${formatSize(file.size)})</div>
<div class="status">准备中...</div>
<div class="progress-bar"><div class="fill"></div></div>
`
progressSection.appendChild(item)
return item
}
function updateProgress(index, status, percent, isError = false) {
const item = document.getElementById(`progress-${index}`)
if (!item) return
item.querySelector('.status').textContent = status
item.querySelector('.fill').style.width = `${percent}%`
if (isError) {
item.classList.add('error')
} else if (percent >= 100) {
item.classList.add('success')
}).catch(async err => {
console.log(err)
console.log(fileUpdateTime)
fileUpdateTime[file.name] ? fileUpdateTime[file.name] += 1 : fileUpdateTime[file.name] = 1
if (fileUpdateTime[file.name] >= 4) {
console.error(`丢,这首歌怎么都传不上:${file.name}`)
return
} else {
console.error(`${file.name} 失败 ${fileUpdateTime[file.name]} 次`)
}
}
// await login()
upload(file, currentIndx)
})
}
</script>
</body>
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
async function uploadFileProxy(file, index, total) {
createProgressItem(file, index, total)
try {
updateProgress(index, '上传中...', 10)
const formData = new FormData()
formData.append('songFile', file)
await axios({
method: 'post',
url: `/cloud?time=${Date.now()}&cookie=${localStorage.getItem('cookie')}`,
headers: {
'Content-Type': 'multipart/form-data',
},
data: formData,
onUploadProgress: (progressEvent) => {
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 90) + 10
updateProgress(index, `上传中... ${Math.round(progressEvent.loaded / progressEvent.total * 100)}%`, Math.min(percent, 100))
},
timeout: 600000,
})
updateProgress(index, '上传完成!', 100)
} catch (err) {
console.error(`${file.name} 上传失败:`, err)
const errorMsg = err.response?.data?.msg || err.message || '未知错误'
if (err.response?.status === 413 || errorMsg.includes('PAYLOAD_TOO_LARGE')) {
updateProgress(index, '文件过大,请切换到客户端直传模式', 0, true)
} else {
updateProgress(index, `上传失败: ${errorMsg}`, 0, true)
}
}
}
async function calculateMD5(file) {
return new Promise((resolve, reject) => {
const chunkSize = 2 * 1024 * 1024
const chunks = Math.ceil(file.size / chunkSize)
let currentChunk = 0
const spark = new SparkMD5.ArrayBuffer()
const reader = new FileReader()
reader.onload = (e) => {
spark.append(e.target.result)
currentChunk++
if (currentChunk < chunks) {
loadNext()
} else {
resolve(spark.end())
}
}
reader.onerror = () => reject(reader.error)
function loadNext() {
const start = currentChunk * chunkSize
const end = Math.min(start + chunkSize, file.size)
reader.readAsArrayBuffer(file.slice(start, end))
}
loadNext()
})
}
async function parseMediaTags(file) {
return new Promise((resolve) => {
jsmediatags.read(file, {
onSuccess: function(tag) {
resolve({
title: tag.tags.title || null,
artist: tag.tags.artist || null,
album: tag.tags.album || null,
})
},
onError: function() {
resolve({ title: null, artist: null, album: null })
}
})
})
}
async function uploadFileDirect(file, index, total) {
createProgressItem(file, index, total)
try {
updateProgress(index, '计算文件MD5...', 5)
const md5 = await calculateMD5(file)
const fileSize = file.size
const filename = file.name
updateProgress(index, '解析音频元数据...', 8)
const mediaTags = await parseMediaTags(file)
updateProgress(index, '获取上传凭证...', 10)
const tokenRes = await axios({
method: 'post',
url: `/cloud/upload/token?time=${Date.now()}`,
data: {
cookie: localStorage.getItem('cookie'),
md5: md5,
fileSize: fileSize,
filename: filename,
},
})
if (tokenRes.data.code !== 200) {
throw new Error(tokenRes.data.msg || '获取上传凭证失败')
}
const tokenData = tokenRes.data.data
if (!tokenData.needUpload) {
updateProgress(index, '文件已存在,直接导入云盘...', 80)
await completeUpload(tokenData, file, mediaTags)
updateProgress(index, '上传完成!', 100)
return
}
updateProgress(index, '开始上传到云存储...', 15)
await axios({
method: 'post',
url: tokenData.uploadUrl,
headers: {
'x-nos-token': tokenData.uploadToken,
'Content-MD5': md5,
'Content-Type': 'audio/mpeg',
'Content-Length': String(fileSize),
},
data: file,
onUploadProgress: (progressEvent) => {
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 70) + 15
updateProgress(index, `上传中... ${Math.round(progressEvent.loaded / progressEvent.total * 100)}%`, Math.min(percent, 85))
},
maxContentLength: Infinity,
maxBodyLength: Infinity,
timeout: 600000,
})
updateProgress(index, '上传完成,正在导入云盘...', 90)
await completeUpload(tokenData, file, mediaTags)
updateProgress(index, '上传完成!', 100)
} catch (err) {
console.error(`${file.name} 上传失败:`, err)
const errorMsg = err.response?.data?.msg || err.message || '未知错误'
updateProgress(index, `上传失败: ${errorMsg}`, 0, true)
}
}
async function completeUpload(tokenData, file, mediaTags = {}) {
const songName = mediaTags.title || file.name.replace(/\.[^.]+$/, '')
const artist = mediaTags.artist || '未知艺术家'
const album = mediaTags.album || '未知专辑'
const completeRes = await axios({
method: 'post',
url: `/cloud/upload/complete?time=${Date.now()}`,
data: {
cookie: localStorage.getItem('cookie'),
songId: tokenData.songId,
resourceId: tokenData.resourceId,
md5: tokenData.md5,
filename: file.name,
song: songName,
artist: artist,
album: album,
},
})
if (completeRes.data.code !== 200) {
throw new Error(completeRes.data.msg || '导入云盘失败')
}
return completeRes.data
}
</script>
<script src="https://fastly.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
</body>
</html>
+5 -5
View File
@@ -1,13 +1,13 @@
# 网易云音乐 API Enhanced
# 网易云音乐 API
> 🔍 网易云音乐API Node.js服务的复兴项目
> 网易云音乐 NodeJS 版 API
- 基于原版网易云API新增更多有趣的功能
- 全部接口已升级到最新
- 具备登录接口,多达200多个接口
- 更完善的文档
[Github](https://github.com/neteasecloudmusicapienhanced/api-enhanced)
[Get Started](#neteasecloudmusicapienhanced)
[GitLab](https://gitlab.com/Binaryify/NeteaseCloudMusicApi)
[Get Started](#neteasecloudmusicapi)
![color](#ffffff)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 58 KiB

+656 -576
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 858 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+10 -92
View File
@@ -3,56 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="KEYWords" contect="网易云音乐,网易云音乐 api,网易云音乐 nodejs,网易云音乐 node.js">
<meta name="description" content="网易云音乐 NodeJS API Enhanced">
<title>网易云音乐 NodeJS API Enhanced</title>
<link rel="icon" href="netease.png">
<meta name="description" content="网易云音乐 NodeJS 版 API">
<title>网易云音乐 NodeJS 版 API</title>
<link rel="icon" href="favicon.ico">
<meta name="description" content="Description">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta name="referrer" content="never">
<link rel="stylesheet" href="//unpkg.com/docsify/lib/themes/vue.css" id="theme-css">
<style>
.theme-toggle {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 999;
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid rgba(255,255,255,0.1);
background: rgba(30,30,46,0.75);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: #fff;
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
transition: all 0.3s cubic-bezier(0.4,0,0.2,1);
outline: none;
padding: 0;
line-height: 1;
user-select: none;
}
.theme-toggle:hover {
transform: scale(1.12) rotate(12deg);
box-shadow: 0 4px 20px rgba(0,0,0,0.25);
}
.theme-toggle:active {
transform: scale(0.92);
}
.theme-toggle.light {
background: rgba(255,255,255,0.85);
color: #333;
border-color: rgba(0,0,0,0.08);
}
html, body, .sidebar, .content, .markdown-section,
.sidebar-nav, .search, .docsify-pagination-container {
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
</style>
<link rel="stylesheet" href="//unpkg.com/docsify/lib/themes/vue.css">
<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>
<script>
(adsbygoogle = window.adsbygoogle || []).push({
@@ -63,52 +20,13 @@
</head>
<body>
<div id="app"></div>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle theme">🌙</button>
</body>
<script>
(function() {
var STORAGE_KEY = 'docsify-theme';
var link = document.getElementById('theme-css');
var btn = document.getElementById('themeToggle');
var LIGHT_URL = '//unpkg.com/docsify/lib/themes/vue.css';
var DARK_URL = '//unpkg.com/docsify/lib/themes/dark.css';
function setTheme(theme) {
link.href = theme === 'dark' ? DARK_URL : LIGHT_URL;
btn.textContent = theme === 'dark' ? '☀️' : '🌙';
btn.className = 'theme-toggle' + (theme === 'dark' ? '' : ' light');
localStorage.setItem(STORAGE_KEY, theme);
}
function toggleTheme() {
var isDark = link.href.includes('dark');
setTheme(isDark ? 'light' : 'dark');
}
var saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
setTheme(saved);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
setTheme('dark');
}
btn.addEventListener('click', toggleTheme);
try {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
if (!localStorage.getItem(STORAGE_KEY)) {
setTheme(e.matches ? 'dark' : 'light');
}
});
} catch(e) {}
})();
</script>
<script>
window.$docsify = {
name: '网易云音乐 API Enhanced',
repo: 'https://github.com/neteasecloudmusicapienhanced/api-enhanced',
name: '网易云音乐 API',
repo: 'https://gitlab.com/Binaryify/NeteaseCloudMusicApi',
coverpage: true,
homepage: 'https://cdn.jsdelivr.net/gh/NeteaseCloudMusicApiEnhanced/api-enhanced@main/public/docs/home.md',
homepage: 'home.md',
}
</script>
<script src="https://unpkg.com/docsify@4.11.3/lib/docsify.min.js"></script>
@@ -118,12 +36,12 @@
}
</script>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-BPRGR23JEG"></script>
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-139996012-1"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-BPRGR23JEG');
gtag('config', 'UA-139996012-1');
</script>
</html>
-6
View File
@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M10.421 11.375c-.294 1.028.012 2.064.784 2.653 1.061.81 2.565.3 2.874-.995.08-.337.103-.722.027-1.056-.23-1.001-.52-1.988-.792-2.996-1.33.154-2.543 1.172-2.893 2.394zm5.548-.287c.273 1.012.285 2.017-.127 3-1.128 2.69-4.721 3.14-6.573.826-1.302-1.627-1.28-3.961.06-5.734.78-1.032 1.804-1.707 3.048-2.054l.379-.104c-.084-.415-.188-.816-.243-1.224-.176-1.317.512-2.503 1.744-3.04 1.226-.535 2.708-.216 3.53.76.406.479.395 1.08-.025 1.464-.412.377-.996.346-1.435-.09-.247-.246-.51-.44-.877-.436-.525.006-.987.418-.945.937.037.468.173.93.3 1.386.022.078.216.135.338.153 1.334.197 2.504.731 3.472 1.676 2.558 2.493 2.861 6.531.672 9.44-1.529 2.032-3.61 3.168-6.127 3.409-4.621.44-8.664-2.53-9.7-7.058C2.515 10.255 4.84 5.831 8.795 4.25c.586-.234 1.143-.031 1.371.498.232.537-.019 1.086-.61 1.35-2.368 1.06-3.817 2.855-4.215 5.424-.533 3.433 1.656 6.776 5 7.72 2.723.77 5.658-.166 7.308-2.33 1.586-2.08 1.4-5.099-.427-6.873a3.979 3.979 0 0 0-1.823-1.013c.198.716.389 1.388.57 2.062z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

+49 -182
View File
@@ -1,186 +1,62 @@
<!DOCTYPE html>
<html lang="zh">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>eapi 参数和返回内容解析</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
h1 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 24px;
}
label {
display: block;
font-size: 14px;
font-weight: 500;
color: #555;
margin-bottom: 8px;
}
textarea {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 13px;
resize: vertical;
min-height: 200px;
outline: none;
}
textarea:focus {
border-color: #333;
}
.radio-group {
display: flex;
gap: 24px;
margin-bottom: 24px;
}
.radio-item {
display: flex;
align-items: center;
gap: 8px;
}
.radio-item input[type="radio"] {
cursor: pointer;
}
.radio-item label {
margin: 0;
cursor: pointer;
font-size: 14px;
}
button {
background: #333;
color: white;
padding: 12px 28px;
border: none;
border-radius: 6px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
}
button + button {
margin-left: 12px;
}
button:hover {
background: #555;
}
.result-section {
margin-top: 24px;
}
.result-section label {
margin-bottom: 12px;
}
.decode-result {
white-space: pre-wrap;
word-break: break-all;
background: #f9f9f9;
padding: 16px;
border-radius: 6px;
border: 1px solid #eee;
min-height: 200px;
max-height: 400px;
overflow: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
}
.example-section {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #eee;
}
.example-section h2 {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
.example-section img {
max-width: 100%;
height: auto;
border-radius: 6px;
margin-bottom: 16px;
border: 1px solid #eee;
}
</style>
<link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
</head>
<style>
.decode-result {
white-space: pre-wrap;
word-break: break-all;
background-color: #f0f0f0;
padding: 10px;
border-radius: 5px;
margin-top: 10px;
height: 300px;
overflow: auto;
}
</style>
<body>
<div id="app" class="container">
<h1>eapi 参数和返回内容解析</h1>
<div class="form-group">
<label for="hexString">十六进制字符串</label>
<textarea id="hexString" v-model="hexString" rows="10"></textarea>
<div id="app" class="p-5 flex flex-col">
<h1 class="text-2xl font-bold mb-5">eapi 参数和返回内容解析</h1>
<textarea class="border border-gray-300 p-3 mb-5" v-model="hexString" rows="10"></textarea>
<div class="flex gap-3 mb-4">
<button @click="decrypt" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
解密
</button>
<button
@click="sendToApi"
:disabled="!canSend"
:class="[
'bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded',
{ 'opacity-50 cursor-not-allowed pointer-events-none': !canSend },
]"
>
填入 API 调试
</button>
</div>
<div class="mt-3">
<input type="radio" id="format" name="format" v-model="isReq" value="true">
<label for="format" class="ml-2">请求数据request params(针对请求数据的 params)</label>
<input type="radio" id="noFormat" name="format" v-model="isReq" value="false" class="ml-5">
<label for="noFormat" class="ml-2">返回数据 response 二进制数据(针对返回内容解析)</label>
</div>
<div>
<p>解密结果:
<pre class="decode-result">{{ JSON.stringify(JSON.parse(result), null, 2) }}</pre>
</p>
</div>
<div class="radio-group">
<div class="radio-item">
<input type="radio" id="req" name="format" v-model="isReq" value="true">
<label for="req">请求数据 request params</label>
</div>
<div class="radio-item">
<input type="radio" id="resp" name="format" v-model="isReq" value="false">
<label for="resp">返回数据 response 二进制数据</label>
</div>
</div>
<button @click="decrypt">解密</button>
<button @click="sendToApi" :disabled="!canSend" :class="[{ 'opacity-50 cursor-not-allowed pointer-events-none': !canSend },]">填入 API 调试</button>
<div class="result-section">
<label>解密结果:</label>
<pre class="decode-result">{{ formatResult(result) }}</pre>
</div>
<div class="example-section">
<h2>使用示例</h2>
<img src="/static/eapi_params.png" alt="请求示例" />
<img src="/static/eapi_response.png" alt="响应示例" />
<div>
<p>使用例子:</p>
<img src="/static/eapi_params.png" />
<img src="/static/eapi_response.png" />
</div>
</div>
@@ -215,15 +91,6 @@
},
},
methods: {
formatResult(value) {
if (value == null || value === '') return ''
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value
return JSON.stringify(parsed, null, 2)
} catch (error) {
return String(value)
}
},
async decrypt() {
try {
const res = await axios({
@@ -262,4 +129,4 @@
</script>
</body>
</html>
</html>
+34 -30
View File
@@ -1,40 +1,44 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>home</title>
</head>
<body>
<div>
<a href="/qrlogin-nocookie.html"> 如果没登录,请先登录 </a>
</div>
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js"></script>
<script>
async function main() {
const res = await axios({
url: `/homepage/block/page`,
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>home</title>
</head>
<body>
<div>
<a href="/qrlogin-nocookie.html">
如果没登录,请先登录
</a>
</div>
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js"></script>
<script>
async function main() {
const res = await axios({
url: `/homepage/block/page`,
data: {
cookie: localStorage.getItem('cookie'),
},
method: 'post',
})
let cursor = ''
console.log(res.data.data)
if (res.data.data.hasMore) {
cursor = res.data.data.cursor
const res2 = await axios({
url: `/homepage/block/page?time=${Date.now()}`,
data: {
cookie: localStorage.getItem('cookie'),
cursor: cursor,
},
method: 'post',
})
let cursor = ''
console.info(res.data.data)
if (res.data.data.hasMore) {
cursor = res.data.data.cursor
const res2 = await axios({
url: `/homepage/block/page?time=${Date.now()}`,
data: {
cookie: localStorage.getItem('cookie'),
cursor: cursor,
},
method: 'post',
})
}
}
main()
</script>
</body>
}
main()
</script>
</body>
</html>
+119 -106
View File
@@ -1,125 +1,138 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<link rel="icon" href="docs/netease.png">
<title>网易云音乐 API Enhanced</title>
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>网易云音乐 API</title>
<style>
:root {
--fg: #333;
--muted: #666;
--border: #ddd;
--bg: #f5f5f5;
--panel: #ffffff;
--accent: #333;
html, body {
height: 100vh;
width: 100vw;
margin: 0;
padding: 0;
background: #f9f9f9;
font-family: Arial, sans-serif;
color: #333;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: var(--fg); background: var(--bg); line-height: 1.6; }
.container { max-width: 960px; margin: 40px auto; padding: 0 20px; }
@media (max-width: 480px) {
.container { margin: 20px auto; padding: 0 16px; }
header.site-header h1 { font-size: 22px; }
.block { padding: 16px; }
h1 {
font-size: 2.5em;
margin-bottom: 1em;
color: #42b983;
}
header.site-header { margin-bottom: 24px; }
header.site-header h1 { font-size: 28px; font-weight: 600; margin: 0; }
.badge { display: inline-block; margin-left: 8px; padding: 4px 10px; border: 1px solid var(--border); border-radius: 12px; font-size: 12px; color: var(--muted); }
.sub { margin-top: 8px; color: var(--muted); font-size: 14px; }
.block { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); }
.block h2 { margin: 0 0 12px; font-size: 18px; font-weight: 600; }
.kvs { display: grid; grid-template-columns: 100px 1fr; gap: 8px 12px; align-items: start; }
.kvs div:first-child { color: var(--muted); flex-shrink: 0; }
.kvs div:last-child { word-break: break-all; overflow-wrap: anywhere; min-width: 0; overflow: hidden; }
@media (max-width: 480px) {
.kvs { grid-template-columns: 1fr; gap: 4px 12px; }
.kvs div:first-child { font-weight: 500; }
p {
font-size: 1.2em;
margin-bottom: 2em;
color: #666;
}
ul.links { list-style: none; padding: 0; margin: 0; }
ul.links li { margin: 8px 0; }
ul.links a { color: var(--fg); text-decoration: none; border-bottom: 1px dotted var(--border); transition: all 0.2s ease; }
ul.links a:hover { color: var(--accent); border-bottom-color: var(--accent); }
pre { margin: 0; background: #f9f9f9; border: 1px solid var(--border); border-radius: 6px; padding: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
code { font-family: 'Courier New', monospace; font-size: 13px; }
@media (max-width: 480px) {
code { font-size: 12px; }
a {
color: #42b983;
text-decoration: none;
transition: color 0.3s ease;
}
a:hover {
color: #329d6e;
}
ul {
list-style: none;
padding: 0;
margin: 0;
text-align: left;
max-width: 600px;
width: 100%;
}
li {
margin: 0.5em 0;
line-height: 1.5;
display: flex;
align-items: center;
}
.number {
margin-right: 10px;
color: #999;
font-weight: bold;
}
.container {
background: #fff;
padding: 2em;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
max-height: 80vh; /* 设置最大高度 */
overflow-y: auto; /* 启用垂直滚动条 */
}
.version {
margin-top: 0.5em;
font-size: 1.0em;
color: #666;
}
footer.site-footer { margin-top: 24px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); text-align: center; }
footer.site-footer a { color: var(--fg); text-decoration: none; transition: color 0.2s ease; }
footer.site-footer a:hover { color: var(--accent); }
</style>
</head>
<body>
<main class="container">
<header class="site-header">
<h1>网易云音乐 API Enhanced <span id="api-version" class="badge"></span></h1>
<p class="sub">🔍 A revival project for NeteaseCloudMusicApi Node.js Api Services || 网易云音乐 API 备份 + 增强 || 本项目自原版v4.28.0版本后开始自行维护</p>
</header>
<section class="block">
<h2>状态</h2>
<div class="kvs">
<div>Base URL</div><div id="base-url">—</div>
<div>当前页</div><div id="current-url">—</div>
</div>
</section>
<section class="block">
<h2>文档</h2>
<p><a href="/docs" target="_blank">查看在线文档</a></p>
</section>
<section class="block">
<h2>常用接口</h2>
<ul class="links">
<li><a href="/search?keywords=妖精小姐的魔法邀约">搜索音乐: <code>GET /search</code></a></li>
<li><a href="/song/detail?ids=2756058128">获取音乐详情: <code>GET /song/detail</code></a></li>
<li><a href="/comment/music?id=2756058128&limit=1">获取音乐评论: <code>GET /comment/music</code></a></li>
<li><a href="/song/url/v1?id=2756058128&level=exhigh">获取音乐播放链接: <code>GET /song/url/v1</code></a></li>
</ul>
</section>
<section class="block">
<h2>调试部分</h2>
<pre><code>curl -s {origin}/inner/version
curl -s {origin}/search?keywords=网易云</code></pre>
<div style="margin-top:10px; line-height:2;">
<a href="/api.html">交互式调试</a> ·
<a href="/qrlogin.html">二维码登录示例</a> ·
<a href="/unblock_test.html">解灰测试</a> ·
<a href="/audio_match_demo/index.html">听歌识曲 Demo</a> ·
<a href="/cloud.html">云盘上传</a> ·
<a href="/playlist_import.html">歌单导入</a> ·
<a href="/eapi_decrypt.html">EAPI 解密</a> ·
<a href="/listen_together_host.html">一起听示例</a> ·
<a href="/playlist_cover_update.html">更新歌单封面示例</a> ·
<a href="/avatar_update.html">头像更新示例</a>
</div>
</section>
<footer class="site-footer">
<a href="https://github.com/neteasecloudmusicapienhanced/api-enhanced" target="_blank">GitHub</a>
</footer>
</main>
<div class="container">
<h1>网易云音乐 API 二改<span id="api-version"></span></h1>
<p>当你看到这个页面时,这个服务已经成功跑起来了~</p>
<p>本项目基于原版API二改, 添加了解灰接口</p>
<p>本项目更新是及时的</p>
<p class="current-url"><span id="current-url"></span></p>
<a href="/docs">查看文档</a>
<h2>例子:</h2>
<ul id="example-list">
<li><a href="./search?keywords=海阔天空">搜索</a></li>
<li><a href="./comment/music?id=186016&limit=1">歌曲评论</a></li>
<li><a href="./dj/program?rid=336355127">电台节目</a></li>
<li><a href="./qrlogin.html">二维码登录</a></li>
<li><a href="./audio_match_demo/index.html">听歌识曲</a></li>
<li><a href="./cloud.html">云盘上传</a></li>
<li><a href="./eapi_decrypt.html">eapi 参数/返回值解析</a></li>
<li><a href="./api.html">API 调试界面</a></li>
<li><a href="./playlist_import.html">歌单导入工具</a></li>
</ul>
</div>
<script src="https://fastly.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
var origin = window.location.origin;
document.getElementById('base-url').textContent = origin;
document.getElementById('current-url').textContent = window.location.href;
document.addEventListener('DOMContentLoaded', function() {
const currentUrlElement = document.getElementById('current-url');
currentUrlElement.textContent = window.location.href;
fetch('/inner/version', { method: 'POST' })
.then(function (r) { return r.json(); })
.then(function (data) {
var v = data && data.data && data.data.version;
if (v) document.getElementById('api-version').textContent = 'v' + v;
var pre = document.querySelector('pre code');
if (pre) pre.textContent = pre.textContent.replace(/\{origin\}/g, origin);
})
.catch(function () {});
const exampleList = document.getElementById('example-list');
const items = exampleList.getElementsByTagName('li');
for (let i = 0; i < items.length; i++) {
const number = document.createElement('span');
number.className = 'number';
number.textContent = (i + 1) + '. ';
items[i].prepend(number);
}
// 获取API版本号
axios({
url: '/inner/version',
method: 'post',
data: {},
}).then((res) => {
const body = res.data;
const version = body.data.version;
const apiVersionElement = document.getElementById('api-version');
apiVersionElement.textContent = ` v${version}`;
});
});
</script>
</body>
</html>
+282 -516
View File
@@ -1,481 +1,159 @@
<!-- eslint-disable prettier/prettier -->
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>一起听 - 主机模式</title>
<script src="https://unpkg.com/petite-vue"></script>
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
h1 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
.login-link {
display: block;
margin-bottom: 24px;
color: #666;
font-size: 14px;
text-decoration: none;
}
.login-link:hover {
color: #333;
text-decoration: underline;
}
.message {
padding: 12px 16px;
background: #f9f9f9;
border-radius: 6px;
margin-bottom: 20px;
font-size: 14px;
color: #555;
}
.audio-player {
width: 100%;
margin-bottom: 20px;
}
.btn {
background: #333;
color: white;
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
margin-right: 8px;
margin-bottom: 8px;
}
.btn:hover {
background: #555;
}
.section {
margin-bottom: 24px;
padding: 20px;
background: #f9f9f9;
border-radius: 6px;
}
.section h3 {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
}
input[type="text"], input[type="number"] {
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
outline: none;
width: 200px;
}
input:focus {
border-color: #333;
}
.input-group {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.input-group label {
font-size: 14px;
color: #555;
min-width: 80px;
}
.share-link {
padding: 12px;
background: #f0f0f0;
border-radius: 6px;
font-size: 13px;
color: #666;
word-break: break-all;
margin-bottom: 12px;
}
.user-list {
list-style: none;
max-height: 200px;
overflow-y: auto;
}
.user-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
border-bottom: 1px solid #eee;
}
.user-item:last-child {
border-bottom: none;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.user-name {
font-size: 14px;
color: #333;
}
.track-list {
list-style: none;
max-height: 300px;
overflow-y: auto;
}
.track-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s ease;
}
.track-item:hover {
background: #f5f5f5;
}
.track-item:last-child {
border-bottom: none;
}
.track-cover {
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: cover;
}
.track-name {
font-size: 14px;
color: #333;
}
details {
cursor: pointer;
}
details summary {
font-size: 14px;
font-weight: 500;
color: #555;
padding: 8px 0;
}
details summary:hover {
color: #333;
}
details[open] summary {
margin-bottom: 12px;
}
.control-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>
</head>
<body class="container">
<a href="/qrlogin.html" class="login-link">还没登录?点击登录</a>
<h1>一起听 - 主机模式</h1>
<div class="message">消息: {{message}}</div>
<audio id="player" class="audio-player" autoplay controls></audio>
<div v-if="!account.login">
<button class="btn" @click="login">获取登录状态</button>
</div>
<div class="section">
<h3>账号信息</h3>
<div>当前登录账号: {{account.nickname}}</div>
</div>
<div v-if="account.login" class="section">
<h3>房间管理</h3>
<div class="control-buttons">
<button v-if="!roomInfo.roomId" class="btn" @click="createRoom">创建房间</button>
</div>
<details>
<summary>加入房间</summary>
<div class="input-group">
<label>房间ID:</label>
<input v-model="roomInfo.roomId" type="text" />
</div>
<div class="input-group">
<label>邀请者ID:</label>
<input v-model="roomInfo.inviterId" type="text" />
</div>
<button class="btn" @click="joinRoom">加入房间</button>
</details>
<div v-if="roomInfo.roomId" style="margin-top: 16px;">
<h4>分享链接</h4>
<div class="share-link">
https://st.music.163.com/listen-together/share/?songId=1372188635&roomId={{roomInfo.roomId}}&inviterId={{roomInfo.inviterId}}
</div>
<button class="btn" @click="refreshRoom">刷新房间状态</button>
<button class="btn" @click="closeRoom">关闭房间</button>
<h4 style="margin-top: 16px;">在线用户</h4>
<ul class="user-list">
<li v-for="user in roomInfo.roomUsers" :key="user.userId" class="user-item">
<img :src="user.avatarUrl" class="user-avatar" alt="avatar" />
<span class="user-name">{{user.nickname}}</span>
</li>
</ul>
</div>
</div>
<div class="section">
<h3>播放控制</h3>
<div class="control-buttons">
<button class="btn" @click="playTrack">播放</button>
<button class="btn" @click="pauseTrack">暂停</button>
<button class="btn" @click="seekTrack">同步进度</button>
</div>
</div>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>一起听</title>
<script src="https://unpkg.com/petite-vue"></script>
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/mdui@1.0.2/dist/css/mdui.min.css" />
<script src="https://unpkg.com/mdui@1.0.2/dist/js/mdui.min.js"></script>
</head>
<body class="mdui-container">
<div>
<a href="/qrlogin.html">
如果没登录,请先登录
</a>
</div>
<h1>一起听 - 主机模式</h1>
<div>消息: {{message}}</div>
<audio id="player" autoplay controls>
</audio>
<br />
<br />
<button v-if="!account.login" @click="login">获取登录状态</button>
<div>您的当前登录账号为: {{account.nickname}}</div>
<br />
<div v-if="account.login">
<button v-if="!roomInfo.roomId" @click="createRoom">创建房间</button>
<details>
<summary>播放列表</summary>
<div class="section">
<div class="input-group">
<label>歌单ID:</label>
<input v-model="playlistInfo.playlistId" type="text" />
</div>
<button class="btn" @click="loadPlaylist">加载歌单</button>
<div style="margin-top: 12px; font-size: 14px; color: #555;">
歌单名称: {{playlistInfo.playlistName}}
</div>
<h4 style="margin-top: 16px;">歌单内容</h4>
<ul class="track-list">
<li
@click="gotoTrack(track.id)"
v-for="track in playlistInfo.playlistTracks"
:key="track.id"
class="track-item"
>
<img :src="track.al.picUrl" class="track-cover" alt="cover" />
<span class="track-name">{{track.name}}</span>
</li>
</ul>
</div>
<summary>加入房间</summary>
<div><span>房间ID: </span><input v-model="roomInfo.roomId" /></div>
<div><span>邀请者 ID: </span><input v-model="roomInfo.inviterId" /></div>
<button @click="joinRoom">点击加入</button>
</details>
</body>
<script>
PetiteVue.createApp({
message: '请点击获取登录状态',
account: {
login: false,
userId: 0,
nickname: '未登录',
},
roomInfo: {
roomId: null,
inviterId: 0,
roomUsers: [],
},
playlistInfo: {
playlistId: 0,
playlistName: '未获取',
playlistTrackIds: [],
playlistTracks: [],
},
playingInfo: {
trackId: 0,
status: 'PLAY',
progress: 1,
},
clientSeq: 1,
login: async function () {
const res = await axios({
url: `/login/status?timestamp=${new Date().getTime()}`,
method: 'get',
data: {
cookie: localStorage.getItem('cookie'),
},
})
if (res.data.data.code != 200) {
alert('请先使用登录 API 登录到网易云音乐')
} else {
this.account.userId = res.data.data.profile.userId
this.account.nickname = res.data.data.profile.nickname
this.account.login = true
this.message = '成功登录,请创建房间'
}
},
joinRoom: async function () {
const res = await axios({
url: 'listentogether/accept',
method: 'post',
data: {
roomId: this.roomInfo.roomId,
inviterId: this.roomInfo.inviterId,
cookie: localStorage.getItem('cookie'),
},
})
console.info(res)
if (res.data.code != 200) {
this.message = '加入房间出现问题: ' + res.data.message
} else {
this.message = '加入房间成功: ' + this.roomInfo.roomId
const res2 = await axios({
url: 'listentogether/room/check',
method: 'post',
data: {
roomId: this.roomInfo.roomId,
cookie: localStorage.getItem('cookie'),
},
})
console.info(res2)
const res3 = await axios({
url: 'listentogether/sync/playlist/get',
method: 'post',
data: {
roomId: this.roomInfo.roomId,
cookie: localStorage.getItem('cookie'),
},
})
<div v-if="roomInfo.roomId">
<div>分享链接为:
https://st.music.163.com/listen-together/share/?songId=1372188635&roomId={{roomInfo.roomId}}&inviterId={{roomInfo.inviterId}}
</div>
<br />
<button @click="refreshRoom">刷新房间状态</button>
<div>在线用户: </div>
<ul class="mdui-list">
<li v-for="user in roomInfo.roomUsers" class="mdui-list-item mdui-ripple">
<div class="mdui-list-item-avatar">
<img :src="user.avatarUrl" />
</div>
<div class="mdui-list-item-content">{{user.nickname}}</div>
</li>
</ul>
<button v-if="roomInfo.roomId" @click="closeRoom">关闭房间</button>
</div>
</div>
<button @click="playTrack">播放</button>
<button @click="pauseTrack">暂停</button>
<button @click="seekTrack">同步进度</button>
<details>
<summary>播放列表</summary>
<br />
<div><span>歌单ID: </span><input v-model="playlistInfo.playlistId" /></div>
<button @click="loadPlaylist">加载歌单到播放列表</button>
<span>{{playlistInfo.playlistName}}</span>
<br />
<br />
<div>歌单内容: </div>
<ul class="mdui-list">
<li @click="gotoTrack(track.id)" v-for="track in playlistInfo.playlistTracks" class="mdui-list-item mdui-ripple">
<div class="mdui-list-item-avatar">
<img :src="track.al.picUrl" />
</div>
<div class="mdui-list-item-content">{{track.name}}</div>
</li>
</ul>
</details>
this.playlistInfo.playlistName = '其他人的歌单'
this.playlistInfo.playlistTrackIds =
res3.data.data.playlist.displayList.result.join(',')
const resa = await axios({
url: '/song/detail',
method: 'post',
data: {
ids: this.playlistInfo.playlistTrackIds,
cookie: localStorage.getItem('cookie'),
},
})
console.info(resa)
this.playlistInfo.playlistTracks = resa.data.songs
}
},
createRoom: async function () {
const res = await axios({
url: 'listentogether/room/create',
method: 'get',
data: {
cookie: localStorage.getItem('cookie'),
},
})
console.info(res)
if (res.data.code != 200) {
this.message = '创建房间出现问题: ' + res.data.message
} else {
this.message = '创建房间成功: ' + res.data.data.roomInfo.roomId
this.roomInfo.inviterId = this.account.userId
this.roomInfo.roomId = res.data.data.roomInfo.roomId
const res2 = await axios({
url: 'listentogether/room/check',
method: 'post',
data: {
roomId: this.roomInfo.roomId,
cookie: localStorage.getItem('cookie'),
},
})
console.info(res2)
}
},
refreshRoom: async function () {
const res = await axios({
url: '/listentogether/status',
data: {
cookie: localStorage.getItem('cookie'),
},
})
console.info(res)
if (res.data.code != 200 || !res.data.data.inRoom) {
this.message = '房间状态获取失败,可能退出了房间'
} else {
this.roomInfo.roomUsers = res.data.data.roomInfo.roomUsers
}
},
closeRoom: async function () {
const res = await axios({
url: '/listentogether/end',
</body>
<script>
PetiteVue.createApp({
message: '请点击获取登录状态',
account: {
login: false,
userId: 0,
nickname: '未登录',
},
roomInfo: {
roomId: null,
inviterId: 0,
roomUsers: [],
},
playlistInfo: {
playlistId: 0,
playlistName: '未获取',
playlistTrackIds: [],
playlistTracks: [],
},
playingInfo: {
trackId: 0,
status: 'PLAY',
progress: 1,
},
clientSeq: 1,
login: async function () {
const res = await axios({
url: `/login/status?timestamp=${new Date().getTime()}`,
method: 'get',
data: {
cookie: localStorage.getItem('cookie'),
},
})
if (res.data.data.code != 200) {
alert('请先使用登录 API 登录到网易云音乐')
} else {
this.account.userId = res.data.data.profile.userId
this.account.nickname = res.data.data.profile.nickname
this.account.login = true
this.message = '成功登录, 请创建房间'
}
},
joinRoom: async function () {
const res = await axios({
url: 'listentogether/accept',
method: 'post',
data: {
roomId: this.roomInfo.roomId,
inviterId: this.roomInfo.inviterId,
cookie: localStorage.getItem('cookie'),
},
})
console.log(res)
if (res.data.code != 200) {
this.message = '加入房间出现问题: ' + res.data.message
} else {
this.message = '加入房间成功: ' + this.roomInfo.roomId
const res2 = await axios({
url: 'listentogether/room/check',
method: 'post',
data: {
roomId: this.roomInfo.roomId,
cookie: localStorage.getItem('cookie'),
},
})
console.info(res)
if (res.data.code != 200 || !res.data.data.success) {
this.message = '房间关闭失败'
} else {
this.message = '房间关闭成功'
this.roomInfo.roomId = null
}
},
loadPlaylist: async function () {
const res = await axios({
url: '/playlist/detail',
console.log(res2)
const res3 = await axios({
url: 'listentogether/sync/playlist/get',
method: 'post',
data: {
id: this.playlistInfo.playlistId,
roomId: this.roomInfo.roomId,
cookie: localStorage.getItem('cookie'),
},
})
console.info(res)
this.playlistInfo.playlistName = res.data.playlist.name
this.playlistInfo.playlistTrackIds = res.data.playlist.trackIds
.map((track) => track.id)
this.playlistInfo.playlistName = "其他人的歌单"
this.playlistInfo.playlistTrackIds = res3.data.data.playlist.displayList.result
.join(',')
const resa = await axios({
url: '/song/detail',
@@ -485,68 +163,156 @@
cookie: localStorage.getItem('cookie'),
},
})
console.info(resa)
console.log(resa)
this.playlistInfo.playlistTracks = resa.data.songs
if (this.roomInfo.roomId) {
const resb = await axios({
url: 'listentogether/sync/list/command',
method: 'post',
data: {
roomId: this.roomInfo.roomId,
commandType: 'REPLACE',
userId: this.account.userId,
version: this.clientSeq++,
playMode: 'ORDER_LOOP',
displayList: this.playlistInfo.playlistTrackIds,
randomList: this.playlistInfo.playlistTrackIds,
cookie: localStorage.getItem('cookie'),
},
})
console.info(resb)
}
},
gotoTrack: async function (trackId) {
this.playingInfo.trackId = trackId
if (this.roomInfo.roomId) {
await this.playCommand('GOTO')
}
document.getElementById('player').src =
'https://music.163.com/song/media/outer/url?id=' + trackId + '.mp3'
},
playTrack: async function () {
this.playingInfo.status = 'PLAY'
await this.playCommand('PLAY')
document.getElementById('player').play()
},
pauseTrack: async function () {
this.playingInfo.status = 'PAUSE'
await this.playCommand('PAUSE')
document.getElementById('player').pause()
},
seekTrack: async function () {
this.playingInfo.status = 'PLAY'
await this.playCommand('seek')
document.getElementById('player').play()
},
playCommand: async function (action) {
const res = await axios({
url: 'listentogether/play/command',
}
},
createRoom: async function () {
const res = await axios({
url: 'listentogether/room/create',
method: 'get',
data: {
cookie: localStorage.getItem('cookie'),
},
})
console.log(res)
if (res.data.code != 200) {
this.message = '创建房间出现问题: ' + res.data.message
} else {
this.message = '创建房间成功: ' + res.data.data.roomInfo.roomId
this.roomInfo.inviterId = this.account.userId
this.roomInfo.roomId = res.data.data.roomInfo.roomId
const res2 = await axios({
url: 'listentogether/room/check',
method: 'post',
data: {
roomId: this.roomInfo.roomId,
progress: Math.floor(
document.getElementById('player').currentTime * 1000,
),
commandType: action,
formerSongId: '-1',
targetSongId: this.playingInfo.trackId,
clientSeq: this.clientSeq++,
playStatus: this.playingInfo.status,
cookie: localStorage.getItem('cookie'),
},
})
console.info(res)
},
}).mount()
</script>
console.log(res2)
}
},
refreshRoom: async function () {
const res = await axios({
url: '/listentogether/status',
data: {
cookie: localStorage.getItem('cookie'),
},
})
console.log(res)
if (res.data.code != 200 || !res.data.data.inRoom) {
this.message = '房间状态获取失败, 可能退出了房间'
} else {
this.roomInfo.roomUsers = res.data.data.roomInfo.roomUsers
}
},
closeRoom: async function () {
const res = await axios({
url: '/listentogether/end',
method: 'post',
data: {
roomId: this.roomInfo.roomId,
cookie: localStorage.getItem('cookie'),
},
})
console.log(res)
if (res.data.code != 200 || !res.data.data.success) {
this.message = '房间关闭失败'
} else {
this.message = '房间关闭成功'
this.roomInfo.roomId = null
}
},
loadPlaylist: async function () {
const res = await axios({
url: '/playlist/detail',
method: 'post',
data: {
id: this.playlistInfo.playlistId,
cookie: localStorage.getItem('cookie'),
},
})
console.log(res)
this.playlistInfo.playlistName = res.data.playlist.name
this.playlistInfo.playlistTrackIds = res.data.playlist.trackIds
.map((track) => track.id)
.join(',')
const resa = await axios({
url: '/song/detail',
method: 'post',
data: {
ids: this.playlistInfo.playlistTrackIds,
cookie: localStorage.getItem('cookie'),
},
})
console.log(resa)
this.playlistInfo.playlistTracks = resa.data.songs
if (this.roomInfo.roomId) {
const resb = await axios({
url: 'listentogether/sync/list/command',
method: 'post',
data: {
roomId: this.roomInfo.roomId,
commandType: 'REPLACE',
userId: this.account.userId,
version: this.clientSeq++,
playMode: 'ORDER_LOOP',
displayList: this.playlistInfo.playlistTrackIds,
randomList: this.playlistInfo.playlistTrackIds,
cookie: localStorage.getItem('cookie'),
},
})
console.log(resb)
}
},
gotoTrack: async function (trackId) {
this.playingInfo.trackId = trackId
if (this.roomInfo.roomId) {
await this.playCommand('GOTO')
}
document.getElementById('player').src =
'https://music.163.com/song/media/outer/url?id=' + trackId + '.mp3'
},
playTrack: async function () {
this.playingInfo.status = 'PLAY'
await this.playCommand('PLAY')
document.getElementById('player').play()
},
pauseTrack: async function () {
this.playingInfo.status = 'PAUSE'
await this.playCommand('PAUSE')
document.getElementById('player').pause()
},
seekTrack: async function () {
this.playingInfo.status = 'PLAY'
await this.playCommand('seek')
document.getElementById('player').play()
},
playCommand: async function (action) {
const res = await axios({
url: 'listentogether/play/command',
method: 'post',
data: {
roomId: this.roomInfo.roomId,
progress: Math.floor(
document.getElementById('player').currentTime * 1000,
),
commandType: action,
formerSongId: '-1',
targetSongId: this.playingInfo.trackId,
clientSeq: this.clientSeq++,
playStatus: this.playingInfo.status,
cookie: localStorage.getItem('cookie'),
},
})
console.log(res)
},
}).mount()
</script>
</html>
+18 -190
View File
@@ -5,143 +5,21 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>登录</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
padding: 40px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-width: 400px;
width: 100%;
text-align: center;
}
h1 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.subtitle {
font-size: 14px;
color: #666;
margin-bottom: 32px;
}
.form-group {
margin-bottom: 20px;
text-align: left;
}
label {
display: block;
font-size: 14px;
font-weight: 500;
color: #555;
margin-bottom: 8px;
}
input {
width: 100%;
padding: 12px 14px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 15px;
outline: none;
}
input:focus {
border-color: #333;
}
.btn {
width: 100%;
padding: 14px;
background: #333;
color: white;
font-size: 15px;
font-weight: 500;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s ease;
}
.btn:hover {
background: #555;
}
.btn:disabled {
background: #999;
cursor: not-allowed;
}
.result {
margin-top: 24px;
padding: 16px;
background: #f9f9f9;
border-radius: 6px;
font-size: 13px;
color: #666;
text-align: left;
white-space: pre-wrap;
word-break: break-all;
}
.error {
color: #ef4444;
background: #fef2f2;
}
.success {
color: #10b981;
background: #f0fdf4;
}
</style>
</head>
<body>
<div class="container">
<h1>登录</h1>
<p class="subtitle">使用手机号和密码登录网易云音乐</p>
<div class="form-group">
<label for="phone">手机号</label>
<input type="tel" id="phone" placeholder="请输入手机号" />
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" placeholder="请输入密码" />
</div>
<button id="loginBtn" class="btn" onclick="handleLogin()">登录</button>
<div id="result" class="result" style="display: none;"></div>
</div>
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js"></script>
<script>
const phone = '' // 这里填手机号
const password = '' // 这里填密码
const fileUpdateTime = {}
if (!phone || !password) {
const msg = '请设置你的手机号码和密码'
alert(msg)
throw new Error(msg)
}
async function login(phone, password) {
async function login() {
const res = await axios({
url: `/login/cellphone`,
method: 'post',
@@ -152,67 +30,17 @@
})
return res.data.cookie
}
async function handleLogin() {
const phoneInput = document.getElementById('phone')
const passwordInput = document.getElementById('password')
const loginBtn = document.getElementById('loginBtn')
const resultDiv = document.getElementById('result')
const phone = phoneInput.value.trim()
const password = passwordInput.value
if (!phone || !password) {
showResult('请输入手机号和密码', 'error')
return
}
loginBtn.disabled = true
loginBtn.textContent = '登录中...'
showResult('正在登录...', 'info')
try {
const cookieToken = await login(phone, password)
localStorage.setItem('cookie', cookieToken)
const res = await axios({
url: `/login/status`,
method: 'post',
data: {
cookie: cookieToken,
},
})
showResult(`登录成功!\n${JSON.stringify(res.data, null, 2)}`, 'success')
} catch (error) {
console.error('登录失败:', error)
const errorMsg = error.response?.data?.message || error.message || '登录失败,请重试'
showResult(`登录失败:${errorMsg}`, 'error')
} finally {
loginBtn.disabled = false
loginBtn.textContent = '登录'
}
async function main() {
const cookieToken = await login()
const res = await axios({
url: `/login/status`,
method: 'post',
data: {
cookie: cookieToken,
},
})
}
function showResult(message, type = 'info') {
const resultDiv = document.getElementById('result')
resultDiv.style.display = 'block'
resultDiv.textContent = message
resultDiv.className = 'result ' + type
}
// 支持回车登录
document.getElementById('password').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
handleLogin()
}
})
document.getElementById('phone').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
document.getElementById('password').focus()
}
})
main()
</script>
</body>
+29 -276
View File
@@ -5,297 +5,56 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>歌单封面上传</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 500px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
text-align: center;
}
h1 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.subtitle {
font-size: 14px;
color: #666;
margin-bottom: 32px;
}
.login-link {
display: block;
margin-bottom: 24px;
color: #666;
font-size: 14px;
text-decoration: none;
}
.login-link:hover {
color: #333;
text-decoration: underline;
}
.cover-wrapper {
position: relative;
width: 180px;
height: 180px;
margin: 0 auto 24px;
}
.cover {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 4px solid #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.form-group {
margin-bottom: 20px;
text-align: left;
}
label {
display: block;
font-size: 14px;
font-weight: 500;
color: #555;
margin-bottom: 8px;
}
input[type="text"] {
width: 100%;
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
outline: none;
}
input[type="text"]:focus {
border-color: #333;
}
.upload-btn {
display: inline-block;
padding: 12px 28px;
background: #333;
color: white;
font-size: 15px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s ease;
border: none;
}
.upload-btn:hover {
background: #555;
}
.upload-btn input[type="file"] {
display: none;
}
.loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
}
.loading.active {
opacity: 1;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid #e0e0e0;
border-top-color: #333;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.result {
margin-top: 20px;
padding: 12px 16px;
border-radius: 6px;
font-size: 14px;
text-align: left;
}
.result.success {
background: #d1fae5;
color: #065f46;
}
.result.error {
background: #fee2e2;
color: #991b1b;
}
</style>
</head>
<body>
<div class="container">
<h1>歌单封面上传</h1>
<p class="subtitle">上传自定义歌单封面图片</p>
<a href="/qrlogin-nocookie.html" class="login-link">还没登录?点击登录</a>
<div class="form-group">
<label for="playlistId">歌单 ID</label>
<input type="text" id="playlistId" placeholder="请输入歌单ID" />
</div>
<div class="cover-wrapper">
<img id="playlist_cover" class="cover" src="" alt="歌单封面" />
<div class="loading" id="loading">
<div class="spinner"></div>
</div>
</div>
<label class="upload-btn">
选择封面图片
<input id="file" type="file" name="filename" accept="image/*" />
</label>
<div id="result" class="result" style="display: none;"></div>
<div>
<a href="/qrlogin-nocookie.html">
如果没登录,请先登录
</a>
</div>
<input id="file" type="file" name="filename" />
<img id="playlist_cover" style="height: 200px; width: 200px; border-radius: 50%" />
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js"></script>
<script>
const loadingOverlay = document.getElementById('loading')
const playlistIdInput = document.getElementById('playlistId')
const resultDiv = document.getElementById('result')
function showLoading() {
loadingOverlay.classList.add('active')
const playlist_id = ''
if (!playlist_id) {
const msg = '请设置你的歌单id'
alert(msg)
throw new Error(msg)
}
function hideLoading() {
loadingOverlay.classList.remove('active')
}
function showResult(message, type) {
resultDiv.textContent = message
resultDiv.className = 'result ' + type
resultDiv.style.display = 'block'
}
function hideResult() {
resultDiv.style.display = 'none'
}
async function loadPlaylistCover() {
const playlistId = playlistIdInput.value.trim()
if (!playlistId) {
return
}
showLoading()
hideResult()
try {
const res = await axios({
url: `/playlist/detail?id=${playlistId}&timestamp=${Date.now()}`,
})
document.querySelector('#playlist_cover').src = res.data.playlist.coverImgUrl
hideResult()
} catch (error) {
console.error('加载封面失败:', error)
showResult('加载封面失败,请检查歌单ID', 'error')
} finally {
hideLoading()
}
}
// 监听歌单ID输入变化
playlistIdInput.addEventListener('input', function() {
loadPlaylistCover()
})
// 监听文件选择
document
.querySelector('input[type="file"]')
.addEventListener('change', async function (e) {
const file = this.files[0]
const playlistId = playlistIdInput.value.trim()
if (!playlistId) {
showResult('请先输入歌单ID', 'error')
return
}
if (!file) {
return
}
showLoading()
hideResult()
try {
await upload(file, playlistId)
} catch (error) {
console.error('上传失败:', error)
showResult('上传失败,请重试', 'error')
} finally {
hideLoading()
}
main()
async function main() {
document.querySelector('input[type="file"]').addEventListener(
'change',
function (e) {
var file = this.files[0]
upload(file)
},
false,
)
const res = await axios({
url: `/playlist/detail?id=${playlist_id}&timestamp=${Date.now()}`,
})
document.querySelector('#playlist_cover').src = res.data.playlist.coverImgUrl
}
async function upload(file, playlistId) {
async function upload(file) {
var formData = new FormData()
formData.append('imgFile', file)
const imgSize = await getImgSize(file)
const res = await axios({
method: 'post',
url: `/playlist/cover/update?id=${playlistId}&cookie=${localStorage.getItem('cookie')}&imgSize=${imgSize.width}&imgX=0&imgY=0&timestamp=${Date.now()}`,
url: `/playlist/cover/update?id=${playlist_id}&cookie=${localStorage.getItem('cookie')}&imgSize=${imgSize.width
}&imgX=0&imgY=0&timestamp=${Date.now()}`,
headers: {
'Content-Type': 'multipart/form-data',
},
data: formData,
})
document.querySelector('#playlist_cover').src = res.data.data.url
showResult('封面上传成功!', 'success')
}
function getImgSize(file) {
return new Promise((resolve, reject) => {
let reader = new FileReader()
@@ -309,16 +68,10 @@
height: this.height,
})
}
image.onerror = function() {
reject(new Error('图片加载失败'))
}
}
reader.onerror = function() {
reject(new Error('文件读取失败'))
}
})
}
</script>
</body>
</html>
</html>
+236 -391
View File
@@ -2,416 +2,261 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>歌单导入工具</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
h1 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.subtitle {
font-size: 14px;
color: #666;
margin-bottom: 24px;
}
.tabs {
display: flex;
gap: 4px;
margin-bottom: 24px;
border-bottom: 1px solid #eee;
}
.tab-btn {
padding: 12px 24px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
font-size: 14px;
font-weight: 500;
color: #666;
cursor: pointer;
transition: all 0.2s ease;
}
.tab-btn:hover {
color: #333;
}
.tab-btn.active {
color: #333;
border-bottom-color: #333;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
font-size: 14px;
font-weight: 500;
color: #555;
margin-bottom: 8px;
}
input[type="text"], textarea {
width: 100%;
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
outline: none;
font-family: inherit;
}
input[type="text"]:focus, textarea:focus {
border-color: #333;
}
textarea {
min-height: 120px;
resize: vertical;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 16px;
}
table th, table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
table th {
font-size: 14px;
font-weight: 600;
color: #555;
background: #f9f9f9;
}
table td input {
width: 100%;
}
.btn {
padding: 10px 20px;
background: #333;
color: white;
font-size: 14px;
font-weight: 500;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s ease;
}
.btn:hover {
background: #555;
}
.btn-secondary {
background: #666;
}
.btn-secondary:hover {
background: #888;
}
.input-group {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.input-group input {
flex: 1;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
}
.checkbox-group input[type="checkbox"] {
cursor: pointer;
}
.checkbox-group label {
margin: 0;
cursor: pointer;
}
input:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
</style>
<!-- 引入Bootstrap CSS -->
<link href="https://fastly.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- 引入Bootstrap JS -->
<script src="https://fastly.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- 引入axios用于发送异步请求 -->
<script src="https://fastly.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
</head>
<body>
<div class="container">
<h1>歌单导入工具</h1>
<p class="subtitle">请选择一种导入方式并填写相关信息</p>
<div class="container mt-5">
<h1 class="mb-4">歌单导入工具</h1>
<p>请选择一种导入方式并填写相关信息:</p>
<ul class="tabs" id="importTabs" role="tablist">
<li role="presentation">
<button class="tab-btn active" id="metadata-tab" data-bs-toggle="tab" data-bs-target="#metadata" type="button" role="tab" aria-controls="metadata" aria-selected="true">元数据导入</button>
</li>
<li role="presentation">
<button class="tab-btn" id="text-tab" data-bs-toggle="tab" data-bs-target="#text" type="button" role="tab" aria-controls="text" aria-selected="false">文字导入</button>
</li>
<li role="presentation">
<button class="tab-btn" id="link-tab" data-bs-toggle="tab" data-bs-target="#link" type="button" role="tab" aria-controls="link" aria-selected="false">链接导入</button>
</li>
</ul>
<!-- 表单开始 -->
<form id="importForm" novalidate>
<!-- 选项卡导航 -->
<ul class="nav nav-tabs mb-3" id="importTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="metadata-tab" data-bs-toggle="tab" data-bs-target="#metadata" type="button" role="tab" aria-controls="metadata" aria-selected="true">元数据导入</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="text-tab" data-bs-toggle="tab" data-bs-target="#text" type="button" role="tab" aria-controls="text" aria-selected="false">文字导入</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="link-tab" data-bs-toggle="tab" data-bs-target="#link" type="button" role="tab" aria-controls="link" aria-selected="false">链接导入</button>
</li>
</ul>
<div class="tab-content active" id="importTabContent">
<!-- 元数据导入 -->
<div class="tab-content active" id="metadata" role="tabpanel" aria-labelledby="metadata-tab">
<table>
<thead>
<tr>
<th style="width: 33%">歌曲名称</th>
<th style="width: 33%">艺术家</th>
<th style="width: 33%">专辑</th>
</tr>
</thead>
<tbody id="metadataTableBody">
<tr>
<td><input type="text" name="name[]" placeholder="歌曲名称"></td>
<td><input type="text" name="artist[]" placeholder="艺术家"></td>
<td><input type="text" name="album[]" placeholder="专辑"></td>
</tr>
</tbody>
</table>
<button type="button" class="btn btn-secondary" id="addMetadataRow">增加歌曲</button>
</div>
<!-- 文字导入 -->
<div class="tab-content" id="text" role="tabpanel" aria-labelledby="text-tab">
<div class="form-group">
<label for="textInput">文字内容</label>
<textarea id="textInput" name="text" rows="5" placeholder="请输入歌曲信息,每行一首歌曲"></textarea>
<!-- 选项卡面板 -->
<div class="tab-content" id="importTabContent">
<!-- 元数据导入 -->
<div class="tab-pane fade show active" id="metadata" role="tabpanel" aria-labelledby="metadata-tab">
<table class="table table-bordered mb-3">
<thead>
<tr>
<th scope="col">歌曲名称</th>
<th scope="col">艺术家</th>
<th scope="col">专辑</th>
</tr>
</thead>
<tbody id="metadataTableBody">
<!-- 默认添加一行 -->
<tr>
<td><input type="text" class="form-control" name="name[]" placeholder="歌曲名称"></td>
<td><input type="text" class="form-control" name="artist[]" placeholder="艺术家"></td>
<td><input type="text" class="form-control" name="album[]" placeholder="专辑"></td>
</tr>
</tbody>
</table>
<button type="button" class="btn btn-secondary mb-3" id="addMetadataRow">增加歌曲</button>
</div>
<div class="form-group">
<label for="playlistNameInput">歌单名称</label>
<input type="text" id="playlistNameInput" name="playlistName" placeholder="请输入歌单名">
<!-- 文字导入 -->
<div class="tab-pane fade" id="text" role="tabpanel" aria-labelledby="text-tab">
<div class="mb-3">
<label for="textInput" class="form-label">文字:</label>
<textarea class="form-control" id="textInput" name="text" rows="5"></textarea>
</div>
<div class="mb-3">
<label for="playlistNameInput" class="form-label">歌单名:</label>
<input type="text" class="form-control" id="playlistNameInput" name="playlistName" placeholder="请输入歌单名">
</div>
</div>
</div>
<!-- 链接导入 -->
<div class="tab-content" id="link" role="tabpanel" aria-labelledby="link-tab">
<div class="form-group">
<label>链接列表</label>
<div id="linkInputsContainer">
<div class="input-group">
<input type="text" id="linkInput0" name="linkInput0" placeholder="请输入链接">
<button type="button" class="btn btn-secondary removeLinkButton" data-index="0">×</button>
<!-- 链接导入 -->
<div class="tab-pane fade" id="link" role="tabpanel" aria-labelledby="link-tab">
<div class="mb-3">
<label for="linkInputs" class="form-label">链接:</label>
<div id="linkInputsContainer">
<div class="input-group mb-3">
<input type="text" class="form-control" id="linkInput0" name="linkInput0" placeholder="请输入链接">
<button type="button" class="btn btn-secondary removeLinkButton" data-index="0">×</button>
</div>
</div>
<button type="button" class="btn btn-secondary mb-3" id="addLinkButton">增加链接</button>
<div class="mb-3">
<label for="playlistNameLinkInput" class="form-label">歌单名:</label>
<input type="text" class="form-control" id="playlistNameLinkInput" name="playlistName" placeholder="请输入歌单名">
</div>
</div>
<button type="button" class="btn btn-secondary" id="addLinkButton" style="margin-top: 8px;">增加链接</button>
</div>
<div class="form-group" style="margin-top: 20px;">
<label for="playlistNameLinkInput">歌单名称</label>
<input type="text" id="playlistNameLinkInput" name="playlistName" placeholder="请输入歌单名">
</div>
</div>
</div>
<div class="checkbox-group">
<input type="checkbox" value="" id="importStarCheckbox">
<label for="importStarCheckbox">
导入"我喜欢的音乐"
</label>
</div>
<!-- 是否导入我喜欢的音乐 -->
<div class="form-check">
<input class="form-check-input" type="checkbox" value="" id="importStarCheckbox">
<label class="form-check-label" for="importStarCheckbox">
导入“我喜欢的音乐”
</label>
</div>
<button type="submit" class="btn" id="submitBtn">导入歌曲</button>
</div>
<!-- 提交按钮 -->
<button type="submit" class="btn btn-primary mt-3">导入歌曲</button>
</form>
<!-- 表单结束 -->
<script src="https://fastly.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
// 选项卡切换
const tabBtns = document.querySelectorAll('.tab-btn');
const tabContents = document.querySelectorAll('.tab-content[id]');
<script>
// 动态增加链接输入框
document.getElementById('addLinkButton').addEventListener('click', function() {
var container = document.getElementById('linkInputsContainer');
var newIndex = container.childElementCount - 1; // 减去非输入框元素的数量
var newInput = document.createElement('input');
newInput.type = 'text';
newInput.className = 'form-control';
newInput.id = `linkInput${newIndex}`;
newInput.name = `linkInput${newIndex}`;
newInput.placeholder = '请输入链接';
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
tabBtns.forEach(b => b.classList.remove('active'));
tabContents.forEach(c => c.classList.remove('active'));
btn.classList.add('active');
const targetId = btn.getAttribute('data-bs-target');
document.getElementById(targetId).classList.add('active');
});
});
// 动态增加链接输入框
document.getElementById('addLinkButton').addEventListener('click', function() {
var container = document.getElementById('linkInputsContainer');
var newIndex = container.children.length;
var newInput = document.createElement('input');
newInput.type = 'text';
newInput.className = '';
newInput.id = `linkInput${newIndex}`;
newInput.name = `linkInput${newIndex}`;
newInput.placeholder = '请输入链接';
newInput.style.cssText = 'flex: 1; padding: 10px 14px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; outline: none;';
var removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'btn btn-secondary';
removeButton.textContent = '×';
removeButton.dataset.index = newIndex.toString();
removeButton.addEventListener('click', function() {
var group = this.closest('.input-group');
container.removeChild(group);
});
var inputGroup = document.createElement('div');
inputGroup.className = 'input-group';
inputGroup.appendChild(newInput);
inputGroup.appendChild(removeButton);
container.appendChild(inputGroup);
});
// 动态增加元数据行
document.getElementById('addMetadataRow').addEventListener('click', function() {
var container = document.getElementById('metadataTableBody');
var newRow = document.createElement('tr');
newRow.innerHTML = `
<td><input type="text" name="name[]" placeholder="歌曲名称" style="width: 100%; padding: 10px 14px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; outline: none;"></td>
<td><input type="text" name="artist[]" placeholder="艺术家" style="width: 100%; padding: 10px 14px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; outline: none;"></td>
<td><input type="text" name="album[]" placeholder="专辑" style="width: 100%; padding: 10px 14px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; outline: none;"></td>
`;
container.appendChild(newRow);
});
document.getElementById('submitBtn').addEventListener('click', async function() {
// 获取表单值
let text = document.getElementById('textInput').value;
let links = [];
let local = [];
let playlistName = '';
// 获取所有链接输入框的值
let linkInputs = document.querySelectorAll('#linkInputsContainer .input-group input[type="text"]');
linkInputs.forEach(function(input) {
if (input.value.trim() !== '') {
links.push(input.value);
}
});
// 获取元数据
let metadataRows = document.querySelectorAll('#metadataTableBody tr');
metadataRows.forEach(function(row) {
let name = row.querySelector('input[name="name[]"]').value;
let artist = row.querySelector('input[name="artist[]"]').value;
let album = row.querySelector('input[name="album[]"]').value;
if (name && artist && album) {
local.push({ name, artist, album });
}
});
// 检查是否有且只有一个输入字段被填写
let filledCount = (text ? 1 : 0) + (links.length > 0 ? 1 : 0) + (local.length > 0 ? 1 : 0);
if (filledCount !== 1) {
alert("请确保仅填写了一个输入字段!");
return;
}
// 获取歌单名
if (document.getElementById('importStarCheckbox').checked) {
playlistName = '我喜欢的音乐';
} else {
playlistName = document.getElementById('playlistNameInput').value ||
document.getElementById('playlistNameLinkInput').value ||
'导入音乐 ' + new Date().toLocaleString();
}
// 创建请求参数
let data = {};
if (text) {
data.text = text;
data.playlistName = playlistName;
} else if (links.length > 0) {
data.link = JSON.stringify(links);
data.playlistName = playlistName;
} else if (local.length > 0) {
data.local = JSON.stringify(local);
}
// 添加额外参数
if (document.getElementById('importStarCheckbox').checked) {
data.importStarPlaylist = true;
}
try {
const res = await axios({
url: `/playlist/import/name/task/create?timestamp=${Date.now()}`,
method: 'post',
data: data,
var removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'btn btn-secondary removeLinkButton';
removeButton.textContent = '×';
removeButton.dataset.index = newIndex.toString();
removeButton.addEventListener('click', function() {
var group = this.closest('.input-group');
container.removeChild(group);
});
let taskId = res.data?.data?.taskId
if (taskId) {
alert(`任务创建成功!正在导入,请稍等;任务id:${taskId}`)
}
} catch (error) {
console.error('Error:', error);
alert('导入失败,请检查您的输入或稍后再试。');
}
});
var inputGroup = document.createElement('div');
inputGroup.className = 'input-group mb-3';
inputGroup.appendChild(newInput);
inputGroup.appendChild(removeButton);
// 监听复选框状态变化
document.getElementById('importStarCheckbox').addEventListener('change', function() {
let isChecked = this.checked;
let playlistNameInputs = document.querySelectorAll('[name="playlistName"]');
playlistNameInputs.forEach(function(input) {
input.disabled = isChecked;
container.appendChild(inputGroup);
});
});
// 初始化时设置歌单名输入框的状态
document.getElementById('importStarCheckbox').dispatchEvent(new Event('change'));
</script>
// 动态增加元数据行
document.getElementById('addMetadataRow').addEventListener('click', function() {
var container = document.getElementById('metadataTableBody');
var newRow = document.createElement('tr');
var nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.className = 'form-control';
nameInput.name = 'name[]';
nameInput.placeholder = '歌曲名称';
var artistInput = document.createElement('input');
artistInput.type = 'text';
artistInput.className = 'form-control';
artistInput.name = 'artist[]';
artistInput.placeholder = '艺术家';
var albumInput = document.createElement('input');
albumInput.type = 'text';
albumInput.className = 'form-control';
albumInput.name = 'album[]';
albumInput.placeholder = '专辑';
newRow.innerHTML = `
<td>${nameInput.outerHTML}</td>
<td>${artistInput.outerHTML}</td>
<td>${albumInput.outerHTML}</td>
`;
container.appendChild(newRow);
});
document.getElementById('importForm').addEventListener('submit', async function(event) {
// 阻止默认行为
event.preventDefault();
// 获取表单值
let text = document.getElementById('textInput').value;
let links = [];
let local = [];
let playlistName = '';
// 获取所有链接输入框的值
let linkInputs = document.querySelectorAll('#linkInputsContainer .input-group .form-control');
linkInputs.forEach(function(input) {
if (input.value.trim() !== '') {
links.push(input.value);
}
});
// 获取元数据
let metadataRows = document.querySelectorAll('#metadataTableBody tr');
metadataRows.forEach(function(row) {
let name = row.querySelector('input[name="name[]"]').value;
let artist = row.querySelector('input[name="artist[]"]').value;
let album = row.querySelector('input[name="album[]"]').value;
if (name && artist && album) {
local.push({ name, artist, album });
}
});
// 检查是否有且只有一个输入字段被填写
let filledCount = (text ? 1 : 0) + (links.length > 0 ? 1 : 0) + (local.length > 0 ? 1 : 0);
if (filledCount !== 1) {
alert("请确保仅填写了一个输入字段!");
return;
}
// 获取歌单名
if (document.getElementById('importStarCheckbox').checked) {
playlistName = '我喜欢的音乐';
} else {
playlistName = document.getElementById('playlistNameInput').value ||
document.getElementById('playlistNameLinkInput').value ||
'导入音乐 ' + new Date().toLocaleString();
}
// 创建请求参数
let data = {};
if (text) {
data.text = text;
data.playlistName = playlistName;
} else if (links.length > 0) {
data.link = JSON.stringify(links);
data.playlistName = playlistName;
} else if (local.length > 0) {
data.local = JSON.stringify(local);
}
// 添加额外参数
if (document.getElementById('importStarCheckbox').checked) {
data.importStarPlaylist = true;
}
try {
const res = await axios({
url: `/playlist/import/name/task/create?timestamp=${Date.now()}`,
method: 'post',
data: data,
});
let taskId = res.data?.data?.taskId
if (taskId) {
alert(`任务创建成功! 正在导入, 请稍等; 任务id:${taskId}`)
// const res2 = await axios({
// url: `/playlist/import/task/status?timestamp=${Date.now()}`,
// method: 'post',
// data: {
// id: taskId
// },
// });
// alert(JSON.stringify(res2.data, null, 2));
}
} catch (error) {
console.error('Error:', error);
alert('导入失败,请检查您的输入或稍后再试。');
}
});
// 监听复选框状态变化
document.getElementById('importStarCheckbox').addEventListener('change', function() {
let isChecked = this.checked;
let playlistNameInputs = document.querySelectorAll('[name="playlistName"]');
playlistNameInputs.forEach(function(input) {
input.disabled = isChecked;
});
});
// 初始化时设置歌单名输入框的状态
document.getElementById('importStarCheckbox').dispatchEvent(new Event('change'));
</script>
</div>
</body>
</html>
+42 -169
View File
@@ -5,164 +5,45 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>二维码登录</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
padding: 48px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-width: 450px;
width: 100%;
text-align: center;
}
h1 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.subtitle {
font-size: 14px;
color: #666;
margin-bottom: 32px;
}
.qr-wrapper {
display: inline-block;
padding: 16px;
background: #f9f9f9;
border-radius: 8px;
margin-bottom: 24px;
}
#qrImg {
width: 200px;
height: 200px;
display: block;
}
.info {
text-align: left;
padding: 16px;
background: #f9f9f9;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 12px;
color: #666;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
.status {
margin-top: 16px;
padding: 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
}
.status.waiting {
background: #fef3c7;
color: #92400e;
}
.status.success {
background: #d1fae5;
color: #065f46;
}
.status.error {
background: #fee2e2;
color: #991b1b;
}
.hint {
margin-top: 16px;
font-size: 13px;
color: #999;
}
</style>
</head>
<body>
<div class="container">
<h1>二维码登录</h1>
<p class="subtitle">使用网易云音乐App扫描二维码登录</p>
<div class="qr-wrapper">
<img id="qrImg" src="" alt="二维码加载中..." />
</div>
<div id="status" class="status waiting">等待扫描...</div>
<div id="info" class="info"></div>
<p class="hint">请打开网易云音乐App,扫描上方二维码完成登录</p>
</div>
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js"></script>
<img id="qrImg" />
<div id="info" class="info"></div>
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js
"></script>
<script>
async function login() {
let timer
const statusDiv = document.getElementById('status')
let timestamp = Date.now()
const cookie = localStorage.getItem('cookie')
updateStatus('加载二维码...', 'waiting')
getLoginStatus(cookie)
const res = await axios({
url: `/login/qr/key?timestamp=${Date.now()}`,
})
const key = res.data.data.unikey
const res2 = await axios({
url: `/login/qr/create?key=${key}&platform=web&qrimg=true&timestamp=${Date.now()}`,
})
document.querySelector('#qrImg').src = res2.data.data.qrimg
try {
const res = await axios({
url: `/login/qr/key?timestamp=${Date.now()}`,
})
const key = res.data.data.unikey
const res2 = await axios({
url: `/login/qr/create?key=${key}&platform=web&qrimg=true&timestamp=${Date.now()}`,
})
document.querySelector('#qrImg').src = res2.data.data.qrimg
updateStatus('请扫描二维码', 'waiting')
timer = setInterval(async () => {
const statusRes = await checkStatus(key)
if (statusRes.code === 800) {
updateStatus('二维码已过期,请刷新页面', 'error')
clearInterval(timer)
} else if (statusRes.code === 801) {
updateStatus('二维码已扫描,请在手机上确认', 'waiting')
} else if (statusRes.code === 802) {
updateStatus('登录成功,正在保存信息...', 'waiting')
} else if (statusRes.code === 803) {
clearInterval(timer)
updateStatus('授权登录成功!', 'success')
await getLoginStatus(statusRes.cookie)
localStorage.setItem('cookie', statusRes.cookie)
}
}, 3000)
} catch (error) {
console.error('登录失败:', error)
updateStatus('二维码加载失败,请刷新页面重试', 'error')
}
timer = setInterval(async () => {
const statusRes = await checkStatus(key)
if (statusRes.code === 800) {
alert('二维码已过期,请重新获取')
clearInterval(timer)
}
if (statusRes.code === 803) {
// 这一步会返回cookie
clearInterval(timer)
alert('授权登录成功')
await getLoginStatus(statusRes.cookie)
localStorage.setItem('cookie', statusRes.cookie)
}
}, 3000)
}
login()
async function checkStatus(key) {
const res = await axios({
@@ -170,30 +51,22 @@
})
return res.data
}
async function getLoginStatus(cookie = '') {
try {
const res = await axios({
url: `/login/status?timestamp=${Date.now()}`,
method: 'post',
data: {
cookie,
},
})
document.querySelector('#info').textContent = JSON.stringify(res.data, null, 2)
} catch (error) {
console.error('获取登录状态失败:', error)
}
const res = await axios({
url: `/login/status?timestamp=${Date.now()}`,
method: 'post',
data: {
cookie,
},
})
document.querySelector('#info').innerText = JSON.stringify(res.data, null, 2)
}
function updateStatus(message, type) {
const statusDiv = document.getElementById('status')
statusDiv.textContent = message
statusDiv.className = 'status ' + type
}
login()
</script>
<style>
.info {
white-space: pre;
}
</style>
</body>
</html>
+42 -170
View File
@@ -5,195 +5,67 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>二维码登录</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
padding: 48px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-width: 450px;
width: 100%;
text-align: center;
}
h1 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.subtitle {
font-size: 14px;
color: #666;
margin-bottom: 32px;
}
.qr-wrapper {
display: inline-block;
padding: 16px;
background: #f9f9f9;
border-radius: 8px;
margin-bottom: 24px;
}
#qrImg {
width: 200px;
height: 200px;
display: block;
}
.info {
text-align: left;
padding: 16px;
background: #f9f9f9;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 12px;
color: #666;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
.status {
margin-top: 16px;
padding: 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
}
.status.waiting {
background: #fef3c7;
color: #92400e;
}
.status.success {
background: #d1fae5;
color: #065f46;
}
.status.error {
background: #fee2e2;
color: #991b1b;
}
.hint {
margin-top: 16px;
font-size: 13px;
color: #999;
}
</style>
</head>
<body>
<div class="container">
<h1>二维码登录</h1>
<p class="subtitle">使用网易云音乐App扫描二维码登录</p>
<div class="qr-wrapper">
<img id="qrImg" src="" alt="二维码加载中..." />
</div>
<div id="status" class="status waiting">等待扫描...</div>
<div id="info" class="info"></div>
<p class="hint">请打开网易云音乐App,扫描上方二维码完成登录</p>
</div>
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js"></script>
<img id="qrImg" />
<div id="info" class="info"></div>
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js
"></script>
<script>
async function login() {
let timer
const statusDiv = document.getElementById('status')
let timestamp = Date.now()
const cookie = localStorage.getItem('cookie')
updateStatus('加载二维码...', 'waiting')
getLoginStatus(cookie)
const res = await axios({
url: `/login/qr/key?timestamp=${Date.now()}`,
})
const key = res.data.data.unikey
const res2 = await axios({
url: `/login/qr/create?key=${key}&platform=web&qrimg=true&timestamp=${Date.now()}`,
})
document.querySelector('#qrImg').src = res2.data.data.qrimg
try {
const res = await axios({
url: `/login/qr/key?timestamp=${Date.now()}`,
})
const key = res.data.data.unikey
const res2 = await axios({
url: `/login/qr/create?key=${key}&platform=web&qrimg=true&timestamp=${Date.now()}&ua=pc`,
})
document.querySelector('#qrImg').src = res2.data.data.qrimg
updateStatus('请扫描二维码', 'waiting')
timer = setInterval(async () => {
const statusRes = await checkStatus(key)
if (statusRes.code === 800) {
updateStatus('二维码已过期,请刷新页面', 'error')
clearInterval(timer)
} else if (statusRes.code === 801) {
updateStatus('二维码已扫描,请在手机上确认', 'waiting')
} else if (statusRes.code === 802) {
updateStatus('登录成功,正在保存信息...', 'waiting')
} else if (statusRes.code === 803) {
clearInterval(timer)
updateStatus('授权登录成功!', 'success')
await getLoginStatus(statusRes.cookie)
localStorage.setItem('cookie', statusRes.cookie)
}
}, 3000)
} catch (error) {
console.error('登录失败:', error)
updateStatus('二维码加载失败,请刷新页面重试', 'error')
}
timer = setInterval(async () => {
const statusRes = await checkStatus(key)
if (statusRes.code === 800) {
alert('二维码已过期,请重新获取')
clearInterval(timer)
}
if (statusRes.code === 803) {
// 这一步会返回cookie
clearInterval(timer)
alert('授权登录成功')
await getLoginStatus(statusRes.cookie)
localStorage.setItem('cookie', statusRes.cookie)
}
}, 3000)
}
login()
async function checkStatus(key) {
const res = await axios({
url: `/login/qr/check?key=${key}&timestamp=${Date.now()}&ua=pc`,
url: `/login/qr/check?key=${key}&timestamp=${Date.now()}`,
})
return res.data
}
async function getLoginStatus(cookie = '') {
try {
const res = await axios({
url: `/login/status?timestamp=${Date.now()}&ua=pc`,
method: 'post',
data: {
cookie,
},
})
document.querySelector('#info').textContent = JSON.stringify(res.data, null, 2)
} catch (error) {
console.error('获取登录状态失败:', error)
}
const res = await axios({
url: `/login/status?timestamp=${Date.now()}`,
method: 'post',
data: {
cookie,
},
})
document.querySelector('#info').innerText = JSON.stringify(res.data, null, 2)
}
function updateStatus(message, type) {
const statusDiv = document.getElementById('status')
statusDiv.textContent = message
statusDiv.className = 'status ' + type
}
login()
</script>
<style>
.info {
white-space: pre;
}
</style>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 992 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 627 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

-153
View File
@@ -1,153 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>音乐解灰测试</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
h1 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
font-size: 14px;
font-weight: 500;
color: #555;
margin-bottom: 8px;
}
input {
width: 100%;
padding: 12px 14px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 15px;
outline: none;
}
input:focus {
border-color: #333;
}
button {
background: #333;
color: white;
padding: 14px 28px;
border: none;
border-radius: 6px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
}
button:hover {
background: #555;
}
button:disabled {
background: #999;
cursor: not-allowed;
}
#result {
margin-top: 24px;
padding: 16px;
background: #f9f9f9;
border-radius: 6px;
border: 1px solid #eee;
font-family: 'Courier New', monospace;
font-size: 13px;
white-space: pre-wrap;
word-break: break-all;
min-height: 100px;
}
.hint {
font-size: 12px;
color: #999;
margin-top: 4px;
}
</style>
</head>
<body>
<div class="container">
<h1>音乐解灰测试</h1>
<div class="form-group">
<label for="songId">音乐 ID</label>
<input type="number" id="songId" placeholder="请输入音乐ID" />
<div class="hint">例如: 1372188635</div>
</div>
<div class="form-group">
<label for="sources">音源列表(可选)</label>
<input type="text" id="sources" placeholder="请输入音源" />
<div class="hint">例如: kuwo, kugou, migu</div>
</div>
<button id="testBtn" onclick="testSong()">开始测试</button>
<div id="result"></div>
</div>
<script>
async function testSong() {
const songId = document.getElementById('songId').value;
const sources = document.getElementById('sources').value;
const testBtn = document.getElementById('testBtn');
const resultDiv = document.getElementById('result');
if (!songId) {
alert('请输入音乐ID');
return;
}
testBtn.disabled = true;
testBtn.textContent = '测试中...';
resultDiv.textContent = '正在请求...';
try {
const response = await fetch(`/song/url/match?id=${songId}&source=${sources}`);
const data = await response.json();
resultDiv.textContent = JSON.stringify(data, null, 2);
} catch (error) {
resultDiv.textContent = `请求失败: ${error.message}`;
} finally {
testBtn.disabled = false;
testBtn.textContent = '开始测试';
}
}
</script>
</body>
</html>
+112 -313
View File
@@ -1,326 +1,125 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>播客上传声音</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
background: #f5f5f5;
padding: 20px;
}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>播客上传声音</title>
</head>
.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
<body>
<div>
<a href="/qrlogin-nocookie.html">
如果没登录,请先登录
</a>
</div>
<div id="app">
<ul>
<li v-for="(item,index) in voicelist" @click="currentVoiceIndex=index"
:class="{active:currentVoiceIndex===index}">
<img :src="item.coverUrl" style="width:50px;width:50px;" />
<ul>
<li v-for="(item2,index) in item.voiceListData">
{{item2.voiceName}}
</li>
</ul>
{{item.voiceListName}}
</li>
</ul>
<input v-model="songName" placeholder="请输入声音名称" />
<input v-model="description" placeholder="请输入介绍" />
<input type="file" name="songFile" />
<button @click="submit">上传</button>
</div>
h1 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 24px;
}
.login-link {
display: block;
margin-bottom: 24px;
color: #666;
font-size: 14px;
text-decoration: none;
}
.login-link:hover {
color: #333;
text-decoration: underline;
}
.content {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.voice-list {
flex: 1;
min-width: 300px;
}
.voice-item {
padding: 12px 16px;
border: 1px solid #eee;
border-radius: 6px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.voice-item:hover {
border-color: #333;
background: #f9f9f9;
}
.voice-item.active {
border-color: #333;
background: #f0f0f0;
}
.voice-header {
display: flex;
align-items: center;
gap: 12px;
}
.voice-cover {
width: 50px;
height: 50px;
border-radius: 4px;
object-fit: cover;
}
.voice-name {
font-size: 14px;
font-weight: 500;
color: #333;
}
.voice-tracks {
margin-top: 8px;
padding-left: 62px;
}
.voice-track {
font-size: 13px;
color: #666;
padding: 4px 0;
}
.upload-section {
flex: 1;
min-width: 300px;
}
.form-group {
margin-bottom: 16px;
}
label {
display: block;
font-size: 14px;
font-weight: 500;
color: #555;
margin-bottom: 8px;
}
input[type="text"], input[type="file"] {
width: 100%;
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
outline: none;
}
input[type="text"]:focus {
border-color: #333;
}
.btn {
width: 100%;
padding: 12px;
background: #333;
color: white;
font-size: 15px;
font-weight: 500;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s ease;
}
.btn:hover {
background: #555;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #999;
font-size: 14px;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
</style>
</head>
<body>
<div class="container">
<h1>播客上传声音</h1>
<a href="/qrlogin-nocookie.html" class="login-link">还没登录?点击登录</a>
<div class="content">
<div class="voice-list">
<h3 style="font-size: 16px; font-weight: 600; color: #333; margin-bottom: 16px;">选择播客列表</h3>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="voicelist.length > 0">
<div
v-for="(item, index) in voicelist"
:key="index"
@click="currentVoiceIndex = index"
:class="{ active: currentVoiceIndex === index }"
class="voice-item"
>
<div class="voice-header">
<img :src="item.coverUrl" class="voice-cover" alt="cover" />
<span class="voice-name">{{ item.voiceListName }}</span>
</div>
<div class="voice-tracks" v-if="item.voiceListData">
<div
v-for="(item2, index2) in item.voiceListData"
:key="index2"
class="voice-track"
>
{{ item2.voiceName }}
</div>
</div>
</div>
</div>
<div v-else class="empty-state">暂无播客列表</div>
</div>
<div class="upload-section">
<h3 style="font-size: 16px; font-weight: 600; color: #333; margin-bottom: 16px;">上传声音</h3>
<div class="form-group">
<label for="songName">声音名称</label>
<input id="songName" v-model="songName" placeholder="请输入声音名称" />
</div>
<div class="form-group">
<label for="description">介绍</label>
<input id="description" v-model="description" placeholder="请输入介绍" />
</div>
<div class="form-group">
<label>选择文件</label>
<input type="file" name="songFile" accept="audio/*" />
</div>
<button class="btn" @click="submit">上传</button>
</div>
</div>
</div>
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js"></script>
<script src="https://fastly.jsdelivr.net/npm/vue@3"></script>
<script>
Vue.createApp({
data() {
return {
songName: '',
description: '',
voicelist: [],
cookieToken: '',
currentVoiceIndex: 0,
loading: false,
}
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js"></script>
<script src="https://fastly.jsdelivr.net/npm/vue"></script>
<script>
Vue.createApp({
data() {
return {
songName: '',
description: '',
voicelist: [],
cookieToken: '',
currentVoiceIndex: 0,
}
},
created() {
this.getData()
},
computed: {
currentVoice() {
// {
// voiceListId: '',
// coverImgId: '',
// categoryId: '',
// secondCategoryId: '',
// }
return this.voicelist[this.currentVoiceIndex]
},
created() {
this.getData()
},
methods: {
submit() {
console.log('submit')
const file = document.querySelector('input[type=file]').files[0]
this.upload(file)
},
computed: {
currentVoice() {
return this.voicelist[this.currentVoiceIndex]
},
},
methods: {
submit() {
console.info('submit')
const file = document.querySelector('input[type=file]').files[0]
if (!file) {
alert('请选择文件')
return
}
this.upload(file)
},
async getData() {
this.loading = true
try {
const res = await axios({
url: `/voicelist/search?cookie=${localStorage.getItem('cookie')}`,
})
async getData() {
const res = await axios({
url: `/voicelist/search?cookie=${localStorage.getItem('cookie')}`,
})
console.info(res.data.data)
this.voicelist = res.data.data.list || []
this.voicelist.forEach(async (i) => {
try {
const res2 = await axios({
url: `/voicelist/list?voiceListId=${i.voiceListId}&limit=5`,
})
i.voiceListData = res2.data.data.list || []
console.info(res2)
} catch (err) {
console.error('获取播客详情失败:', err)
}
})
} catch (err) {
console.error('获取播客列表失败:', err)
} finally {
this.loading = false
}
},
upload(file) {
if (!this.currentVoice) {
alert('请先选择播客列表')
return
}
var formData = new FormData()
formData.append('songFile', file)
axios({
method: 'post',
url: `/voice/upload?time=${Date.now()}&cookie=${localStorage.getItem(
'cookie',
)}&songName=${this.songName}&voiceListId=${
this.currentVoice.voiceListId
}&categoryId=${this.currentVoice.categoryId}&coverImgId=${
this.currentVoice.coverImgId
}&secondCategoryId=${
this.currentVoice.secondCategoryId
}&description=${this.description}&privacy=1`,
headers: {
'Content-Type': 'multipart/form-data',
},
data: formData,
console.log(res.data.data)
this.voicelist = res.data.data.list
this.voicelist.map(async i => {
const res2 = await axios({
url: `/voicelist/list?voiceListId=${i.voiceListId}&limit=5`,
})
.then((res) => {
alert(`${file.name} 上传成功`)
})
.catch((err) => {
console.error('上传失败:', err)
alert('上传失败,请重试')
})
},
i.voiceListData = res2.data.data.list
console.log(res2);
})
},
}).mount('body')
</script>
</body>
upload(file) {
var formData = new FormData()
formData.append('songFile', file)
axios({
method: 'post',
url: `/voice/upload?time=${Date.now()}&cookie=${localStorage.getItem('cookie')
}&songName=${this.songName}&voiceListId=${this.currentVoice.voiceListId
}&categoryId=${this.currentVoice.categoryId}&coverImgId=${this.currentVoice.coverImgId
}&secondCategoryId=${this.currentVoice.secondCategoryId}&description=${this.description
}&privacy=1`,
headers: {
'Content-Type': 'multipart/form-data',
},
data: formData,
})
.then((res) => {
alert(`${file.name} 上传成功`)
if (currentIndx >= fileLength) {
console.log('上传完毕')
}
})
.catch(async (err) => {
console.log(err)
})
},
},
}).mount('#app')
</script>
<style>
ul li {
cursor: pointer;
}
ul li.active {
color: red;
}
</style>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
{
"extends": [
"config:base",
"helpers:pinGitHubActionDigests"
]
}
+35 -153
View File
@@ -1,4 +1,3 @@
require('dotenv').config()
const fs = require('fs')
const path = require('path')
const express = require('express')
@@ -9,8 +8,7 @@ const cache = require('./util/apicache').middleware
const { cookieToJson } = require('./util/index')
const fileUpload = require('express-fileupload')
const decode = require('safe-decode-uri-component')
const logger = require('./util/logger.js')
require("dotenv").config();
/**
* The version check result.
* @readonly
@@ -99,7 +97,7 @@ async function getModulesDefinitions(
*/
async function checkVersion() {
return new Promise((resolve) => {
exec('npm info NeteaseCloudMusicApiEnhanced version', (err, stdout) => {
exec('npm info NeteaseCloudMusicApi version', (err, stdout) => {
if (!err) {
let version = stdout.trim()
@@ -127,68 +125,15 @@ async function checkVersion() {
})
}
function parseCorsAllowOrigins(corsAllowOrigin) {
if (!corsAllowOrigin) {
return null
}
const origins = corsAllowOrigin
.split(',')
.map((origin) => origin.trim())
.filter(Boolean)
return origins.length > 0 ? origins : null
}
function getCorsAllowOrigin(allowOrigins, requestOrigin) {
if (!allowOrigins) {
return requestOrigin || '*'
}
if (allowOrigins.includes('*')) {
return '*'
}
if (requestOrigin && allowOrigins.includes(requestOrigin)) {
return requestOrigin
}
return null
}
function createConsoleSpinner(message = '启动中') {
if (!process.stdout.isTTY) {
return {
stop() {},
}
}
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
let index = 0
process.stdout.write(`${frames[index]} ${message}...`)
const timer = setInterval(() => {
index = (index + 1) % frames.length
process.stdout.write(`\r${frames[index]} ${message}...`)
}, 80)
return {
stop() {
clearInterval(timer)
process.stdout.write(`\r✔ ${message} 完成。\n`)
},
}
}
/**
* Construct the server of NCM API.
*
* @param {ModuleDefinition[]} [moduleDefs] Customized module definitions [advanced]
* @returns {Promise<import("express").Express>} The server instance.
*/
async function constructServer(moduleDefs) {
async function consturctServer(moduleDefs) {
const app = express()
const { CORS_ALLOW_ORIGIN } = process.env
const allowOrigins = parseCorsAllowOrigins(CORS_ALLOW_ORIGIN)
app.set('trust proxy', true)
/**
@@ -200,17 +145,9 @@ async function constructServer(moduleDefs) {
*/
app.use((req, res, next) => {
if (req.path !== '/' && !req.path.includes('.')) {
const corsAllowOrigin = getCorsAllowOrigin(
allowOrigins,
req.headers.origin,
)
const shouldSetVaryHeader = corsAllowOrigin && corsAllowOrigin !== '*'
res.set({
'Access-Control-Allow-Credentials': true,
...(corsAllowOrigin
? { 'Access-Control-Allow-Origin': corsAllowOrigin }
: {}),
...(shouldSetVaryHeader ? { Vary: 'Origin' } : {}),
'Access-Control-Allow-Origin': CORS_ALLOW_ORIGIN || req.headers.origin || '*',
'Access-Control-Allow-Headers': 'X-Requested-With,Content-Type',
'Access-Control-Allow-Methods': 'PUT,POST,GET,DELETE,OPTIONS',
'Content-Type': 'application/json; charset=utf-8',
@@ -238,25 +175,10 @@ async function constructServer(moduleDefs) {
/**
* Body Parser and File Upload
*/
const MAX_UPLOAD_SIZE_MB = 500
const MAX_UPLOAD_SIZE_BYTES = MAX_UPLOAD_SIZE_MB * 1024 * 1024
app.use(express.json({ limit: '50mb' }))
app.use(express.urlencoded({ extended: false, limit: '50mb' }))
app.use(express.json({ limit: `${MAX_UPLOAD_SIZE_MB}mb` }))
app.use(
express.urlencoded({ extended: false, limit: `${MAX_UPLOAD_SIZE_MB}mb` }),
)
app.use(
fileUpload({
limits: {
fileSize: MAX_UPLOAD_SIZE_BYTES,
},
useTempFiles: true,
tempFileDir: require('os').tmpdir(),
abortOnLimit: true,
parseNested: true,
}),
)
app.use(fileUpload())
/**
* Cache
@@ -281,11 +203,9 @@ async function constructServer(moduleDefs) {
for (const moduleDef of moduleDefinitions) {
// Register the route.
app.all(moduleDef.route, async (req, res) => {
app.use(moduleDef.route, async (req, res) => {
;[req.query, req.body].forEach((item) => {
// item may be undefined (some environments / middlewares).
// Guard access to avoid "Cannot read properties of undefined (reading 'cookie')".
if (item && typeof item.cookie === 'string') {
if (typeof item.cookie === 'string') {
item.cookie = cookieToJson(decode(item.cookie))
}
})
@@ -302,56 +222,36 @@ async function constructServer(moduleDefs) {
const moduleResponse = await moduleDef.module(query, (...params) => {
// 参数注入客户端IP
const obj = [...params]
const options = obj[2] || {}
if (!options.randomCNIP) {
let ip = req.ip
let ip = req.ip
if (ip.substring(0, 7) == '::ffff:') {
ip = ip.substring(7)
}
if (ip == '::1') {
ip = global.cnIp
}
// logger.info('Requested from ip:', ip)
obj[2] = {
...options,
ip,
}
if (ip.substring(0, 7) == '::ffff:') {
ip = ip.substring(7)
}
if (ip == '::1') {
ip = global.cnIp
}
// console.log(ip)
obj[3] = {
...obj[3],
ip,
}
return request(...obj)
})
logger.info(`Request Success: ${decode(req.originalUrl)}`)
console.log('[OK]', decode(req.originalUrl))
// 夹带私货部分:如果开启了通用解锁,并且是获取歌曲URL的接口,则尝试解锁(如果需要的话)ヾ(≧▽≦*)o
if (
req.baseUrl === '/song/url/v1' &&
process.env.ENABLE_GENERAL_UNBLOCK === 'true'
) {
const song = moduleResponse.body.data[0]
if (
song.freeTrialInfo !== null ||
!song.url ||
[1, 4].includes(song.fee)
) {
const {
matchID,
} = require('@neteasecloudmusicapienhanced/unblockmusic-utils')
logger.info('Starting unblock(uses general unblock):', req.query.id)
const result = await matchID(req.query.id)
song.url = result.data.url
song.freeTrialInfo = null
logger.info('Unblock success! url:', song.url)
}
if (song.url && song.url.includes('kuwo')) {
const proxy = process.env.PROXY_URL
const useProxy = process.env.ENABLE_PROXY || 'false'
if (useProxy === 'true' && proxy) {
song.proxyUrl = proxy + song.url
if (req.baseUrl === '/song/url/v1' || req.baseUrl === '/song/url') {
const song = moduleResponse['body']['data'][0]
if (song.freeTrialInfo !== null || !song.url || [1, 4].includes(song.fee)) {
const match = require('@unblockneteasemusic/server')
const source = ['pyncmd', 'kuwo']
const { url } = await match(req.query.id, source)
song.url = url
song.freeTrialInfo = 'unblock'
console.log("解灰成功!")// 对于Splayer来说,去除开通会员提示
}
}
}
const cookies = moduleResponse.cookie
if (!query.noCookie) {
if (Array.isArray(cookies) && cookies.length > 0) {
@@ -368,14 +268,9 @@ async function constructServer(moduleDefs) {
}
}
}
if (moduleResponse.redirectUrl) {
res.redirect(moduleResponse.status || 302, moduleResponse.redirectUrl)
return
}
res.status(moduleResponse.status).send(moduleResponse.body)
} catch (/** @type {*} */ moduleResponse) {
logger.error(`${decode(req.originalUrl)}`, {
console.log('[ERR]', decode(req.originalUrl), {
status: moduleResponse.status,
body: moduleResponse.body,
})
@@ -410,39 +305,26 @@ async function serveNcmApi(options) {
const port = Number(options.port || process.env.PORT || '3000')
const host = options.host || process.env.HOST || ''
const spinner = createConsoleSpinner('服务启动中')
const checkVersionSubmission =
options.checkVersion &&
checkVersion().then(({ npmVersion, ourVersion, status }) => {
if (status == VERSION_CHECK_RESULT.NOT_LATEST) {
logger.info(
console.log(
`最新版本: ${npmVersion}, 当前版本: ${ourVersion}, 请及时更新`,
)
}
})
const constructServerSubmission = constructServer(options.moduleDefs)
const constructServerSubmission = consturctServer(options.moduleDefs)
const [_, app] = await Promise.all([
checkVersionSubmission,
constructServerSubmission,
])
spinner.stop()
/** @type {import('express').Express & ExpressExtension} */
const appExt = app
appExt.server = app.listen(port, host, () => {
console.log(`
╔═╗╔═╗╦ ╔═╗╔╗╔╦ ╦╔═╗╔╗╔╔═╗╔═╗╔╦╗
╠═╣╠═╝║ ║╣ ║║║╠═╣╠═╣║║║║ ║╣ ║║
╩ ╩╩ ╩ ╚═╝╝╚╝╩ ╩╩ ╩╝╚╝╚═╝╚═╝═╩╝
`)
logger.info(`
- Server started successfully @ http://${host ? host : 'localhost'}:${port}
- Environment: ${process.env.NODE_ENV || 'development'}
- Node Version: ${process.version}
- Process ID: ${process.pid}`)
console.log(`server running @ http://${host ? host : 'localhost'}:${port}`)
})
return appExt
+1 -1
View File
@@ -4,7 +4,7 @@ const host = global.host || 'http://localhost:3000'
describe('测试获取歌词是否正常', () => {
it('数据应该有 lrc 字段', (done) => {
const qs = {
id: 1969519579,
id: 347230,
realIP: global.cnIp,
}
+3 -4
View File
@@ -4,14 +4,13 @@ const host = global.host || 'http://localhost:3000'
describe('测试获取歌曲是否正常', () => {
it('歌曲的 url 不应该为空', (done) => {
const qs = {
id: 2709812973,
level: 'exhigh',
//realip: '125.88.253.199',
id: 464315036,
br: 999000,
realIP: global.cnIp,
}
axios
.get(`${host}/song/url/v1`, {
.get(`${host}/song/url`, {
params: qs,
})
.then(({ status, data }) => {
+3 -3
View File
@@ -4,17 +4,17 @@ const host = global.host || 'http://localhost:3000'
describe('测试搜索是否正常', () => {
it('获取到的数据的 name 应该和搜索关键词一致', (done) => {
const qs = {
keywords: '拂晓 Proi Proi',
keywords: '海阔天空',
type: 1,
realIP: global.cnIp,
}
axios
.get(`${host}/cloudsearch`, {
.get(`${host}/search`, {
params: qs,
})
.then(({ status, data }) => {
if (status == 200) {
assert(data.result.songs[0].name === '拂晓 Proi Proi')
assert(data.result.songs[0].name === '海阔天空')
}
done()
})
-1
View File
@@ -3,7 +3,6 @@
"target": "ES2015",
"module": "commonjs",
"experimentalDecorators": true,
"ignoreDeprecations": "6.0",
"moduleResolution": "node",
"lib": [
"esnext",
+6 -10
View File
@@ -1,6 +1,5 @@
var url = require('url')
var MemoryCache = require('./memory-cache')
const logger = require('./logger.js')
var t = {
ms: 1,
@@ -155,12 +154,9 @@ function ApiCache() {
}
// add automatic cache clearing from duration, includes max limit on setTimeout
timers[key] = setTimeout(
function () {
instance.clear(key, true)
},
Math.min(duration, 2147483647),
)
timers[key] = setTimeout(function () {
instance.clear(key, true)
}, Math.min(duration, 2147483647))
}
function accumulateContent(res, content) {
@@ -353,7 +349,7 @@ function ApiCache() {
try {
redis.del(key)
} catch (err) {
logger.info('[apicache] error in redis.del("' + key + '")')
console.log('[apicache] error in redis.del("' + key + '")')
}
}
index.all = index.all.filter(doesntMatch(key))
@@ -377,7 +373,7 @@ function ApiCache() {
try {
redis.del(target)
} catch (err) {
logger.info('[apicache] error in redis.del("' + target + '")')
console.log('[apicache] error in redis.del("' + target + '")')
}
}
@@ -408,7 +404,7 @@ function ApiCache() {
try {
redis.del(key)
} catch (err) {
logger.info('[apicache] error in redis.del("' + key + '")')
console.log('[apicache] error in redis.del("' + key + '")')
}
})
}

Some files were not shown because too many files have changed in this diff Show More