初始化提交

This commit is contained in:
史悦
2026-01-07 16:46:09 +08:00
commit 0dbb36be9d
113 changed files with 16197 additions and 0 deletions

22
.dockerignore Normal file
View File

@@ -0,0 +1,22 @@
npm-debug.log
Dockerfile*
docker-compose*
.dockerignore
.git
.github
.gitignore
README.md
LICENSE
dist
imgs
backend/.vscode
backend/.data
backend/.profile/
backend/foamzou
backend/node_modules
backend/bin/*
frontend/node_modules
frontend/.vscode

139
.gitignore vendored Normal file
View File

@@ -0,0 +1,139 @@
# repo file
/backend/.profile
/backend/.data
/backend/bin/media-get*
/backend/public
.vscode
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

57
Dockerfile Normal file
View File

@@ -0,0 +1,57 @@
# stage: build frontend
FROM surnet/alpine-node-opencv:16.13.0-4.5.1 AS FRONTEND_BUILD
WORKDIR /app
RUN apk add --no-cache git && \
npm install -g pnpm@7.8.0
ENV NODE_OPTIONS="--max-old-space-size=4096"
COPY frontend frontend/
WORKDIR /app/frontend
RUN pnpm install --verbose && \
pnpm run build
# stage: build backend
FROM surnet/alpine-node-opencv:16.13.0-4.5.1 AS BACKEND_BUILD
WORKDIR /app
RUN apk add --no-cache git && \
npm install -g pnpm@7.8.0
ENV CROSS_COMPILING=1
ENV NODE_OPTIONS="--max-old-space-size=4096"
COPY backend backend/
COPY scripts scripts/
COPY package.json ./
WORKDIR /app/backend
RUN pnpm install --production --verbose
# Download media-get
RUN mkdir -p /app/backend/bin && \
node /app/scripts/setup-for-build-docker.js
# stage: final
FROM iamccc/alpine-node:16.20
WORKDIR /app
# Install FFmpeg
COPY --from=pldin601/static-ffmpeg:22.04.061404-87ac0d7 /ffmpeg /ffprobe /usr/local/bin/
RUN chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe
ENV PATH="/usr/local/bin:${PATH}"
ENV NODE_ENV=production
# Copy backend with media-get
COPY --from=BACKEND_BUILD /app/backend ./backend
COPY --from=FRONTEND_BUILD /app/frontend/dist ./backend/public
# Ensure media-get is executable
RUN chmod +x /app/backend/bin/media-get
EXPOSE 5566
CMD ["node", "backend/src/index.js"]

201
LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

187
README.md Normal file
View File

@@ -0,0 +1,187 @@
# Melody
## 项目介绍
<img src="./imgs/melody.png" width="100" ></img>
大家好,我叫 Melody你的音乐精灵旨在帮助你更好地管理音乐。目前的主要能力是帮助你将喜欢的歌曲或者音频上传到音乐平台的云盘。
## 免责声明
- 本项目所搜索的歌曲均来源于音乐平台的免费公开资源。请确保在使用本项目时遵守相关音乐平台的服务条款和版权规定。
- 本项目仅供技术学习和交流使用,使用者仅限于个人学习,不得用于任何商业目的。使用者应自行承担因使用本项目而可能产生的法律责任。
为了避免不必要的纠纷和账号安全问题,本项目不会以任何形式提供在线 demo 服务
## Feature
- 支持在各大音乐和视频网站检索歌曲。目前支持 咪咕、网易云、QQ 音乐、酷狗、bilibili、抖音等站点。详情可以在我的 [media-get](https://github.com/foamzou/media-get#%E6%94%AF%E6%8C%81%E7%9A%84%E7%BD%91%E7%AB%99) 项目中查看
- 支持一键下载到本地,一键上传到云盘
- 支持定时上传网易云歌单歌曲到网易云云盘
- 支持定时同步网易云歌单歌曲到本地
- 用链接搜索歌曲(例如使用 b站或抖音的视频链接进行搜索可以将对应的音频自动上传到音乐云盘
- 一键“解锁”无法播放的歌曲(一键检测变灰的歌曲,自动从公共资源搜索最佳资源,自动上传到云盘,自动匹配歌曲信息。代替繁琐的人工操作,实现可播放)(实验性功能,目前仅支持网易云)
- PC 端、移动端适配良好(支持 PWA
- 部署简单,支持 docker
## 安装和启动
### 方式一Docker 安装
1. 在你的宿主机创建一个目录,例如: `~/melody-profile`
2. 创建镜像,有两种方式选择(注意修改下面的宿主机目录为你实际的)
- 从 hub.docker.com 拉取
```
docker run -d -p 5566:5566 -v ~/melody-profile:/app/backend/.profile -v /你宿主机的路径:/app/melody-data foamzou/melody:latest
```
- 从代码编译镜像(若你的 docker 不支持 DOCKER_BUILDKIT则去掉)
```
DOCKER_BUILDKIT=1 docker build -t melody .
docker run -d -p 5566:5566 -v ~/melody-profile:/app/backend/.profile -v /你宿主机的路径:/app/melody-data melody
```
3. 后续更新(以从 hub.docker.com 更新为例)
```
docker pull docker.io/foamzou/melody:latest
docker kill <CONTAINER ID>
docker run -d -p 5566:5566 -v ~/melody-profile:/app/backend/.profile -v /你宿主机的路径:/app/melody-data foamzou/melody:latest
```
### 方式二:源码安装
1. 依赖
确保以下两个依赖是安装好的
1. node.js ([官网下载](https://nodejs.org/zh-cn/download/))
2. FFmpeg ([windows 安装介绍](https://zhuanlan.zhihu.com/p/400952501))
2. 下载源码、初始化服务、运行服务
```
git clone https://github.com/foamzou/melody.git
cd melody && npm run init && npm run app
```
3. 若后面代码有更新,下次执行该命令即可更新
```
npm run update && npm run app
```
### 方式三:通过第三方部署
<details>
<summary>通过宝塔面板一键部署</summary>
#### 前提
* 仅适用于宝塔面板 9.2.0 及以上版本
* 安装宝塔面板,前往[宝塔面板](https://www.bt.cn/new/download.html)官网,选择正式版的脚本下载安装
#### 部署
1. 登录宝塔面板,在左侧菜单栏中点击 `Docker`
2. 首次会提示安装`Docker`和`Docker Compose`服务,点击立即安装,若已安装请忽略。
3. 安装完成后在`Docker-应用商店-实用工具`中找到 `Melody`,点击`安装`,也可以在搜索框直接搜索`melody`。
4. 设置域名等基本信息,点击`确定`
* 说明:
* 名称:应用名称,默认`melody_随机字符`
* 版本选择:默认`latest`
* 域名:如您需要通过域名访问,请在此处填写您的域名
* 允许外部访问:如您需通过`IP+Port`直接访问,请勾选,如您已经设置了域名,请不要勾选此处
* 端口:默认`5568`,可自行修改
* CPU 限制0 为不限制,根据实际需要设置
* 内存限制0 为不限制,根据实际需要设置
5. 提交后面板会自动进行应用初始化,大概需要`1-3`分钟,初始化完成后即可访问。
</details>
### 配置你的账号(可选)
默认的 melody key 为: `melody`,若你的服务部署在私有网络,则可以不用修改(网易云账号、密码可以在 web 页面设置)。
若需要修改或添加新账号,则编辑 `backend/.profile/accounts.json` (安装方式为 docker 的则为:`你的宿主机目录/accounts.json`
1. 该 JSON 中的 key 是 `Melody Key`,是你在网页访问该服务的唯一凭证
2. 网易云账号信息: `account` 和 `password` 可以后续在网页修改
3. 该 JSON 是个数组,支持配置多个账号
Q: 更新了 accounts.json 如何生效?
A: 两种方式。1: 重启服务。2: 网页端 `我的音乐账号` tab 下,随便修改点账号,密码,然后点击 `更新账号密码`,这样会从 accounts.json 更新信息到内存(我后面优化下这块)
### 浏览器访问
最后,在浏览器访问 http://127.0.0.1:5566 就可以使用啦~
## 功能介绍
### 关键词搜索歌曲
如果试听后是你想要的,点击上传按钮会将该歌曲上传到你的网易云音乐云盘
<img src="./imgs/1-home-search-keyword.png" width="1000" ></img>
### 链接搜索
有时候我们在 b 站 听到好听的歌,也可以上传到云盘
<img src="./imgs/2-home-search-url.png" width="1000" ></img>
### 一键解锁歌单
点击 `解锁全部`(实验性功能) 后,服务会自动匹配每首歌,并把歌曲上传到云盘,最后做个 match以保证你还能看到歌词、评论
<img src="./imgs/3-playlist.png" width="1000" ></img>
### 手动搜索匹配
当某首歌自动解锁失败后,还可以手动点击搜索按钮,找到符合的歌曲后,手动点击上传按钮
<img src="./imgs/4-playlist-search.png" width="1000" ></img>
### 移动端适配
<div>
<img src="./imgs/mobile-1.png" width="300" ></img>
<img src="./imgs/mobile-2.png" width="300" ></img>
</div>
<div style="margin-top:30px;">
<img src="./imgs/mobile-3.png" width="300" ></img>
<img src="./imgs/mobile-4.png" width="300" ></img>
</div>
## Roadmap
计划在后面支持以下功能
- [x] 页面适配移动端
- [ ] 浏览器油猴脚本
- [ ] 云盘歌曲 match 手动纠错
- [ ] 支持播放列表
- [x] 支持播放云盘的歌曲
- [x] 支持 docker 部署
- [ ] 支持 youtube-dl,you-dl 等工具作为输入源
- [ ] 支持 酷狗、qq 音乐等音乐平台的云盘作为输出
- [ ] 偏好设置
- [ ] 版本更新提示
## Q & A
1. Q移动端版本为什么点击下载歌曲会跳新的页面
A有的浏览器不支持嗅探的会有这个问题。因为外部资源文件都不允许跨域无法用常规下载方式 save as。考虑后续 hack
2. Q移动端版本为什么在数据网络无法播放歌曲
A发现某些网络下没有触发 `canplaythrough` 事件wifi 环境下一般是没有问题的。
3. Q为什么移动端 PWA点击跳转到其他页面时无法返回到原来页面
APWA 在移动端不支持使用外部浏览器打开外链,只能在应用内打开,因此会有各种奇怪问题。此时,只能先杀死应用。
4. Q为什么我部署的服务PWA 始终出不了?
APWA 要求服务必须是 HTTPS。
5. Q: 为什么更新 media-get 组件后,搜索报错
A: 目前存在 bug更新完 media-get 组件之后,请务必重启 docker 容器或服务,否则将无法继续使用
## Change log
见 [Release](https://github.com/foamzou/melody/releases)
## 致谢
- [NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi) 的网易云 API
- [MakeGirlsMoe](https://make.girls.moe/) 生成的 Melody 虚拟形象图片
- [Media Get](https://github.com/foamzou/media-get) 我的开源项目

226
api.md Normal file
View File

@@ -0,0 +1,226 @@
# TuneHub API 文档
TuneHub 是一个统一的音乐信息解析服务。它打破了不同音乐平台之间的壁垒,提供了一套标准化的 API 接口。
**Base URL:** `https://music-dl.sayqz.com`
**Version:** 1.0.0
---
## 核心 API
### 1. 获取歌曲基本信息
获取歌曲的名称、歌手、专辑等基本元数据信息。
* **Method:** `GET`
* **Endpoint:** `/api/`
* **Query Parameters:**
* `source`: `string` - **必需**. 平台标识 (例如: `netease`, `kuwo`, `qq`).
* `id`: `string` - **必需**. 歌曲 ID.
* `type`: `string` - **必需**. 固定为 `info`.
* **Response Example (200 OK):**
```json
{
"code": 200,
"message": "success",
"data": {
"name": "歌曲名称",
"artist": "歌手名称",
"album": "专辑名称",
"url": "https://music-dl.sayqz.com/api/?source=netease&id=123456&type=url",
"pic": "https://music-dl.sayqz.com/api/?source=netease&id=123456&type=pic",
"lrc": "https://music-dl.sayqz.com/api/?source=netease&id=123456&type=lrc"
},
"timestamp": "2025-11-23T12:00:00.000+08:00"
}
```
### 2. 获取音乐文件链接
获取音乐文件链接。成功时返回 302 Redirect 到实际的音乐文件 URL。
* **Method:** `GET`
* **Endpoint:** `/api/`
* **Query Parameters:**
* `source`: `string` - **必需**. 平台标识.
* `id`: `string` - **必需**. 歌曲 ID.
* `type`: `string` - **必需**. 固定为 `url`.
* `br`: `string` - *可选*. 音质 (默认: `320k`). 可选值: `128k`, `320k`, `flac`, `flac24bit`.
### 3. 获取专辑封面
获取歌曲的专辑封面图片。成功时返回 302 Redirect 到图片 URL。
* **Method:** `GET`
* **Endpoint:** `/api/`
* **Query Parameters:**
* `source`: `string` - **必需**. 平台标识.
* `id`: `string` - **必需**. 歌曲 ID.
* `type`: `string` - **必需**. 固定为 `pic`.
### 4. 获取歌词
获取LRC格式的歌词。
* **Method:** `GET`
* **Endpoint:** `/api/`
* **Query Parameters:**
* `source`: `string` - **必需**. 平台标识.
* `id`: `string` - **必需**. 歌曲 ID.
* `type`: `string` - **必需**. 固定为 `lrc`.
* **Response Example (200 OK, text/plain):**
```
[00:00.00]歌词第一行
[00:05.50]歌词第二行
```
### 5. 搜索歌曲
* **Method:** `GET`
* **Endpoint:** `/api/`
* **Query Parameters:**
* `source`: `string` - **必需**. 平台标识.
* `type`: `string` - **必需**. 固定为 `search`.
* `keyword`: `string` - **必需**. 搜索关键词.
* `limit`: `integer` - *可选*. 返回数量 (默认: 20).
* **Response Example (200 OK):**
```json
{
"code": 200,
"message": "success",
"data": {
"keyword": "周杰伦",
"total": 10,
"results": [
{
"id": "123456",
"name": "歌曲名称",
"artist": "周杰伦",
"album": "专辑名称",
"url": "https://music-dl.sayqz.com/api/?...",
"platform": "netease"
}
]
}
}
```
### 6. 聚合搜索
一次性并发请求所有启用的平台,并对结果进行智能混合排列。
* **Method:** `GET`
* **Endpoint:** `/api/`
* **Query Parameters:**
* `type`: `string` - **必需**. 固定为 `aggregateSearch`.
* `keyword`: `string` - **必需**. 搜索关键词.
* **Response Example (200 OK):**
```json
{
"code": 200,
"message": "success",
"data": {
"keyword": "周杰伦",
"results": [
{
"id": "123456",
"name": "歌曲名称",
"artist": "周杰伦",
"platform": "netease"
},
{
"id": "789012",
"name": "另一首歌",
"artist": "周杰伦",
"platform": "kuwo"
}
]
}
}
```
### 7. 获取歌单详情
* **Method:** `GET`
* **Endpoint:** `/api/`
* **Query Parameters:**
* `source`: `string` - **必需**. 平台标识.
* `id`: `string` - **必需**. 歌单 ID.
* `type`: `string` - **必需**. 固定为 `playlist`.
### 8. 获取排行榜列表
* **Method:** `GET`
* **Endpoint:** `/api/`
* **Query Parameters:**
* `source`: `string` - **必需**. 平台标识.
* `type`: `string` - **必需**. 固定为 `toplists`.
### 9. 获取排行榜歌曲
* **Method:** `GET`
* **Endpoint:** `/api/`
* **Query Parameters:**
* `source`: `string` - **必需**. 平台标识.
* `id`: `string` - **必需**. 排行榜 ID.
* `type`: `string` - **必需**. 固定为 `toplist`.
---
## 系统 API
### 10. 系统状态
* **Method:** `GET`
* **Endpoint:** `/status`
### 11. 健康检查
* **Method:** `GET`
* **Endpoint:** `/health`
---
## 统计 API
### 12. 获取统计数据
* **Method:** `GET`
* **Endpoint:** `/stats`
* **Query Parameters:**
* `period`: `string` - *可选*. 时间段 (默认: `today`).
* `groupBy`: `string` - *可选*. 分组依据 (默认: `platform`).
### 13. 获取统计摘要
* **Method:** `GET`
* **Endpoint:** `/stats/summary`
### 14. 平台统计概览
* **Method:** `GET`
* **Endpoint:** `/stats/platforms`
* **Query Parameters:**
* `period`: `string` - *可选*. 时间段 (默认: `today`).
### 15. QPS 统计
* **Method:** `GET`
* **Endpoint:** `/stats/qps`
* **Query Parameters:**
* `period`: `string` - *可选*. 时间段 (默认: `today`).
### 16. 趋势数据
* **Method:** `GET`
* **Endpoint:** `/stats/trends`
* **Query Parameters:**
* `period`: `string` - *可选*. 时间段 (默认: `week`).
### 17. 请求类型统计
* **Method:** `GET`
* **Endpoint:** `/stats/types`
* **Query Parameters:**
* `period`: `string` - *可选*. 时间段 (默认: `today`).

1
backend/.nvmrc Normal file
View File

@@ -0,0 +1 @@
v16.13.0

View File

@@ -0,0 +1,14 @@
{
"Melody Key建议随机生成 UUID": {
"loginType": "固定为phone目前仅支持手机号+密码登录。下面为示例",
"account": "填写手机号。如18888888888",
"password": "填写密码",
"platform": "固定为wy目前仅支持网易云。"
},
"melody": {
"loginType": "phone",
"account": "",
"password": "",
"platform": "wy"
}
}

0
backend/bin/.gitkeep Normal file
View File

33
backend/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "melody-backend",
"version": "0.1.0",
"main": "index.js",
"scripts": {
"dev": "nodemon node src/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/foamzou/personal-music-assistant.git"
},
"author": "foamzou",
"license": "ISC",
"bugs": {
"url": "https://github.com/foamzou/personal-music-assistant/issues"
},
"homepage": "https://github.com/foamzou/personal-music-assistant#readme",
"dependencies": {
"NeteaseCloudMusicApi": "4.6.7",
"body-parser": "^1.19.1",
"consola": "^2.15.3",
"cors": "^2.8.5",
"express": "^4.17.2",
"got": "11",
"md5": "^2.3.0",
"node-schedule": "^2.1.1",
"uuid": "^8.3.2"
},
"devDependencies": {
"nodemon": "^2.0.15"
}
}

1699
backend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
module.exports = {
StatusJobAlreadyExisted: 40010,
StatusJobNoNeedToCreate: 40011,
StatusNoNeedToSync: 40012,
}

View File

@@ -0,0 +1,6 @@
module.exports = {
Pending: "待开始",
InProgress: "进行中",
Failed: "失败",
Finished: "已完成",
}

View File

@@ -0,0 +1,7 @@
module.exports = {
UnblockedPlaylist: "UnblockedPlaylist",
UnblockedSong: "UnblockedSong",
SyncSongFromUrl: "SyncSongFromUrl",
DownloadSongFromUrl: "DownloadSongFromUrl",
SyncThePlaylistToLocalService: "SyncThePlaylistToLocalService",
}

View File

@@ -0,0 +1,4 @@
module.exports = {
High: "high",
Lossless: "lossless",
}

View File

@@ -0,0 +1,40 @@
module.exports = {
consts: {
Netease : {
code: 'netease',
label: '网易云',
},
Bilibili : {
code: 'bilibili',
label: '哔哩哔哩',
},
Douyin : {
code: 'douyin',
label: '抖音',
},
Kugou : {
code: 'kugou',
label: '酷狗',
},
Kuwo : {
code: 'kuwo',
label: '酷我',
},
Migu : {
code: 'migu',
label: '咪咕',
},
QQ : {
code: 'qq',
label: 'QQ',
},
Youtube : {
code: 'youtube',
label: 'Youtube',
},
Qmkg : {
code: 'qmkg',
label: '全民K歌',
},
},
}

View File

@@ -0,0 +1,6 @@
module.exports = class AccountNotExisted extends Error {
constructor(message) {
super(message);
this.name = 'AccountNotExisted';
}
}

View File

@@ -0,0 +1,108 @@
const AccountService = require('../service/account');
const WYAPI = require('../service/music_platform/wycloud');
const { storeCookie } = require('../service/music_platform/wycloud/transport.js');
async function get(req, res) {
res.send({
status: 0,
data: {
account: await getWyAccountInfo(req.account.uid)
}
});
}
async function set(req, res) {
const loginType = req.body.loginType;
const accountName = req.body.account;
const password = req.body.password;
const countryCode = req.body.countryCode;
const config = req.body.config;
const name = req.body.name;
if (name) {
// check if the name is already used by other accounts
const allAccounts = await AccountService.getAllAccountsWithoutSensitiveInfo();
for (const account of Object.values(allAccounts)) {
if (account.name === name && account.uid !== req.account.uid) {
res.status(412).send({ status: 1, message: '昵称已被占用啦,请换一个试试吧', data: {} });
return;
}
}
}
const ret = await AccountService.setAccount(req.account.uid, loginType, accountName, password, countryCode, config, name);
res.send({
status: ret ? 0 : 1,
data: {
account: await getWyAccountInfo(req.account.uid)
}
});
}
async function getWyAccountInfo(uid) {
const account = AccountService.getAccount(uid)
const wyInfo = await WYAPI.getMyAccount(uid);
account.wyAccount = wyInfo;
return account;
}
async function qrLoginCreate(req, res) {
const qrData = await WYAPI.qrLoginCreate(req.account.uid);
if (qrData === false) {
res.status(500).send({
status: 1,
message: 'qr login create failed',
data: {}
});
return;
}
res.send({
status: 0,
data: {
qrKey: qrData.qrKey,
qrCode: qrData.qrCode,
}
});
}
async function qrLoginCheck(req, res) {
// 800 为二维码过期; 801 为等待扫码; 802 为待确认; 803 为授权登录成功
const loginCheckRet = await WYAPI.qrLoginCheck(req.account.uid, req.query.qrKey);
let account = false;
if (loginCheckRet.code == 803) {
// it's a bad design to export the transport function here. Let's refactor it at a good time.
// should be put the cookie method to a cookie manager service
req.account.loginType = 'qrcode';
req.account.account = 'temp';
storeCookie(req.account.uid, req.account, loginCheckRet.cookie);
account = await getWyAccountInfo(req.account.uid);
req.account.account = account.wyAccount.userId;
storeCookie(req.account.uid, req.account, loginCheckRet.cookie);
AccountService.setAccount(req.account.uid, 'qrcode', account.wyAccount.userId, '', null);
account = await getWyAccountInfo(req.account.uid);
}
res.send({
status: loginCheckRet ? 0 : 1,
data: {
wyQrStatus: loginCheckRet.code,
account
}
});
}
async function getAllAccounts(req, res) {
const data = await AccountService.getAllAccountsWithoutSensitiveInfo();
res.send({
status: 0,
data: data
});
}
module.exports = {
get: get,
set: set,
qrLoginCreate,
qrLoginCheck,
getAllAccounts,
}

View File

@@ -0,0 +1,23 @@
const ConfigService = require('../service/config_manager');
async function getGlobalConfig(req, res) {
const config = await ConfigService.getGlobalConfig();
res.send({
status: 0,
data: config
});
}
async function setGlobalConfig(req, res) {
const config = req.body;
await ConfigService.setGlobalConfig(config);
res.send({
status: 0,
data: config
});
}
module.exports = {
getGlobalConfig,
setGlobalConfig,
}

View File

@@ -0,0 +1,41 @@
const logger = require('consola');
const { getMediaGetInfo, getLatestMediaGetVersion, downloadTheLatestMediaGet } = require('../service/media_fetcher/media_get');
async function checkLibVersion(req, res) {
const query = req.query;
if (!['mediaGet'].includes(query.lib)) {
res.send({
status: 1,
message: "lib name is invalid",
});
return;
}
const latestVersion = await getLatestMediaGetVersion();
const mediaGetInfo = await getMediaGetInfo();
res.send({
status: 0,
data: {
mediaGetInfo,
latestVersion,
}
});
}
async function downloadTheLatestLib(req, res) {
const {version} = req.body;
const succeed = await downloadTheLatestMediaGet(version);
res.send({
status: succeed ? 0 : 1,
data: {}
});
}
module.exports = {
checkLibVersion: checkLibVersion,
downloadTheLatestLib: downloadTheLatestLib,
}

View File

@@ -0,0 +1,92 @@
const logger = require('consola');
const { getUserAllPlaylist } = require('../service/music_platform/wycloud');
const { getPlaylistDetail } = require('../service/music_platform/tunehub');
const Source = require('../consts/source').consts;
function splitArtists(artist) {
if (!artist) {
return [];
}
return artist
.split(/[、/]/)
.map(item => item.trim())
.filter(Boolean);
}
function normalizeTunehubPlaylist(playlistId, detail) {
const info = detail && detail.info ? detail.info : {};
const list = detail && Array.isArray(detail.list) ? detail.list : [];
const songs = list.map(item => {
const artists = splitArtists(item.artist);
const primaryArtist = artists[0] || item.artist || '';
const cover = item.pic || info.pic || '';
const isBlocked = !item.types || item.types.length === 0;
return {
songId: item.id,
songName: item.name || '',
artists,
artist: primaryArtist,
duration: 0,
album: item.album || '',
cover,
pageUrl: `https://music.163.com/song?id=${item.id}`,
playUrl: item.url || '',
isBlocked,
isCloud: false,
};
});
return {
id: playlistId,
name: info.name || '',
cover: info.pic || '',
songs,
};
}
async function listAllPlaylists(req, res) {
const uid = req.account.uid;
const playlists = await getUserAllPlaylist(uid);
if (playlists === false) {
logger.error(`get user all playlist failed, uid: ${uid}`);
}
res.send({
status: playlists ? 0 : 1,
data: {
playlists,
}
});
}
async function listSongsFromPlaylist(req, res) {
const uid = req.account.uid;
const source = req.params.source;
const playlistId = req.params.id;
if (source !== Source.Netease.code || !playlistId) {
res.send({
status: 1,
message: "source or id is invalid",
});
return;
}
const detail = await getPlaylistDetail(source, playlistId);
if (detail === false) {
logger.error(`get playlist detail failed, uid: ${uid}`);
}
const playlists = detail ? normalizeTunehubPlaylist(playlistId, detail) : false;
res.send({
status: playlists ? 0 : 1,
data: {
playlists: playlists ? playlists : [],
}
});
}
module.exports = {
listAllPlaylists: listAllPlaylists,
listSongsFromPlaylist: listSongsFromPlaylist,
}

View File

@@ -0,0 +1,46 @@
const logger = require('consola');
const got = require('got');
async function proxyAudio(req, res) {
const url = req.query.url;
const source = req.query.source;
const referer = req.query.referer;
if (!url || !source) {
res.status(400).send({
status: 1,
message: "url and source are required"
});
return;
}
// 只允许 bilibili 源
if (source !== 'bilibili') {
res.status(403).send({
status: 1,
message: "only bilibili source is allowed"
});
return;
}
try {
const stream = got.stream(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko)',
'Referer': referer || 'https://www.bilibili.com'
}
});
stream.pipe(res);
} catch (err) {
logger.error('proxy audio error:', err);
res.status(500).send({
status: 1,
message: "proxy failed"
});
}
}
module.exports = {
proxyAudio
};

View File

@@ -0,0 +1,27 @@
const schedulerService = require('../service/scheduler');
const AccountService = require('../service/account');
async function getNextRun(req, res) {
const localNextRun = schedulerService.getLocalSyncNextRun();
const accounts = await AccountService.getAllAccounts();
const cloudNextRuns = {};
for (const uid in accounts) {
const nextRun = schedulerService.getCloudSyncNextRun(uid);
if (nextRun) {
cloudNextRuns[uid] = nextRun;
}
}
res.send({
status: 0,
data: {
localNextRun,
cloudNextRuns
}
});
}
module.exports = {
getNextRun
};

View File

@@ -0,0 +1,31 @@
const logger = require('consola');
const { getMetaWithUrl } = require('../service/media_fetcher');
const { matchUrlFromStr } = require('../utils/regex');
async function getMeta(req, res) {
const query = req.query;
const url = matchUrlFromStr(query.url);
if (!url) {
res.send({
status: 1,
message: "url is invalid",
});
return;
}
const songMeta = await getMetaWithUrl(url);
songMeta && (songMeta.pageUrl = url);
res.send({
status: songMeta ? 0 : 1,
data: {
songMeta,
}
});
}
module.exports = {
getMeta: getMeta
}

View File

@@ -0,0 +1,76 @@
const logger = require('consola');
const { searchSongsWithKeyword, searchSongsWithSongMeta } = require('../service/search_songs');
const { getPlayUrlWithOptions } = require('../service/songs_info');
const { getMetaWithUrl } = require('../service/media_fetcher');
const { matchUrlFromStr } = require('../utils/regex');
async function search(req, res) {
const query = req.query;
const keywordOrUrl = query.keyword;
if (!keywordOrUrl) {
res.send({
status: 1,
message: "keyword is required",
});
return;
}
let songs = [];
const url = matchUrlFromStr(keywordOrUrl);
if (!url) {
songs = await searchSongsWithKeyword(keywordOrUrl);
} else {
const songMeta = await getMetaWithUrl(url);
if (!songMeta) {
res.send({
status: 2,
message: "can not get song meta with this url",
});
return;
}
songs = await searchSongsWithSongMeta({
songName: songMeta.songName,
artist: songMeta.artist,
album: songMeta.album,
duration: songMeta.duration,
}, {
expectArtistAkas: [],
allowSongsJustMatchDuration: true,
allowSongsNotMatchMeta: true,
});
}
res.send({
status: 0,
data: {
songs: songs ? songs : [],
}
});
}
async function getPlayUrl(req, res) {
const source = req.params.source;
const songId = req.params.id;
if (!source || !songId) {
res.send({
status: 1,
message: "source and songId is required",
});
return;
}
const playUrl = await getPlayUrlWithOptions(req.account.uid, source, songId);
res.send({
status: 0,
data: {
playUrl,
}
});
}
module.exports = {
search: search,
getPlayUrl: getPlayUrl
}

View File

@@ -0,0 +1,183 @@
const logger = require('consola');
const { unblockMusicInPlaylist, unblockMusicWithSongId } = require('../service/sync_music');
const JobType = require('../consts/job_type');
const Source = require('../consts/source').consts;
const { matchUrlFromStr } = require('../utils/regex');
const { syncSingleSongWithUrl, syncPlaylist } = require('../service/sync_music');
const findTheBestMatchFromWyCloud = require('../service/search_songs/find_the_best_match_from_wycloud');
const JobManager = require('../service/job_manager');
const JobStatus = require('../consts/job_status');
const BusinessCode = require('../consts/business_code');
async function createJob(req, res) {
const uid = req.account.uid;
const request = req.body;
const jobType = request.jobType;
const options = request.options;
let jobId = 0;
if (jobType === JobType.UnblockedPlaylist || jobType === JobType.SyncThePlaylistToLocalService) {
const source = request.playlist && request.playlist.source;
const playlistId = request.playlist && request.playlist.id;
if (source !== Source.Netease.code || !playlistId) {
res.status(412).send({
status: 1,
message: "source or id is invalid",
});
return;
}
if (jobType === JobType.UnblockedPlaylist) {
jobId = await unblockMusicInPlaylist(uid, source, playlistId, {
syncWySong: options.syncWySong,
syncNotWySong: options.syncNotWySong,
asyncExecute: true,
});
} else {
jobId = await syncPlaylist(uid, source, playlistId)
}
} else if (jobType === JobType.UnblockedSong) {
const source = request.source;
const songId = request.songId;
if (source !== Source.Netease.code || !songId) {
res.status(412).send({
status: 1,
message: "source or id is invalid",
});
return;
}
jobId = await unblockMusicWithSongId(uid, source, songId)
} else if (jobType === JobType.SyncSongFromUrl || jobType === JobType.DownloadSongFromUrl) {
const request = req.body;
const url = request.urlJob && matchUrlFromStr(request.urlJob.url);
if (!url) {
res.status(412).send({
status: 1,
message: "url is invalid",
});
return;
}
let meta = {};
const songId = request.urlJob && request.urlJob.meta.songId ? request.urlJob.meta.songId : "";
if (request.urlJob.meta && (request.urlJob.meta.songName !== "" && request.urlJob.meta.artist !== "")) {
meta = {
songName: request.urlJob.meta.songName,
artist: request.urlJob.meta.artist,
album : request.urlJob.meta.album ? request.urlJob.meta.album : "",
};
}
if (songId) {
const songFromWyCloud = await findTheBestMatchFromWyCloud(req.account.uid, {
songName: meta.songName,
artist: meta.artist,
album: meta.album,
musicPlatformSongId: songId,
});
if (!songFromWyCloud) {
logger.error(`song not found in wycloud`);
res.status(412).send({
status: 1,
message: "can not find song in wycloud with your songId",
});
return;
}
meta.songFromWyCloud = songFromWyCloud;
}
// create job
const args = `${jobType}: {"url":${url}}`;
if (await JobManager.findActiveJobByArgs(uid, args)) {
logger.info(`${jobType} job is already running.`);
jobId = BusinessCode.StatusJobAlreadyExisted;
} else {
const operation = jobType === JobType.SyncSongFromUrl ? "上传" : "下载";
jobId = await JobManager.createJob(uid, {
name: `${operation}歌曲:${meta.songName ? meta.songName : url}`,
args,
type: jobType,
status: JobStatus.Pending,
desc: `歌曲:${meta.songName ? meta.songName : url}`,
progress: 0,
tip: `等待${operation}`,
createdAt: Date.now()
});
// async job
syncSingleSongWithUrl(req.account.uid, url, meta, jobId, jobType).then(async ret => {
await JobManager.updateJob(uid, jobId, {
status: ret === true ? JobStatus.Finished : JobStatus.Failed,
progress: 1,
tip: ret === true ? `${operation}成功` : `${operation}失败`,
});
})
}
} else {
res.status(412).send({
status: 1,
message: "jobType is not supported",
});
return;
}
if (jobId === false) {
logger.error(`create job failed, uid: ${uid}`);
res.status(412).send({
status: 1,
message: "create job failed",
});
return;
}
if (jobId === BusinessCode.StatusJobAlreadyExisted) {
res.status(412).send({
status: BusinessCode.StatusJobAlreadyExisted,
message: "你的任务已经在跑啦,等等吧",
});
return;
}
if (jobId === BusinessCode.StatusJobNoNeedToCreate) {
res.status(412).send({
status: BusinessCode.StatusJobAlreadyExisted,
message: "你的任务无需被创建,可能是因为没有需要 sync 的歌曲",
});
return;
}
res.status(201).send({
status: jobId ? 0 : 1,
data: {
jobId,
}
});
}
async function listAllJobs(req, res) {
res.send({
status: 0,
data: {
jobs: await JobManager.listJobs(req.account.uid),
}
});
}
async function getJob(req, res) {
res.send({
status: 0,
data: {
jobs: await JobManager.getJob(req.account.uid, req.params.id),
}
});
}
module.exports = {
createJob: createJob,
listAllJobs: listAllJobs,
getJob: getJob,
}

39
backend/src/index.js Normal file
View File

@@ -0,0 +1,39 @@
const path = require('path');
const logger = require('consola');
const cors = require('cors');
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
const port = 5566;
require('./init_app')().then(() => {
const middlewareHandleError = require('./middleware/handle_error');
const middlewareAuth = require('./middleware/auth');
const proxy = require('./handler/proxy');
app.use(bodyParser.json());
app.use(cors({
origin: true,
credentials: true,
}));
// 先注册代理路由,跳过 auth 验证
app.get('/api/proxy/audio', proxy.proxyAudio);
// 其他 API 路由需要 auth
app.use('/api', middlewareAuth);
app.use('/', require('./router'));
app.use(middlewareHandleError);
app.use(express.static(path.resolve(__dirname, '../public')));
const server = app.listen(port, () => {
const host = server.address().address
const port = server.address().port
logger.info(`Express server is listening on ${host}:${port}!`)
})
const schedulerService = require('./service/scheduler');
schedulerService.start();
});

48
backend/src/init_app.js Normal file
View File

@@ -0,0 +1,48 @@
const logger = require('consola');
const fs = require('fs');
const process = require('process');
initDir();
initAccountFileIfNotExisted();
const mediaGet = require('./service/media_fetcher/media_get');
function initDir() {
// make sure all dir has been created
const dirList = [
__dirname + '/../.profile',
__dirname + '/../.profile/cookie',
__dirname + '/../.profile/data',
__dirname + '/../.profile/data/jobs',
];
dirList.forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
});
}
function initAccountFileIfNotExisted() {
const targetFile = __dirname + '/../.profile/accounts.json';
const sampleFile = __dirname + '/../accounts.sample.json';
if (!fs.existsSync(targetFile)) {
fs.copyFileSync(sampleFile, targetFile);
logger.info('初始化 accounts.json 文件成功, 默认的 melody key 为: melody');
}
}
module.exports = async function() {
// check if media-get is installed
const mediaGetInfo = await mediaGet.getMediaGetInfo();
if (mediaGetInfo === false) {
process.exit(-1);
}
if (!mediaGetInfo.hasInstallFFmpeg) {
logger.error('please install FFmpeg and FFprobe first');
process.exit(-1);
}
logger.info(`[media-get] Version: ${mediaGetInfo.versionInfo}`);
// TODO check media-get latest version
}

View File

@@ -0,0 +1,19 @@
const AccountService = require('../service/account');
const AccountNotExisted = require('../errors/account_not_existed');
const logger = require('consola');
module.exports = (req, res, next) => {
if (req.method === 'OPTIONS') {
return next();
}
if (!req.headers['mk']) {
throw new AccountNotExisted;
}
const account = AccountService.getAccount(req.headers['mk'])
if (!account) {
throw new AccountNotExisted;
}
//logger.info('user access', account);
req.account = account
next()
}

View File

@@ -0,0 +1,19 @@
const logger = require('consola');
const AccountNotExisted = require('../errors/account_not_existed');
module.exports = async (error, req, res, next) => {
logger.error('catch error', error);
if (error instanceof AccountNotExisted) {
res.status(403).send({
status: 1,
message: "account not existed",
});
return;
}
res.status(500).send({
status: 1,
message: "Internal Server Error",
});
}

42
backend/src/router.js Normal file
View File

@@ -0,0 +1,42 @@
const router = require('express').Router();
const SyncJob = require('./handler/sync_jobs');
const Songs = require('./handler/songs');
const SongMeta = require('./handler/song_meta');
const Playlists = require('./handler/playlists');
const Account = require('./handler/account');
const MediaFetcherLib = require('./handler/media_fetcher_lib');
const Config = require('./handler/config');
const Scheduler = require('./handler/scheduler');
const asyncWrapper = (cb) => {
return (req, res, next) => cb(req, res, next).catch(next);
};
router.post('/api/sync-jobs', asyncWrapper(SyncJob.createJob));
router.get('/api/sync-jobs', asyncWrapper(SyncJob.listAllJobs));
router.get('/api/sync-jobs/:id', asyncWrapper(SyncJob.getJob));
router.get('/api/songs', asyncWrapper(Songs.search));
router.get('/api/songs/:source/:id/playUrl', asyncWrapper(Songs.getPlayUrl));
router.get('/api/songs-meta', asyncWrapper(SongMeta.getMeta));
router.get('/api/playlists', asyncWrapper(Playlists.listAllPlaylists));
router.get('/api/playlists/:source/:id/songs', asyncWrapper(Playlists.listSongsFromPlaylist));
router.get('/api/account', asyncWrapper(Account.get));
router.post('/api/account', asyncWrapper(Account.set));
router.get('/api/accounts', asyncWrapper(Account.getAllAccounts));
router.get('/api/account/qrlogin-create', asyncWrapper(Account.qrLoginCreate));
router.get('/api/account/qrlogin-check', asyncWrapper(Account.qrLoginCheck));
router.get('/api/media-fetcher-lib/version-check', asyncWrapper(MediaFetcherLib.checkLibVersion));
router.post('/api/media-fetcher-lib/update', asyncWrapper(MediaFetcherLib.downloadTheLatestLib));
router.get('/api/config/global', asyncWrapper(Config.getGlobalConfig));
router.post('/api/config/global', asyncWrapper(Config.setGlobalConfig));
router.get('/api/scheduler/next-run', asyncWrapper(Scheduler.getNextRun));
module.exports = router;

View File

@@ -0,0 +1,117 @@
const AccountPath = __dirname + '/../../.profile/accounts.json';
const CookiePath = __dirname + '/../../.profile/cookie/';
let AccountMap = require(AccountPath);
const logger = require('consola');
const locker = require('../utils/simple_locker');
const fs = require('fs');
const SoundQuality = require('../consts/sound_quality');
module.exports = {
getAccount: getAccount,
setAccount: setAccount,
getAllAccounts: getAllAccounts,
getAllAccountsWithoutSensitiveInfo: getAllAccountsWithoutSensitiveInfo,
}
const defaultConfig = {
playlistSyncToWyCloudDisk: {
autoSync: {
enable: false,
frequency: 1,
frequencyUnit: "day",
onlyCreatedPlaylists: true,
},
syncWySong: true,
syncNotWySong: false,
soundQualityPreference: SoundQuality.High,
},
};
function getAccount(uid) {
const account = AccountMap[uid];
if (!account) {
logger.error(`the uid(${uid}) does not existed`);
return false;
}
if (!account.config) {
account.config = defaultConfig;
}
if (!account.config.playlistSyncToWyCloudDisk) {
account.config.playlistSyncToWyCloudDisk = defaultConfig.playlistSyncToWyCloudDisk;
}
account.uid = uid;
return account;
}
async function setAccount(uid, loginType, account, password, countryCode = '', config, name) {
const lockKey = 'setAccount';
await locker.lock(lockKey, 5);
refreshAccountFromFile();
const userAccount = getAccount(uid);
if (!userAccount) {
locker.unlock(lockKey);
return false;
}
const oldAccount = userAccount;
userAccount.loginType = loginType;
userAccount.account = account;
userAccount.password = password;
userAccount.countryCode = countryCode;
if (name) {
userAccount.name = name;
}
if (config) {
userAccount.config = config;
}
AccountMap[uid] = userAccount;
storeAccount(AccountMap);
locker.unlock(lockKey);
// clear cookie
try {
fs.unlinkSync(CookiePath + uid);
} catch(_){}
// 重启调度器以应用新的账号配置
if (config && JSON.stringify(oldAccount?.config?.playlistSyncToWyCloudDisk) !==
JSON.stringify(config.playlistSyncToWyCloudDisk)) {
const schedulerService = require('../service/scheduler');
await schedulerService.updateCloudSyncJob(uid);
}
return true;
}
function refreshAccountFromFile() {
AccountMap = JSON.parse(fs.readFileSync(AccountPath).toString());
}
function storeAccount(account) {
fs.writeFileSync(AccountPath, JSON.stringify(account, null, 4));
}
async function getAllAccounts() {
refreshAccountFromFile();
return AccountMap;
}
async function getAllAccountsWithoutSensitiveInfo() {
refreshAccountFromFile();
const filteredAccounts = {};
for (const [uid, account] of Object.entries(AccountMap)) {
filteredAccounts[uid] = {
name: account.name || uid,
uid: account.uid
};
}
return filteredAccounts;
}

View File

@@ -0,0 +1,85 @@
const sound_quality = require('../../consts/sound_quality');
const asyncFs = require('../../utils/fs');
const DataPath = `${__dirname}/../../../.profile/data`;
const ConfigPath = `${DataPath}/config`;
const GlobalConfig = `${ConfigPath}/global.json`;
const sourceConsts = require('../../consts/source').consts;
const libPath = require('path');
async function init() {
if (!await asyncFs.asyncFileExisted(ConfigPath)) {
await asyncFs.asyncMkdir(ConfigPath, { recursive: true });
}
}
init();
const GlobalDefaultConfig = {
downloadPath: '',
filenameFormat: '{songName}-{artist}',
downloadPathExisted: false,
// don't search youtube by default
sources: Object.values(sourceConsts).map(i => i.code).filter(s => s !== sourceConsts.Youtube.code),
sourceConsts,
playlistSyncToLocal: {
autoSync: {
enable: false,
frequency: 1,
frequencyUnit: "day",
},
deleteLocalFile: false,
filenameFormat: `{playlistName}${libPath.sep}{songName}-{artist}`,
soundQualityPreference: sound_quality.High,
syncAccounts: [],
},
};
async function setGlobalConfig(config) {
const oldConfig = await getGlobalConfig();
await asyncFs.asyncWriteFile(GlobalConfig, JSON.stringify(config));
// 只在本地同步配置发生变化时更新调度器
if (JSON.stringify(oldConfig.playlistSyncToLocal) !== JSON.stringify(config.playlistSyncToLocal)) {
const schedulerService = require('../scheduler');
await schedulerService.updateLocalSyncJob();
}
}
async function getGlobalConfig() {
if (!await asyncFs.asyncFileExisted(GlobalConfig)) {
return GlobalDefaultConfig;
}
const config = JSON.parse(await asyncFs.asyncReadFile(GlobalConfig));
if (!config.sources) {
config.sources = GlobalDefaultConfig.sources;
}
config.sourceConsts = GlobalDefaultConfig.sourceConsts;
config.downloadPathExisted = false;
if (config.downloadPath) {
config.downloadPathExisted = await asyncFs.asyncFileExisted(config.downloadPath);
}
if (!config.filenameFormat) {
config.filenameFormat = GlobalDefaultConfig.filenameFormat;
}
if (!config.playlistSyncToLocal) {
config.playlistSyncToLocal = GlobalDefaultConfig.playlistSyncToLocal;
}
if (!config.playlistSyncToLocal.filenameFormat) {
config.playlistSyncToLocal.filenameFormat = GlobalDefaultConfig.playlistSyncToLocal.filenameFormat;
}
if (!config.playlistSyncToLocal.soundQualityPreference) {
config.playlistSyncToLocal.soundQualityPreference = GlobalDefaultConfig.playlistSyncToLocal.soundQualityPreference;
}
if (!config.playlistSyncToLocal.syncAccounts) {
config.playlistSyncToLocal.syncAccounts = GlobalDefaultConfig.playlistSyncToLocal.syncAccounts;
}
return config;
}
module.exports = {
setGlobalConfig,
getGlobalConfig,
}

View File

View File

@@ -0,0 +1,189 @@
const logger = require('consola');
const asyncFs = require('../../utils/fs');
const genUUID = require('../../utils/uuid');
const { lock, unlock } = require('../../utils/simple_locker');
const JobStatus = require('../../consts/job_status');
const DataPath = `${__dirname}/../../../.profile/data`;
const JobDataPath = `${DataPath}/jobs`;
const JobManagerInitTime = Date.now();
async function listJobs(uid) {
const list = [];
const jobs = await getUserJobs(uid);
for (const jobId in jobs) {
const job = await getJob(uid, jobId);
if (!job) {
continue;
}
job.id = jobId;
list.push(job);
}
return list.sort((a, b) => b.createdAt - a.createdAt);
}
async function getJob(uid, jobId) {
const jobFile = await getJobFilePath(uid, jobId, false);
if (!await asyncFs.asyncFileExisted(jobFile)) {
return null;
}
const fileText = await asyncFs.asyncReadFile(jobFile);
if (fileText == "") {
return null;
}
return JSON.parse(fileText);
}
async function updateJob(uid, jobId, info) {
const lockKey = getJobLockKey(jobId);
if (!await lock(lockKey, 5)) {
logger.error(`get job locker failed, uid: ${uid}, job: ${jobId}`);
return false;
}
const job = await getJob(uid, jobId);
if (info.desc) {
job.desc = info.desc;
}
if (info.progress) {
job.progress = info.progress;
}
if (info.status) {
job.status = info.status;
}
if (info.tip) {
job.tip = info.tip;
if (!info.log) {
info.log = info.tip
}
}
if (info.log) {
if (!job.logs) {
job.logs = [];
}
job.logs.push({
time: Date.now(),
info: info.log
});
}
if (info.data) {
job.data = info.data;
}
const jobFile = await getJobFilePath(uid, jobId);
await asyncFs.asyncWriteFile(jobFile, JSON.stringify(job));
unlock(lockKey);
}
async function createJob(uid, job = {
name: '',
type: '',
desc: '',
progress: 0,
tip: '',
status: '',
logs: [],
data: {},
createdAt: Date.now(),
}) {
const jobId = genUUID();
const jobFile = await getJobFilePath(uid, jobId);
await asyncFs.asyncWriteFile(jobFile, JSON.stringify(job));
await addJobIdToUserJobList(uid, jobId);
return jobId;
}
async function deleteJob(uid, jobId) {
await removeJobIdFromUserJobList(uid, jobId);
await asyncFs.asyncUnlinkFile(await getJobFilePath(uid, jobId, false));
}
async function addJobIdToUserJobList(uid, jobId) {
const lockKey = getJobListLockKey(uid);
if (!await lock(lockKey, 5)) {
logger.error(`get job_list locker failed, uid: ${uid}`);
return false;
}
const jobs = await getUserJobs(uid);
jobs[jobId] = {
createdAt: Date.now(),
};
await asyncFs.asyncWriteFile(await getJobListFilePath(uid), JSON.stringify(jobs));
unlock(lockKey);
}
async function removeJobIdFromUserJobList(uid, jobId) {
const lockKey = getJobListLockKey(uid);
if (!await lock(lockKey, 5)) {
logger.error(`get job_list locker failed, uid: ${uid}`);
return false;
}
const jobs = await getUserJobs(uid);
delete jobs[jobId];
await asyncFs.asyncWriteFile(await getJobListFilePath(uid), JSON.stringify(jobs));
unlock(lockKey);
}
function getJobListLockKey(uid) {
return `job_list_${uid}`;
}
function getJobLockKey(jobId) {
return `job_${jobId}`;
}
async function getUserJobs(uid) {
const jobListFile = await getJobListFilePath(uid);
return JSON.parse(await asyncFs.asyncReadFile(jobListFile));
}
async function getJobFilePath(uid, jobId, createIfNotExist = true) {
const path = `${await getUserJobPath(uid)}/${jobId}`;
if (createIfNotExist && !await asyncFs.asyncFileExisted(path)) {
await asyncFs.asyncWriteFile(path, '{}');
}
return path;
}
async function getJobListFilePath(uid, createIfNotExist = true) {
const path = `${await getUserJobPath(uid)}/list`;
if (createIfNotExist && !await asyncFs.asyncFileExisted(path)) {
await asyncFs.asyncWriteFile(path, '{}');
}
return path;
}
async function getUserJobPath(uid, createIfNotExist = true) {
const path = `${JobDataPath}/${uid}`;
if (createIfNotExist && !await asyncFs.asyncFileExisted(path)) {
await asyncFs.asyncMkdir(path, { recursive: true });
}
return path;
}
async function findActiveJobByArgs(uid, args) {
const jobs = await listJobs(uid);
return jobs.find(job => {
if (job['args'] === args && job['status'] !== JobStatus.Failed && job['status'] !== JobStatus.Finished) {
// 如果创建时间 早于 组件 init 时间,那么认为是无效的 job意味着服务重启了而目前 job 不支持重启服务后继续 run
if (job['createdAt'] < JobManagerInitTime) {
return false;
}
// 超过 1 小时也认为超时
if (Date.now() - job['createdAt'] > 1000 * 60 * 60) {
return false;
}
return job;
}
});
}
module.exports = {
listJobs: listJobs,
createJob: createJob,
findActiveJobByArgs: findActiveJobByArgs,
deleteJob: deleteJob,
getJob: getJob,
updateJob: updateJob,
}

View File

@@ -0,0 +1,88 @@
const { lock, unlock } = require('../../utils/simple_locker');
const asyncFs = require('../../utils/fs');
const logger = require('consola');
const DbPath = `${__dirname}/../../../.profile/data/kv-db`;
async function init() {
if (!await asyncFs.asyncFileExisted(DbPath)) {
await asyncFs.asyncMkdir(DbPath);
}
}
init();
async function set(table, key, value) {
if (!await lock(table, 5)) {
logger.error(`get table locker failed, table: ${table}, key: ${key}, value: ${value}`);
return false;
}
const filePath = `${DbPath}/${table}.json`;
let data = {};
if (await asyncFs.asyncFileExisted(filePath)) {
try {
data = JSON.parse(await asyncFs.asyncReadFile(filePath));
} catch (err) {
logger.error(`parse ${filePath} failed`, err);
return false;
}
}
data[key] = value;
try {
await asyncFs.asyncWriteFile(filePath, JSON.stringify(data));
} catch (err) {
logger.error(`write ${filePath} failed`, err);
return false;
}
unlock(table);
return true;
}
async function get(table, key) {
const filePath = `${DbPath}/${table}.json`;
let data = {};
if (await asyncFs.asyncFileExisted(filePath)) {
try {
data = JSON.parse(await asyncFs.asyncReadFile(filePath));
} catch (err) {
logger.error(`parse ${filePath} failed`, err);
return false;
}
}
return data[key];
}
module.exports = {
set,
get,
fileSyncMeta: {
set: async function (source, sourceID, value) {
const key = `${source}-${sourceID}`;
return await set('fileSyncMeta', key, JSON.stringify(value));
},
get: async function (source, sourceID) {
const key = `${source}-${sourceID}`;
const ret = await get('fileSyncMeta', key);
if (!ret) {
return false;
}
return JSON.parse(ret);
},
setPlaylistMeta: async function(playlistID, meta) {
const key = `playlist-${playlistID}`;
return await set('fileSyncMeta', key, JSON.stringify({
songIDs: meta.songIDs || [],
// 预留其他字段
}));
},
getPlaylistMeta: async function(playlistID) {
const key = `playlist-${playlistID}`;
const ret = await get('fileSyncMeta', key);
if (!ret) {
return {
songIDs: []
};
}
return JSON.parse(ret);
}
}
};

View File

@@ -0,0 +1,180 @@
const logger = require('consola');
const os = require('os');
const md5 = require('md5');
const path = require('path');
const cmd = require('../../utils/cmd');
const fs = require('fs');
const configManager = require('../config_manager')
const downloadFile = require('../../utils/download');
const { getBinPath } = require('./media_get');
const basePath = path.join(os.tmpdir(), 'melody-tmp-songs');
// create path if not exists
if (!fs.existsSync(basePath)) {
fs.mkdirSync(basePath);
}
logger.info(`[tmp path] use ${basePath}`)
async function downloadViaSourceUrl(url) {
logger.info(`downloadViaSourceUrl params: url: ${url}`);
const requestHash = md5(url);
const downloadPath = `${basePath}/${requestHash}.mp3`;
logger.info(`start download from ${url}`);
const isSucceed = await downloadFile(url, downloadPath);
if (!isSucceed) {
logger.error(`download failed with ${url}`);
return false;
}
if (!fs.existsSync(downloadPath)) {
logger.error(`download failed with ${url}, the file not exists ${downloadPath}`);
return false;
}
logger.info(`download success, path: ${downloadPath}`);
return downloadPath;
}
async function fetchWithUrl(url, {
songName = "",
addMediaTag = false,
}) {
logger.info(`fetchWithUrl params: ${JSON.stringify(arguments)}`);
if (songName) {
songName = songName.replace(/ /g, '').replace(/\./g, '').replace(/\//g, '').replace(/"/g, '');
}
const requestHash = md5(`${url}${songName}${addMediaTag}`);
const fileBasePath = `${basePath}/${requestHash}`;
try {
fs.mkdirSync(fileBasePath, { recursive: true });
} catch (err) {
logger.error('create dir failed', err);
return false;
}
addMediaTag = false; // todo: 等到 media-get fix 偶现的 添加 addMediaTag 后 panic 的问题,再移除这行代码
const downloadPath = `${fileBasePath}/${songName ? songName : requestHash}.mp3`;
logger.info(`start parse and download from ${url}`);
let args = ['-u', `"${url}"`, '--out', `${downloadPath}`, '-t', 'audio', `${addMediaTag ? '--addMediaTag' : ''}`];
logger.info(`${getBinPath()} ${args.join(' ')}`);
const {code, message} = await cmd(getBinPath(), args);
logger.info('-------')
logger.info(code);
logger.info(message);
logger.info('-------')
if (code != 0) {
return false;
}
if (!fs.existsSync(downloadPath)) {
return false;
}
return downloadPath;
}
async function getMetaWithUrl(url) {
logger.info(`getMetaWithUrl from ${url}`);
let args = ['-u', `"${url}"`, '-m', '--infoFormat=json', '-l=silence'];
const {code, message} = await cmd(getBinPath(), args);
logger.info('-------')
logger.info(code);
// logger.info(message);
logger.info('-------')
if (code != 0) {
logger.error(`getMetaWithUrl failed with ${url}, err: ${message}`);
return false;
}
let meta;
try {
meta = JSON.parse(message);
} catch (e) {
logger.error(e, message)
return false;
}
return {
songName: meta.title,
artist: meta.artist,
album: meta.album,
duration: meta.duration,
coverUrl: meta.cover_url,
publicTime: meta.public_time,
isTrial: meta.is_trial,
resourceType: meta.resource_type,
audios: meta.audios,
fromMusicPlatform: meta.from_music_platform,
resourceForbidden: meta.resource_forbidden,
source: meta.source
}
}
async function searchSongFromAllPlatform({
keyword,
songName, artist, album
}) {
logger.info(`searchSong with ${JSON.stringify(arguments)}`);
const globalConfig = await configManager.getGlobalConfig();
let searchParams = keyword
? ['-k', `"${keyword}"`]
: ['--searchSongName', `"${songName}"`, '--searchArtist', `"${artist}"`, '--searchAlbum', `"${album}"`];
searchParams = searchParams.concat([
'--searchType="song"',
'-m',
`--sources=${globalConfig.sources.join(',')}`,
'--infoFormat=json',
'-l', 'silence'
]);
logger.info(`cmdStr: ${getBinPath()} ${searchParams.join(' ')}`);
const {code, message} = await cmd(getBinPath(), searchParams);
logger.info('-------')
logger.info(code);
// logger.info(message);
logger.info('-------')
if (code != 0) {
logger.error(`searchSong failed with ${arguments}, err: ${message}`);
return false;
}
let jsonResponse;
try {
jsonResponse = JSON.parse(message);
} catch (e) {
logger.error(e, message)
return false;
}
return jsonResponse.map(searchItem => {
return {
songName: searchItem.Name,
artist: searchItem.Artist,
album: searchItem.Album,
duration: searchItem.Duration,
url: searchItem.Url,
resourceForbidden: searchItem.ResourceForbidden,
source: searchItem.Source,
fromMusicPlatform: searchItem.FromMusicPlatform,
score: searchItem.Score,
}
})
}
module.exports = {
downloadViaSourceUrl: downloadViaSourceUrl,
fetchWithUrl: fetchWithUrl,
getMetaWithUrl: getMetaWithUrl,
searchSongFromAllPlatform: searchSongFromAllPlatform,
}

View File

@@ -0,0 +1,228 @@
const logger = require('consola');
const https = require('https');
const cmd = require('../../utils/cmd');
var isWin = require('os').platform().indexOf('win32') > -1;
const isLinux = require('os').platform().indexOf('linux') > -1;
const isDarwin = require('os').platform().indexOf('darwin') > -1;
const httpsGet = require('../../utils/network').asyncHttpsGet;
const RemoteConfig = require('../remote_config');
const fs = require('fs');
function getBinPath(isTemp = false) {
return `${__dirname}/../../../bin/media-get` + (isTemp ? '-tmp-' : '') + (isWin ? '.exe' : '');
}
async function getMediaGetInfo(isTempBin = false) {
try {
const {code, message, error} = await cmd(getBinPath(isTempBin), ['-h']);
logger.info('Command execution result:', {
code,
error,
binPath: getBinPath(isTempBin)
});
if (code != 0) {
logger.error(`Failed to execute media-get:`, {
code,
error,
message
});
return false;
}
const hasInstallFFmpeg = message.indexOf('FFmpeg,FFprobe: installed') > -1;
const versionInfo = message.match(/Version:(.+?)\n/);
return {
hasInstallFFmpeg,
versionInfo: versionInfo ? versionInfo[1].trim() : '',
fullMessage: message,
}
} catch (err) {
logger.error('Exception while executing media-get:', err);
return false;
}
}
async function getLatestMediaGetVersion() {
const remoteConfig = await RemoteConfig.getRemoteConfig();
const latestVerisonUrl = `${remoteConfig.bestGithubProxy}https://raw.githubusercontent.com/foamzou/media-get/main/LATEST_VERSION`;
console.log('start to get latest version from: ' + latestVerisonUrl);
const latestVersion = await httpsGet(latestVerisonUrl);
console.log('latest version: ' + latestVersion);
if (latestVersion === null || (latestVersion || "").split('.').length !== 3) {
logger.error('获取 media-get 最新版本号失败, got: ' + latestVersion);
return false;
}
return latestVersion;
}
async function downloadFile(url, filename) {
return new Promise((resolve) => {
let fileStream = fs.createWriteStream(filename);
let receivedBytes = 0;
const handleResponse = (res) => {
// Handle redirects
if (res.statusCode === 301 || res.statusCode === 302) {
logger.info('Following redirect');
fileStream.end();
fileStream = fs.createWriteStream(filename);
if (res.headers.location) {
https.get(res.headers.location, handleResponse)
.on('error', handleError);
}
return;
}
// Check for successful status code
if (res.statusCode !== 200) {
handleError(new Error(`HTTP Error: ${res.statusCode}`));
return;
}
const totalBytes = parseInt(res.headers['content-length'], 10);
res.on('error', handleError);
fileStream.on('error', handleError);
res.pipe(fileStream);
res.on('data', (chunk) => {
receivedBytes += chunk.length;
});
fileStream.on('finish', () => {
fileStream.close(() => {
if (receivedBytes === 0) {
fs.unlink(filename, () => {
logger.error('Download failed: Empty file received');
resolve(false);
});
} else if (totalBytes && receivedBytes < totalBytes) {
fs.unlink(filename, () => {
logger.error(`Download incomplete: ${receivedBytes}/${totalBytes} bytes`);
resolve(false);
});
} else {
resolve(true);
}
});
});
};
const handleError = (error) => {
fileStream.destroy();
fs.unlink(filename, () => {
logger.error('Download error:', error);
resolve(false);
});
};
const req = https.get(url, handleResponse)
.on('error', handleError)
.setTimeout(60000, () => {
handleError(new Error('Download timeout'));
});
req.on('error', handleError);
});
}
async function getMediaGetRemoteFilename(latestVersion) {
let suffix = 'win.exe';
if (isLinux) {
suffix = 'linux';
}
if (isDarwin) {
suffix = 'darwin';
}
if (process.arch === 'arm64') {
suffix += '-arm64';
}
const remoteConfig = await RemoteConfig.getRemoteConfig();
return `${remoteConfig.bestGithubProxy}https://github.com/foamzou/media-get/releases/download/v${latestVersion}/media-get-${latestVersion}-${suffix}`;
}
const renameFile = (oldName, newName) => {
return new Promise((resolve, reject) => {
fs.rename(oldName, newName, (err) => {
if (err) {
logger.error(err)
resolve(false);
} else {
resolve(true);
}
});
});
};
async function downloadTheLatestMediaGet(version) {
const remoteFile = await getMediaGetRemoteFilename(version);
logger.info('start to download media-get: ' + remoteFile);
const ret = await downloadFile(remoteFile, getBinPath(true));
if (ret === false) {
logger.error('download failed');
return false;
}
fs.chmodSync(getBinPath(true), '755');
logger.info('download finished');
// Add debug logs for binary file and validate
try {
const stats = fs.statSync(getBinPath(true));
logger.info(`Binary file stats: size=${stats.size}, mode=${stats.mode.toString(8)}`);
// Check minimum file size (should be at least 2MB)
const minSize = 2 * 1024 * 1024; // 2MB
if (stats.size < minSize) {
logger.error(`Invalid binary file size: ${stats.size} bytes. Expected at least ${minSize} bytes`);
return false;
}
// Check file permissions (should be executable)
const executableMode = 0o755;
if ((stats.mode & 0o777) !== executableMode) {
logger.error(`Invalid binary file permissions: ${stats.mode.toString(8)}. Expected: ${executableMode.toString(8)}`);
return false;
}
// Skip validation when cross compiling
if (!process.env.CROSS_COMPILING) {
const temBinInfo = await getMediaGetInfo(true);
logger.info('Execution result:', {
binPath: getBinPath(true),
arch: process.arch,
platform: process.platform,
temBinInfo
});
if (!temBinInfo || temBinInfo.versionInfo === "") {
logger.error('testing new bin failed. Details:', {
binExists: fs.existsSync(getBinPath(true)),
binPath: getBinPath(true),
error: temBinInfo === false ? 'Execution failed' : 'No version info'
});
return false;
}
}
const renameRet = await renameFile(getBinPath(true), getBinPath());
if (!renameRet) {
logger.error('rename failed');
return false;
}
return true;
} catch (err) {
logger.error('Failed to get binary stats:', err);
return false;
}
}
module.exports = {
getBinPath: getBinPath,
getMediaGetInfo: getMediaGetInfo,
getLatestMediaGetVersion: getLatestMediaGetVersion,
downloadTheLatestMediaGet: downloadTheLatestMediaGet,
}

View File

@@ -0,0 +1,52 @@
const logger = require('consola');
const { asyncHttpsGet } = require('../../utils/network');
const BaseUrl = 'https://music-dl.sayqz.com/api/';
function buildApiUrl(params = {}) {
const searchParams = new URLSearchParams(params);
return `${BaseUrl}?${searchParams.toString()}`;
}
async function fetchJson(params = {}) {
const url = buildApiUrl(params);
const raw = await asyncHttpsGet(url);
if (!raw) {
return null;
}
try {
return JSON.parse(raw);
} catch (err) {
logger.error(`TuneHub 返回非 JSON: ${url}`);
return null;
}
}
async function getPlaylistDetail(source, playlistId) {
const response = await fetchJson({
source,
id: playlistId,
type: 'playlist',
});
if (!response || response.code !== 200 || !response.data) {
return false;
}
return response.data;
}
function buildSongUrl(source, songId, br) {
const params = {
source,
id: songId,
type: 'url',
};
if (br) {
params.br = br;
}
return buildApiUrl(params);
}
module.exports = {
getPlaylistDetail,
buildSongUrl,
};

View File

@@ -0,0 +1,312 @@
const logger = require('consola');
const {
cloud, cloudsearch, cloud_match, song_detail,
user_playlist, playlist_detail, user_account, playlist_track_all,
login_qr_check, login_qr_create, login_qr_key,
} = require('NeteaseCloudMusicApi');
const fs = require('fs');
const path = require('path');
const {requestApi} = require('./transport');
const { buildSongUrl } = require('../tunehub');
async function uploadSong(uid, filePath) {
const response = await safeRequest(uid, cloud, {
songFile: {
name: path.basename(filePath),
data: fs.readFileSync(filePath),
},
});
if (response === false) {
return false;
}
logger.debug('uploadSong\'s resonse: ', response)
if (!response.privateCloud) {
return false;
}
const songInfo = response.privateCloud.simpleSong;
return {
songId: songInfo.id,
matched: songInfo.ar[0].id !== 0 && songInfo.al.id !== 0, // It's matched the song on wyMusic if singer and album has info
};
}
async function searchSong(uid, songName, artist) {
const response = await safeRequest(uid, cloudsearch, {
keywords: `${songName} ${artist}`,
type: 1,
});
if (response === false) {
return false;
}
if (!response.result || response.result.songs.length === 0) {
return false;
}
return response.result.songs.map(song => {
let artists = [];
if (song.ar.length !== 0) {
song.ar.map(artist => {
artists.push(artist.name);
artist.alias && artists.push(...artist.alias);
artist.alia && artists.push(...artist.alia);
});
}
return {
songId: song.id,
songName: song.name,
album: song.al.name,
artists: artists.filter(a => a !== '' && a !== undefined),
};
})
}
async function matchAndFixCloudSong(uid, cloudSongId, wySongId) {
const response = await safeRequest(uid, cloud_match, {
sid: cloudSongId,
asid: wySongId,
});
if (response === false) {
return false;
}
if (response.code > 399) {
logger.warn(response);
return false;
}
return true;
}
async function getMyAccount(uid) {
const response = await safeRequest(uid, user_account, {
uid,
});
if (response === false) {
return false;
}
if (!response.profile) {
return false;
}
return {
userId: response.profile.userId,
nickname: response.profile.nickname,
avatarUrl: response.profile.avatarUrl,
};
}
async function getSongInfo(uid, id) {
const response = await safeRequest(uid, song_detail, {
ids: `"${id}"`,
});
if (response === false) {
return false;
}
if (!response.songs || response.songs.length === 0) {
return false;
}
const songInfo = response['songs'][0];
return {
songId: songInfo.id,
songName: songInfo.name,
artists: songInfo.ar.map(artist => artist.name),
duration: songInfo.dt / 1000,
album: songInfo.al.name,
cover: songInfo.al.picUrl,
};
}
async function getPlayUrl(uid, id, isLossless = false) {
const br = isLossless ? 'flac' : '320k';
return buildSongUrl('netease', id, br);
}
async function getUserAllPlaylist(uid) {
const wyAccount = await getMyAccount(uid);
if (wyAccount === false) {
logger.error(`uid(${uid}) get user's wycloud account failed.`);
return false;
}
const response = await safeRequest(uid, user_playlist, {
uid: wyAccount.userId,
});
if (response === false) {
return false;
}
if (!response.playlist || response.playlist.length === 0) {
return false;
}
return response.playlist.map(playlist => {
return {
id: playlist.id,
name: playlist.name,
cover: playlist.coverImgUrl,
trackCount: playlist.trackCount,
isCreatedByMe: playlist.creator.userId === wyAccount.userId,
};
});
}
async function getSongsFromPlaylist(uid, source, playlistId) {
const [detailResponse, songsResponse] = await Promise.all([
safeRequest(uid, playlist_detail, {
id: playlistId,
}),
safeRequest(uid, playlist_track_all, {
id: playlistId,
offset: 0,
limit: 1000,
}),
]);
if (detailResponse === false || songsResponse === false) {
return false;
}
if (!detailResponse.playlist || !songsResponse.songs || songsResponse.songs.length === 0) {
logger.error(`uid(${uid}) playlist(${playlistId}) has no songs.`, detailResponse, songsResponse);
return false;
}
// console.log(JSON.stringify(songsResponse, null, 4));
// ddd
if (songsResponse.songs.length >= 1000) {
const songsPage2Response = await safeRequest(uid, playlist_track_all, {
id: playlistId,
offset: 1000,
limit: 1000,
});
if (songsPage2Response !== false && songsPage2Response.songs) {
songsResponse.songs = songsResponse.songs.concat(songsPage2Response.songs);
songsResponse.privileges = songsResponse.privileges.concat(songsPage2Response.privileges);
}
}
let info = {
id: playlistId,
name: detailResponse.playlist.name,
cover: detailResponse.playlist.coverImgUrl,
songs: [],
};
const songsMap = {};
songsResponse.songs.map(song => {
songsMap[song.id] = song;
});
const isBlockedSong = (song, songInfo) => {
// the song has been added to cloud if the pc field is present
if (songInfo.pc) {
return false;
}
// 收费歌曲
if (song.fee === 1) {
if (song.realPayed === 1 || song.payed === 1) {
return false;
}
return true;
}
// subp 或 cp === 1 可能都表示有版权
// 免费歌曲
if (song.subp === 1) {
return false;
}
return true;
};
songsResponse.privileges.forEach(song => {
const songInfo = songsMap[song.id];
if (!songInfo) {
return;
}
const isBlocked = isBlockedSong(song, songInfo);
const isCloud = !!songInfo.pc;
info.songs.push({
songId: songInfo.id,
songName: songInfo.name,
artists: songInfo.ar.map(artist => artist.name),
artist: songInfo.ar.length > 0 ? songInfo.ar[0].name : '',
duration: songInfo.dt / 1000,
album: songInfo.al.name,
cover: songInfo.al.picUrl,
pageUrl: `https://music.163.com/song?id=${songInfo.id}`,
playUrl: !isBlocked && !isCloud ? `http://music.163.com/song/media/outer/url?id=${songInfo.id}.mp3` : '', // 不再建议使用这个 url建议每次都 Call API 获取
isBlocked,
isCloud,
});
});
return info;
}
async function getBlockedSongsFromPlaylist(uid, source, playlistId) {
const info = await getSongsFromPlaylist(uid, source, playlistId);
if (info === false) {
return false;
}
info.blockedSongs = info.songs.filter(song => song.isBlocked);
return info;
}
async function qrLoginCreate(uid) {
const keyResponse = await safeRequest(uid, login_qr_key, {}, false);
if (keyResponse === false || !keyResponse.data.unikey) {
logger.warn(`uid(${uid}) get qr login key failed.`);
return false;
}
const qrKey = keyResponse.data.unikey;
const qrCodeResponse = await safeRequest(uid, login_qr_create, {key: qrKey, qrimg: true}, false);
if (qrCodeResponse === false || !qrCodeResponse.data.qrimg) {
return false;
}
return {
qrKey,
qrCode: qrCodeResponse.data.qrimg,
};
}
async function qrLoginCheck(uid, qrKey) {
const response = await safeRequest(uid, login_qr_check, {key: qrKey, cookie: {
os: 'pc',
}}, false);
if (response === false) {
return false;
}
return {
code: response.code,
cookie: response.cookie,
};
}
async function verifyAccountStatus(uid) {
const account = await getMyAccount(uid);
return account !== false;
}
async function safeRequest(uid, moduleFunc, params, cookieRequired = true) {
try {
const response = await requestApi(uid, moduleFunc, params, cookieRequired);
if (response == false) {
logger.error(`request failed.`, response);
return false;
}
return response;
} catch (error) {
logger.error(`uid(${uid}) request failed.`, error);
return false;
}
}
module.exports = {
getMyAccount: getMyAccount,
uploadSong: uploadSong,
matchAndFixCloudSong: matchAndFixCloudSong,
searchSong: searchSong,
getBlockedSongsFromPlaylist: getBlockedSongsFromPlaylist,
getSongsFromPlaylist: getSongsFromPlaylist,
getUserAllPlaylist: getUserAllPlaylist,
getSongInfo: getSongInfo,
getPlayUrl: getPlayUrl,
qrLoginCreate: qrLoginCreate,
qrLoginCheck: qrLoginCheck,
verifyAccountStatus: verifyAccountStatus,
}

View File

@@ -0,0 +1,151 @@
const logger = require('consola');
const AccountService = require('../../account');
const { login_cellphone, login_refresh, login } = require('NeteaseCloudMusicApi');
const CookiePath = `${__dirname}/../../../../.profile/cookie/`;
const fs = require('fs');
const LoginTypePhone = 'phone';
const LoginTypeEmail = 'email';
const CookieMap = {};
async function requestApi(uid, moduleFunc, request = {}, cookieRequired = true) {
if (cookieRequired) {
let cookie = await getCookie(uid);
if (!cookie) {
logger.error(`uid(${uid}) get cookie failed`);
return false;
}
request.cookie = cookie;
}
let response = await requestWyyApi(moduleFunc, request);
// need refresh
if (response && response.status == 301) {
cookie = await getCookie(uid, true);
if (!cookie) {
logger.error(`uid(${uid}) refresh cookie failed. request api abort`);
return false;
}
// retry request
request.cookie = cookie;
response = await requestWyyApi(moduleFunc, request);
}
if (response && response.status == 200) {
return response.body;
}
logger.error(`requestWyyApi respond non 200, response: `, response);
return false;
}
async function requestWyyApi(moduleFunc, request) {
return moduleFunc(request).then(response => {
return response;
}).catch(err => {
console.log(err)
logger.error(`requestWyyApi failed: `, err);
if (typeof err == 'object' && err.status == '301') {
return err;
}
return false;
});
}
async function getCookie(uid, refresh = false) {
const account = AccountService.getAccount(uid);
if (!account) {
return false;
}
// fetch from cache
const cookieFromCache = fetchCookieFromCache(uid, account);
if (cookieFromCache) {
if (!refresh) {
return cookieFromCache;
}
logger.info('refresh cookie...', cookieFromCache);
const response = await requestWyyApi(login_refresh, {cookie: cookieFromCache});
if (response && response.status == 200 && response.cookie && response.cookie.length > 1) {
const cookie = response.cookie.map(line => line.replace('HTTPOnly', '')).join(';');
logger.info('refresh cookie succeed, ', cookie);
storeCookie(uid, account, cookie);
return cookie;
}
logger.info(`refresh failed, try login again`);
}
// login
logger.info(`uid(${uid}) login with ${account.countryCode} ${account.account} via ${account.loginType}`);
let result;
if (account.loginType === LoginTypePhone) {
result = await requestWyyApi(login_cellphone, {
countrycode: account.countrycode,
phone: account.account,
password: account.password,
});
} else if (account.loginType === LoginTypeEmail) {
result = await requestWyyApi(login, {
email: account.account,
password: account.password,
});
} else {
if (account.loginType === 'qrcode') {
logger.error(`uid(${uid})'s loginType(${account.loginType}) does not support auto login, please login in the browser page first`);
} else {
logger.error(`uid(${uid})'s loginType(${account.loginType}) does not support now`);
}
return false;
}
if (result && result.status == 200 && result.body && result.body.code == 200 && result.body.cookie) {
logger.info(`uid(${uid}) login succeed`)
storeCookie(uid, account, result.body.cookie);
return result.body.cookie;
}
logger.error(`fetch cookie from response failed, uid(${uid}) login failed`, result);
return false;
}
function storeCookie(uid, account, cookie) {
fs.writeFileSync(getCookieFilePath(uid, account), cookie);
CookieMap[getCookieMapKey(uid, account)] = cookie;
}
function fetchCookieFromCache(uid, account) {
const cacheKey = getCookieMapKey(uid, account);
if (CookieMap[cacheKey]) {
return CookieMap[cacheKey];
}
const CookieFile = getCookieFilePath(uid, account);
if (!fs.existsSync(CookieFile)) {
logger.info(`uid(${uid})'s cookie not found from .profile`);
return null;
}
const cookie = fs.readFileSync(CookieFile).toString();
CookieMap[cacheKey] = cookie;
return cookie;
}
function getCookieMapKey(uid, account) {
return `${uid}-${account.platform}-${account.account}`;
}
function getCookieFilePath(uid, account) {
return `${CookiePath}${uid}-${account.platform}-${account.account}`;
}
module.exports = {
requestApi,
storeCookie,
}

View File

@@ -0,0 +1,69 @@
const httpsGet = require('../../utils/network').asyncHttpsGet;
const logger = require('consola');
const configManager = require('../config_manager');
// Store best proxy in memory for performance
let cachedBestProxy = '';
async function validateGithubAccess(proxy = '') {
try {
const testUrl = proxy ? `${proxy}https://api.github.com/zen` : 'https://api.github.com/zen';
const response = await httpsGet(testUrl);
return response !== null;
} catch (err) {
return false;
}
}
async function findBestProxy(proxyList) {
// Always try direct access first
if (await validateGithubAccess()) {
cachedBestProxy = '';
return '';
}
// Try cached proxy if available
if (cachedBestProxy && await validateGithubAccess(cachedBestProxy)) {
return cachedBestProxy;
}
// Test each proxy in the list
for (const proxy of proxyList) {
if (proxy && await validateGithubAccess(proxy)) {
cachedBestProxy = proxy;
return proxy;
}
}
logger.warn('No working GitHub access found, either direct or via proxy');
return ''; // Return empty string if no working access found
}
async function getRemoteConfig() {
const fallbackConfig = {
githubProxy: ['', 'https://ghp.ci/'],
}
const remoteConfigUrl = 'https://foamzou.com/tools/melody-config.php?v=2';
const remoteConfig = await httpsGet(remoteConfigUrl);
let config = {};
if (remoteConfig === null) {
config = fallbackConfig;
} else {
config = JSON.parse(remoteConfig);
}
let bestGithubProxy = await findBestProxy(config.githubProxy);
if (bestGithubProxy !== '' && !bestGithubProxy.endsWith('/')) {
bestGithubProxy = bestGithubProxy + '/';
}
return {
bestGithubProxy,
}
}
module.exports = {
getRemoteConfig,
}

View File

@@ -0,0 +1,221 @@
const schedule = require('node-schedule');
const logger = require('consola');
const configManager = require('../config_manager');
const AccountService = require('../account');
const syncPlaylist = require('../sync_music/sync_playlist');
const unblockMusicInPlaylist = require('../sync_music/unblock_music_in_playlist');
const { consts: sourceConsts } = require('../../consts/source');
const { getUserAllPlaylist, verifyAccountStatus } = require('../music_platform/wycloud');
class SchedulerService {
constructor() {
this.jobs = new Map();
}
async start() {
await this.scheduleLocalSyncJobs();
await this.scheduleCloudSyncJobs();
// Log initial schedule info
const localNextRun = this.getLocalSyncNextRun();
if (localNextRun) {
logger.info(`Next local sync scheduled at: ${localNextRun.nextRunTime}, in ${Math.round(localNextRun.remainingMs / 1000 / 60)} minutes`);
}
const accounts = await AccountService.getAllAccounts();
for (const uid in accounts) {
const cloudNextRun = this.getCloudSyncNextRun(uid);
if (cloudNextRun) {
logger.info(`Next cloud sync for account ${uid} scheduled at: ${cloudNextRun.nextRunTime}, in ${Math.round(cloudNextRun.remainingMs / 1000 / 60)} minutes`);
}
}
logger.info('Scheduler service started');
}
async scheduleLocalSyncJobs() {
// 系统级别的本地同步任务
const config = await configManager.getGlobalConfig();
const syncAccounts = config.playlistSyncToLocal.syncAccounts || [];
if (!config.playlistSyncToLocal.autoSync.enable || syncAccounts.length === 0) {
return;
}
const frequency = config.playlistSyncToLocal.autoSync.frequency;
const unit = config.playlistSyncToLocal.autoSync.frequencyUnit;
const rule = this.buildScheduleRule(frequency, unit);
const jobKey = 'localSync';
const job = schedule.scheduleJob(rule, async () => {
logger.info('Start auto sync playlist to local');
for (const uid of syncAccounts) {
const isActive = await verifyAccountStatus(uid);
if (!isActive) {
logger.warn(`Account ${uid} is not active, skip local sync`);
continue;
}
const playlists = await getUserAllPlaylist(uid);
for (const playlist of playlists) {
logger.info(`Start sync playlist ${playlist.id} to local for account ${uid}`);
await syncPlaylist(uid, sourceConsts.Netease.code, playlist.id);
}
}
});
this.jobs.set(jobKey, job);
logger.info(`Schedule local sync job success, rule: ${this.formatScheduleRule(rule)}`);
}
async scheduleCloudSyncJobs() {
// 账号级别的云盘同步任务
const accounts = await AccountService.getAllAccounts();
for (const uid in accounts) {
const account = accounts[uid];
await this.scheduleCloudSyncJob(uid, account);
}
}
async scheduleCloudSyncJob(uid, account) {
if (!account.config?.playlistSyncToWyCloudDisk?.autoSync?.enable) {
return;
}
const isActive = await verifyAccountStatus(uid);
if (!isActive) {
logger.warn(`Account ${uid} is not active, skip cloud sync`);
return;
}
const frequency = account.config.playlistSyncToWyCloudDisk.autoSync.frequency;
const unit = account.config.playlistSyncToWyCloudDisk.autoSync.frequencyUnit;
const jobKey = `cloudSync_${uid}`;
const rule = this.buildScheduleRule(frequency, unit);
this.jobs.set(jobKey, schedule.scheduleJob(rule, async () => {
logger.info(`Start cloud sync for account ${uid}`);
const playlists = await getUserAllPlaylist(uid);
// Filter playlists based on user preference
const playlistsToSync = account.config.playlistSyncToWyCloudDisk.autoSync.onlyCreatedPlaylists
? playlists.filter(p => p.isCreatedByMe)
: playlists;
for (const playlist of playlistsToSync) {
logger.info(`Start sync playlist ${playlist.id} to cloud for account ${uid}`);
await unblockMusicInPlaylist(uid, sourceConsts.Netease.code, playlist.id, {
syncWySong: account.config.playlistSyncToWyCloudDisk.syncWySong,
syncNotWySong: account.config.playlistSyncToWyCloudDisk.syncNotWySong
});
}
}));
logger.info(`Schedule cloud sync job for account ${uid} success, rule: ${this.formatScheduleRule(rule)}`);
}
buildScheduleRule(frequency, unit) {
if (unit === 'minute') {
return `0 */${frequency} * * * *`;
} else if (unit === 'hour') {
return `0 0 */${frequency} * * *`;
} else {
return `0 0 0 */${frequency} * *`;
}
}
formatScheduleRule(rule) {
if (typeof rule === 'string') {
return rule;
}
return `每天 ${rule.hour.map(h => h.toString().padStart(2, '0') + ':00').join(', ')} 执行`;
}
async updateLocalSyncJob() {
logger.info('Update local sync job');
// 添加调试日志
logger.info('Before update:');
logger.info(`- Jobs map size: ${this.jobs.size}`);
logger.info(`- Jobs keys: ${Array.from(this.jobs.keys()).join(', ')}`);
const localJob = this.jobs.get('localSync');
if (localJob) {
localJob.cancel();
this.jobs.delete('localSync');
}
// 添加调试日志
logger.info('After cancel:');
logger.info(`- Jobs map size: ${this.jobs.size}`);
logger.info(`- Jobs keys: ${Array.from(this.jobs.keys()).join(', ')}`);
await this.scheduleLocalSyncJobs();
// 添加调试日志
logger.info('After reschedule:');
logger.info(`- Jobs map size: ${this.jobs.size}`);
logger.info(`- Jobs keys: ${Array.from(this.jobs.keys()).join(', ')}`);
const newJob = this.jobs.get('localSync');
logger.info(`- New job created: ${!!newJob}`);
if (newJob) {
logger.info(`- New job next run: ${newJob.nextInvocation()}`);
}
logger.info('Update local sync job success');
}
async updateCloudSyncJob(uid) {
logger.info(`Update cloud sync job for account ${uid}`);
// 取消指定账号的云盘同步任务
const cloudJobKey = `cloudSync_${uid}`;
const cloudJob = this.jobs.get(cloudJobKey);
if (cloudJob) {
cloudJob.cancel();
this.jobs.delete(cloudJobKey);
logger.info(`Cancel cloud sync job for account ${uid}`);
}
// 重新调度指定账号的云盘同步任务
const account = (await AccountService.getAllAccounts())[uid];
if (account) {
await this.scheduleCloudSyncJob(uid, account);
}
logger.info(`Update cloud sync job for account ${uid} success`);
}
getNextRunInfo(job) {
if (!job) return null;
const nextRun = job.nextInvocation();
if (!nextRun) return null;
const now = new Date();
const remainingMs = nextRun.getTime() - now.getTime();
return {
nextRunTime: nextRun,
remainingMs: remainingMs
};
}
getLocalSyncNextRun() {
const job = this.jobs.get('localSync');
// 添加调试日志
logger.info('Debug getLocalSyncNextRun:');
logger.info(`- Has job: ${!!job}`);
logger.info(`- Jobs map size: ${this.jobs.size}`);
logger.info(`- Jobs keys: ${Array.from(this.jobs.keys()).join(', ')}`);
if (job) {
logger.info(`- Job next invocation: ${job.nextInvocation()}`);
logger.info(`- Job scheduling info:`, job.scheduledJobs);
}
return this.getNextRunInfo(job);
}
getCloudSyncNextRun(uid) {
const job = this.jobs.get(`cloudSync_${uid}`);
return this.getNextRunInfo(job);
}
}
const schedulerService = new SchedulerService();
module.exports = schedulerService;

View File

@@ -0,0 +1,57 @@
const { searchSong, getSongInfo } = require('../music_platform/wycloud');
const logger = require('consola');
module.exports = async function findTheBestMatchFromWyCloud(uid, {songName, artist, album, musicPlatformSongId} = {}) {
if (musicPlatformSongId) {
const songInfo = await getSongInfo(uid, musicPlatformSongId);
if (songInfo) {
return songInfo;
}
if (songName && artist) {
return {
songId: musicPlatformSongId,
songName,
artists: [artist],
album,
};
}
return null;
}
if (songName === "" || artist === "") {
return null;
}
const searchLists = await searchSong(uid, songName, artist);
logger.info('searchLists', searchLists);
if (searchLists === false) {
logger.warn(`search song failed, no matter, go on`);
return null;
}
let matchSongAndArtist = null;
for (const searchItem of searchLists) {
let hitArtist = false;
for (const searchArtist of searchItem.artists) {
if (artist === searchArtist) {
hitArtist = true;
}
}
if (!hitArtist) {
continue;
}
if (searchItem.songName === songName) {
if (searchItem.album === album) {
logger.info('matched the best')
return searchItem;
}
if (!matchSongAndArtist) {
matchSongAndArtist = searchItem;
}
}
}
return matchSongAndArtist;
}

View File

@@ -0,0 +1,19 @@
const { searchSongFromAllPlatform } = require('../media_fetcher');
const searchSongsWithSongMeta = require('./search_songs_with_song_meta');
const findTheBestMatchFromWyCloud = require('./find_the_best_match_from_wycloud');
async function searchSongsWithKeyword(keyword) {
const searchList = await searchSongFromAllPlatform({keyword});
if (searchList === false || searchList.length === 0) {
return [];
}
return searchList;
}
module.exports = {
searchSongsWithSongMeta: searchSongsWithSongMeta,
searchSongsWithKeyword: searchSongsWithKeyword,
findTheBestMatchFromWyCloud: findTheBestMatchFromWyCloud,
}

View File

@@ -0,0 +1,131 @@
const logger = require('consola');
const { searchSongFromAllPlatform } = require('../media_fetcher');
module.exports = async function searchSongsWithSongMeta(songMeta = {
songName: '',
artist: '',
album: '',
duration: 0,
}, options = {
expectArtistAkas: [], // 歌手名字,有的歌手有很多别名的,给出这些信息能够更好地排序
allowSongsJustMatchDuration: false, // 关键信息不对的情况下,但 duration 很接近的歌曲,是否希望返回
allowSongsNotMatchMeta: false, // 关键的 meta 信息不匹配的歌曲,是否希望返回
}) {
// search song with the meta
const searchList = await searchSongFromAllPlatform({
songName:songMeta.songName,
artist: songMeta.artist,
album: songMeta.album
});
if (searchList === false || searchList.length === 0) {
logger.error(`search song failed, songMeta: ${JSON.stringify(songMeta)}`);
return false;
}
return sortOutTheSearchList(searchList, options.expectArtistAkas, {
songName: songMeta.songName,
duration: songMeta.duration,
}, {
allowSongsJustMatchDuration: options.allowSongsJustMatchDuration,
allowSongsNotMatchMeta: options.allowSongsNotMatchMeta,
});
}
function sortOutTheSearchList(searchList, expectArtistAkas, songMeta = {
songName: '',
duration: 0,
}, options = {
allowSongsJustMatchDuration: false,
allowSongsNotMatchMeta: false,
}) {
let searchListfilttered = [];
let searchListfiltterJustWithDuration = [];
let searchListNotMatchMeta = [];
// filter with song name, artist first
for (const searchItem of searchList) {
if (searchItem.cannotDownload) {
logger.info(`song cannot download, continue. searchItem: ${JSON.stringify(searchItem)}`);
searchListNotMatchMeta.push(searchItem);
continue;
}
const hasDuration = songMeta.duration > 0 && searchItem.duration > 0;
const durationDiff = hasDuration ? Math.abs(searchItem.duration - songMeta.duration) : 0;
searchItem.durationDiff = durationDiff;
if (hasDuration && durationDiff > 10) {
searchListNotMatchMeta.push(searchItem);
continue;
}
if (thereAreWordNotExistFromInputButInSearchResult(['cover', '伴奏', '翻唱', 'instrumental'], searchItem.songName, songMeta.songName)) {
logger.info(`there are word not exist from input but in search result, continue. searchItem.songName: ${searchItem.songName}, songMeta.songName: ${songMeta.songName}`);
searchListNotMatchMeta.push(searchItem);
continue;
}
if (hasDuration && durationDiff <= 5) {
searchListfiltterJustWithDuration.push(searchItem);
searchListNotMatchMeta.push(searchItem);
}
if (searchItem.songName.replace(' ', '').indexOf(songMeta.songName.replace(' ', '')) === -1) {
logger.info(`songName not matched, continue. ${searchItem.songName} vs ${songMeta.songName}`);
searchListNotMatchMeta.push(searchItem);
continue;
}
if (searchItem.fromMusicPlatform && expectArtistAkas.length > 0) {
logger.info(`should find the artist:${searchItem.artist} from ${expectArtistAkas.join(',')}`);
if (!expectArtistAkas.find(artist => artist === searchItem.artist)) {
logger.info(`artist not matched, continue.`);
searchListNotMatchMeta.push(searchItem);
continue;
}
}
searchListfilttered.push(searchItem);
}
if (options.allowSongsJustMatchDuration) {
searchListfiltterJustWithDuration = searchListfiltterJustWithDuration.sort((a, b) => a.durationDiff - b.durationDiff);
searchListfilttered.push(...searchListfiltterJustWithDuration);
}
if (options.allowSongsNotMatchMeta) {
searchListfilttered.push(...searchListNotMatchMeta);
}
// uniq with song url
const uniqedSearchList = [];
for (const searchItem of searchListfilttered) {
if (uniqedSearchList.find(item => item.url === searchItem.url)) {
continue;
}
uniqedSearchList.push(searchItem);
}
// stable sort。 resourceForbidden 排在后面
return uniqedSearchList.map((data, i) => {
return {i, data}
}).sort((a,b)=>{
if (a.data.resourceForbidden == b.data.resourceForbidden) {
return a.i-b.i;
} if (a.data.resourceForbidden) {
return 1;
}
return -1
}).map(d=> d.data)
}
function thereAreWordNotExistFromInputButInSearchResult(words, searchResultWord, inputWord) {
searchResultWord = searchResultWord.toLowerCase();
inputWord = inputWord.toLowerCase();
for (const word of words) {
if (searchResultWord.indexOf(word) !== -1 && inputWord.indexOf(word) === -1) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,14 @@
const { getPlayUrl } = require('../music_platform/wycloud');
async function getPlayUrlWithOptions(uid, source, songId) {
// Only support netease now
if (source !== 'netease') {
return '';
}
return await getPlayUrl(uid, songId);
}
module.exports = {
getPlayUrlWithOptions: getPlayUrlWithOptions,
}

View File

@@ -0,0 +1,64 @@
const { fetchWithUrl, getMetaWithUrl } = require('../media_fetcher');
const { uploadSong, searchSong, matchAndFixCloudSong } = require('../music_platform/wycloud');
const logger = require('consola');
const sleep = require('../../utils/sleep');
const configManager = require('../config_manager');
const fs = require('fs');
const libPath = require('path');
const utilFs = require('../../utils/fs');
module.exports = {
downloadFromLocalTmpPath: downloadFromLocalTmpPath,
buildDestFilename: buildDestFilename,
}
async function downloadFromLocalTmpPath(tmpPath, songInfo = {
songName: "",
artist: "",
album: "",
}, playlistName = '', collectResponse) {
const globalConfig = (await configManager.getGlobalConfig());
const downloadPath = globalConfig.downloadPath;
if (!downloadPath) {
logger.error(`download path not set`);
return "IOFailed";
}
const destPathAndFilename = buildDestFilename(globalConfig, songInfo, playlistName);
const destPath = libPath.dirname(destPathAndFilename);
// make sure the path is exist
await utilFs.asyncMkdir(destPath, {recursive: true});
try {
if (await utilFs.asyncFileExisted(destPathAndFilename)) {
logger.info(`file already exists, remove it: ${destPathAndFilename}`);
await utilFs.asyncUnlinkFile(destPathAndFilename)
}
await utilFs.asyncMoveFile(tmpPath, destPathAndFilename);
} catch (err) {
logger.error(`move file failed, ${tmpPath} -> ${destPathAndFilename}`, err);
return "IOFailed";
}
if (collectResponse !== undefined) {
try {
const md5Value = await utilFs.asyncMd5(destPathAndFilename);
collectResponse['md5Value'] = md5Value;
} catch (err) {
logger.error(`md5 failed, ${destPathAndFilename}`, err);
// don't return false, just log it
}
}
logger.info(`download song success, path: ${destPathAndFilename}`);
return true;
}
function buildDestFilename(globalConfig, songInfo, playlistName) {
const downloadPath = globalConfig.downloadPath;
let filename = (playlistName ? globalConfig.playlistSyncToLocal?.filenameFormat : globalConfig.filenameFormat)
.replace(/{artist}/g, songInfo.artist ? songInfo.artist : 'Unknown')
.replace(/{songName}/g, songInfo.songName ? songInfo.songName : 'Unknown')
.replace(/{playlistName}/g, playlistName ? playlistName : 'UnknownPlayList')
.replace(/{album}/g, songInfo.album ? songInfo.album : 'Unknown');
// remove the head / and \ in filename
filename = filename.replace(/^[\/\\]+/, '') + '.mp3';
return `${downloadPath}${libPath.sep}${filename}`
}

View File

@@ -0,0 +1,11 @@
const syncSingleSongWithUrl = require('./sync_single_song_with_url');
const unblockMusicInPlaylist = require('./unblock_music_in_playlist');
const unblockMusicWithSongId = require('./unblock_music_with_song_id');
const syncPlaylist = require('./sync_playlist');
module.exports = {
syncSingleSongWithUrl: syncSingleSongWithUrl,
unblockMusicInPlaylist: unblockMusicInPlaylist,
unblockMusicWithSongId: unblockMusicWithSongId,
syncPlaylist: syncPlaylist,
};

View File

@@ -0,0 +1,382 @@
const {
getSongsFromPlaylist,
getPlayUrl,
} = require("../music_platform/wycloud");
const syncSingleSongWithUrl = require("./sync_single_song_with_url");
const logger = require("consola");
const {
findTheBestMatchFromWyCloud,
searchSongsWithSongMeta,
} = require("../search_songs");
const JobManager = require("../job_manager");
const JobType = require("../../consts/job_type");
const JobStatus = require("../../consts/job_status");
const BusinessCode = require("../../consts/business_code");
const { downloadViaSourceUrl } = require("../media_fetcher/index");
const {
downloadFromLocalTmpPath,
buildDestFilename,
} = require("./download_to_local");
const KV = require("../kv");
const utilFs = require("../../utils/fs");
const configManager = require("../config_manager");
const path = require("path");
const { consts } = require("../../consts/source");
const soundQualityConst = require("../../consts/sound_quality");
module.exports = async function syncPlaylist(uid, source, playlistId) {
// step 1. get all the songs
const playlistInfo = await getSongsFromPlaylist(uid, source, playlistId);
if (playlistInfo === false) {
return false;
}
// calc the songs need to be synced
const songsNeedToSync = [];
const cacheForCalcSongsNeedToSync = {};
// 如果开启了删除功能,先执行删除
const globalConfig = await configManager.getGlobalConfig();
if (globalConfig.playlistSyncToLocal.deleteLocalFile) {
const currentSongIDs = playlistInfo.songs.map(song => song.songId);
await syncDeleteFiles(playlistId, currentSongIDs);
}
for (const song of playlistInfo.songs) {
const needToSync = await isNeedToSyncFile(playlistId, song.songId, cacheForCalcSongsNeedToSync);
if (!needToSync) {
continue;
}
songsNeedToSync.push(song);
}
if (songsNeedToSync.length === 0) {
logger.info(`[No need] all the songs in the playlist are already downloaded.`);
return BusinessCode.StatusJobNoNeedToCreate;
}
// create job
const args = `syncPlaylist: {"source":${source},"playlistId":${playlistId}}`;
if (await JobManager.findActiveJobByArgs(uid, args)) {
logger.info(`syncPlaylist job is already running.`);
return BusinessCode.StatusJobAlreadyExisted;
}
const jobId = await JobManager.createJob(uid, {
name: `下载歌单到本地服务器:${playlistInfo.name}`,
args,
type: JobType.SyncThePlaylistToLocalService,
status: JobStatus.Pending,
desc: `${songsNeedToSync.length}首歌曲需要下载`,
progress: 0,
tip: "等待下载",
createdAt: Date.now(),
});
// async do the job
(async () => {
const songs = songsNeedToSync;
logger.info(`${jobId}: try to sync songs: ${playlistInfo.name}`);
await JobManager.updateJob(uid, jobId, {
status: JobStatus.InProgress,
});
const succeedList = [];
const failedList = [];
// step 2. download the songs
for (const song of songs) {
let tip = `[${succeedList.length + failedList.length + 1}/${
songs.length
}] 正在下载歌曲:${song.songName}`;
await JobManager.updateJob(uid, jobId, {
tip,
});
const syncSucceed = await syncSingleSong(uid, song, playlistInfo);
if (syncSucceed) {
await JobManager.updateJob(uid, jobId, {
log: song.songName + ": 下载成功",
});
succeedList.push({ songName: song.songName, artist: song.artists[0] });
} else {
await JobManager.updateJob(uid, jobId, {
log: song.songName + ": 下载失败",
});
failedList.push({ songName: song.songName, artist: song.artists[0] });
}
await JobManager.updateJob(uid, jobId, {
progress: (succeedList.length + failedList.length) / songs.length,
});
}
let tip = `任务完成,成功${succeedList.length}首,失败${failedList.length}`;
await JobManager.updateJob(uid, jobId, {
progress: 1,
status: succeedList.length > 0 ? JobStatus.Finished : JobStatus.Failed,
tip,
data: {
succeedList,
failedList,
},
});
})().catch(async (e) => {
logger.error(`${jobId}: ${e}`);
let tip = "遇到不可思议的错误了哦,任务终止";
await JobManager.updateJob(uid, jobId, {
status: JobStatus.Failed,
tip,
});
});
return jobId;
};
async function syncSingleSong(uid, wySongMeta, playlistInfo) {
const playlistName = playlistInfo.name;
const playlistID = playlistInfo.id;
logger.info(`sync single song with meta: ${JSON.stringify(wySongMeta)}`);
const globalConfig = await configManager.getGlobalConfig();
let isLossless = false;
if (globalConfig.playlistSyncToLocal.soundQualityPreference === soundQualityConst.Lossless) {
isLossless = true;
}
// 优先使用官方资源下载
const playUrl = await getPlayUrl(uid, wySongMeta.songId, isLossless);
if (playUrl) {
const tmpPath = await downloadViaSourceUrl(playUrl);
if (tmpPath) {
const collectRet = {};
const ret = await downloadFromLocalTmpPath(
tmpPath,
wySongMeta,
playlistName,
collectRet
);
if (ret === true) {
logger.info(`download from official succeed`, wySongMeta);
if (collectRet.md5Value) {
await recordSongIndex(playlistID, wySongMeta.songId, wySongMeta, playlistName, collectRet.md5Value);
}
return true;
}
}
}
// 从公开资源获取
logger.info(
`download from official failed, try to download from public resources`,
wySongMeta
);
const songFromWyCloud = await findTheBestMatchFromWyCloud(uid, {
songName: wySongMeta.songName,
artist: wySongMeta.artists[0],
album: wySongMeta.album,
musicPlatformSongId: wySongMeta.songId,
});
// search songs with the meta
const searchListfilttered = await searchSongsWithSongMeta(
{
songName: wySongMeta.songName,
artist: wySongMeta.artists[0],
album: wySongMeta.album,
duration: wySongMeta.duration,
},
{
expectArtistAkas: songFromWyCloud.artists ? songFromWyCloud.artists : [],
allowSongsJustMatchDuration: false,
allowSongsNotMatchMeta: false,
}
);
logger.info(
`use the searchListfilttered: ${JSON.stringify(searchListfilttered)}`
);
if (searchListfilttered === false) {
return false;
}
// find the best match song
for (const searchItem of searchListfilttered) {
logger.info(`try to the search item: ${JSON.stringify(searchItem)}`);
const collectRet = {};
const isSucceed = await syncSingleSongWithUrl(
uid,
searchItem.url,
{
songName: wySongMeta.songName,
artist: wySongMeta.artists[0],
album: wySongMeta.album,
songFromWyCloud,
},
0,
JobType.SyncThePlaylistToLocalService,
playlistName,
collectRet
);
if (isSucceed === "IOFailed") {
logger.error(`not try others due to upload failed.`);
return false;
}
if (isSucceed) {
if (collectRet.md5Value) {
await recordSongIndex(playlistID, wySongMeta.songId, wySongMeta, playlistName, collectRet.md5Value);
}
return true;
}
}
return false;
}
const SourceWYPlaylist = "wycloudPlaylist";
function recordFileIndex(playlistID, songID, songInfo, playlistName, md5Value) {
const sourceID = `${playlistID}_${songID}`;
// no need to await to save time
KV.fileSyncMeta.set(SourceWYPlaylist, sourceID, {
songInfo,
playlistName,
md5Value,
createTime: Date.now(),
});
}
async function getRecordFileIndex(playlistID, songID) {
const sourceID = `${playlistID}_${songID}`;
return await KV.fileSyncMeta.get(SourceWYPlaylist, sourceID);
}
async function isNeedToSyncFile(playlistID, songID, cache) {
const record = await getRecordFileIndex(playlistID, songID);
if (!record) {
// logger.info(`no record for ${playlistID}_${songID}, need to sync`);
return true;
}
// use the latest setting to rebuild the dest filename
// then check:
// 1. if the file exists(don't check the md5), skip
// 2. if the file not exists, check if there is a same file under the destFile's dir with the same md5, skip
const globalConfig = await configManager.getGlobalConfig();
const destFilename = buildDestFilename(
globalConfig,
record.songInfo,
record.playlistName
);
if (await utilFs.asyncFileExisted(destFilename)) {
// logger.info(`file already exists, skip: ${destFilename}`);
return false;
}
try {
const dir = path.dirname(destFilename);
const files = await (async () => {
if (cache[`dir_files_${dir}`]) {
return cache[`dir_files_${dir}`];
}
const files = await utilFs.asyncReadDir(dir);
cache[`dir_files_${dir}`] = files;
return files;
})();
for (const file of files) {
const filename = path.join(dir, file);
const md5Value = await (async () => {
if (cache[`md5_${filename}`]) {
return cache[`md5_${filename}`];
}
const md5Value = await utilFs.asyncMd5(filename);
cache[`md5_${filename}`] = md5Value;
return md5Value;
})()
if (md5Value === record.md5Value) {
// logger.info(`file already exists with the same md5, skip: ${filename}`);
return false;
}
}
// logger.info(`no file with the same md5, need to sync: ${destFilename}`);
return true;
} catch (e) {
logger.error(e);
// logger.info(`error when check the file, need to sync: ${destFilename}`);
return true;
}
}
async function recordSongIndex(playlistID, songID, songInfo, playlistName, md5Value) {
try {
// 记录单曲信息
recordFileIndex(playlistID, songID, songInfo, playlistName, md5Value);
// 更新歌单元数据
const playlistMeta = await KV.fileSyncMeta.getPlaylistMeta(playlistID);
const songIDs = new Set(playlistMeta.songIDs || []);
songIDs.add(songID);
await KV.fileSyncMeta.setPlaylistMeta(playlistID, {
songIDs: Array.from(songIDs)
});
logger.info(`Updated playlist meta for song: ${songInfo.songName}`);
} catch (err) {
logger.error(`Failed to record song index: ${songInfo.songName}`, err);
// 不抛出错误,避免影响主流程
}
}
async function syncDeleteFiles(playlistId, currentSongIDs) {
try {
// 获取本地已下载的歌曲记录
const playlistMeta = await KV.fileSyncMeta.getPlaylistMeta(playlistId);
const localSongIDs = playlistMeta.songIDs || [];
// 找出需要删除的歌曲(在本地但不在云端的)
const needDeleteSongIDs = localSongIDs.filter(id => !currentSongIDs.includes(id));
if (needDeleteSongIDs.length === 0) {
logger.info(`No songs need to be deleted for playlist: ${playlistId}`);
return;
}
logger.info(`Found ${needDeleteSongIDs.length} songs to delete for playlist: ${playlistId}`);
// 记录删除结果
const deletedSongIDs = new Set();
for (const songID of needDeleteSongIDs) {
const record = await getRecordFileIndex(playlistId, songID);
if (!record) {
logger.warn(`No record found for song: ${songID}`);
continue;
}
const globalConfig = await configManager.getGlobalConfig();
const destFilename = buildDestFilename(globalConfig, record.songInfo, record.playlistName);
try {
if (await utilFs.asyncFileExisted(destFilename)) {
await utilFs.asyncUnlinkFile(destFilename);
logger.info(`Deleted file: ${destFilename}`);
} else {
logger.warn(`File not found: ${destFilename}`);
}
// 删除单曲记录
const sourceID = `${playlistId}_${songID}`;
await KV.fileSyncMeta.set(SourceWYPlaylist, sourceID, null);
deletedSongIDs.add(songID);
logger.info(`Deleted record for song: ${record.songInfo.songName}`);
} catch (err) {
logger.error(`Failed to delete song: ${record.songInfo.songName}`, err);
}
}
// 更新歌单元数据
const remainingSongIDs = localSongIDs.filter(id => !deletedSongIDs.has(id));
await KV.fileSyncMeta.setPlaylistMeta(playlistId, {
songIDs: remainingSongIDs
});
logger.info(`Successfully deleted ${deletedSongIDs.size} songs from playlist: ${playlistId}`);
} catch (err) {
logger.error(`Failed to sync delete files for playlist: ${playlistId}`, err);
}
}

View File

@@ -0,0 +1,83 @@
const { fetchWithUrl, getMetaWithUrl } = require('../media_fetcher');
const logger = require('consola');
const sleep = require('../../utils/sleep');
const findTheBestMatchFromWyCloud = require('../search_songs/find_the_best_match_from_wycloud');
const JobManager = require('../job_manager');
const JobStatus = require('../../consts/job_status');
const JobType = require('../../consts/job_type');
const configManager = require('../config_manager');
const fs = require('fs');
const libPath = require('path');
const utilFs = require('../../utils/fs');
const { downloadFromLocalTmpPath } = require('./download_to_local');
const uploadWithRetryThenMatch = require('./upload_to_wycloud_disk_with_retry_then_match');
module.exports = async function syncSingleSongWithUrl(uid, url, {
songName = "",
artist = "",
album = "",
songFromWyCloud = null
} = {}, jobId = 0, jobType = JobType.SyncSongFromUrl, playlistName = "", collectRet) {
// step 1. fetch song info
const songInfo = await getMetaWithUrl(url);
logger.info(songInfo);
if (songInfo === false || songInfo.isTrial) {
logger.error(`fetch song info failed or it's a trial song. ${JSON.stringify(songInfo)}`);
return false;
}
await updateJobIfNeed(uid, jobId, songInfo, jobType);
// step 2. find the best match from wycloud
if (songFromWyCloud === null) {
let findSongName, findArtist, findAlbum;
if (songName !== "" && artist !== "") {
logger.info(`use the user input song name and artist, ${songName}, ${artist}, ${album}`);
findSongName = songName;
findArtist = artist;
findAlbum = album;
} else if (songInfo.fromMusicPlatform) {
findSongName = songInfo.songName;
findArtist = songInfo.artist;
findAlbum = songInfo.album;
}
songFromWyCloud = await findTheBestMatchFromWyCloud(uid, {
songName: findSongName,
artist: findArtist,
album: findAlbum,
});
} else {
logger.info(`use the songFromWyCloud by params`);
}
logger.info('songFromWyCloud:', songFromWyCloud);
// step 3. download
// should add meta tag if not matched song on wycloud
const path = await fetchWithUrl(url, {songName: songInfo.songName, addMediaTag: songFromWyCloud ? false : true});
if (path === false) {
return false;
}
// step 4. upload or download
logger.info(`handle song start: ${path}`);
if (jobType === JobType.DownloadSongFromUrl || jobType === JobType.SyncThePlaylistToLocalService) {
return await downloadFromLocalTmpPath(path, songInfo, playlistName, collectRet);
} else {
return await uploadWithRetryThenMatch(uid, path, songInfo, songFromWyCloud);
}
}
async function updateJobIfNeed(uid, jobId, songInfo, jobType) {
if (!jobId) {
return;
}
const operation = jobType === JobType.SyncSongFromUrl ? "上传" : "下载";
await JobManager.updateJob(uid, jobId, {
name: `${operation}歌曲:${songInfo.songName}`,
status: JobStatus.InProgress,
desc: `歌曲: ${songInfo.songName}`,
tip: "任务开始",
});
}

View File

@@ -0,0 +1,205 @@
const { getSongsFromPlaylist, getPlayUrl } = require('../music_platform/wycloud');
const syncSingleSongWithUrl = require('./sync_single_song_with_url');
const logger = require('consola');
const { findTheBestMatchFromWyCloud, searchSongsWithSongMeta } = require('../search_songs');
const JobManager = require('../job_manager');
const JobType = require('../../consts/job_type');
const JobStatus = require('../../consts/job_status');
const SoundQuality = require('../../consts/sound_quality');
const BusinessCode = require('../../consts/business_code');
const AccountService = require('../account');
const { downloadViaSourceUrl } = require("../media_fetcher/index");
const uploadWithRetryThenMatch = require('./upload_to_wycloud_disk_with_retry_then_match');
const asyncFS = require('../../utils/fs');
// scope:
// 1. for not wy song: download from network then upload to cloud disk
// 2. for wy song: download from wy then upload to cloud disk. (i.e. backup wy song to cloud disk)
module.exports = async function unblockMusicInPlaylist(uid, source, playlistId, options = {
syncWySong: false,
syncNotWySong: false,
asyncExecute: true,
}) {
// step 1. get songs
const songsInfo = await getSongsFromPlaylist(uid, source, playlistId);
if (songsInfo === false) {
return false;
}
if (songsInfo.songs.length === 0) {
return false;
}
const songsNeedToSync = [];
songsInfo.songs.forEach(song => {
if (song.isCloud) {
return
}
// block song
if (song.isBlocked) {
if (options.syncNotWySong) {
songsNeedToSync.push(song);
}
} else {
// wy song
if (options.syncWySong) {
songsNeedToSync.push(song);
}
}
});
if (songsNeedToSync.length === 0) {
return BusinessCode.StatusNoNeedToSync;
}
// create job
const args = `unblockMusicInPlaylist: {"source":${source},"playlistId":${playlistId}}`;
if (await JobManager.findActiveJobByArgs(uid, args)) {
logger.info(`unblock music in playlist job is already running.`);
return BusinessCode.StatusJobAlreadyExisted;
}
const jobId = await JobManager.createJob(uid, {
name: `解锁歌单:${songsInfo.name}`,
args,
type: JobType.UnblockedPlaylist,
status: JobStatus.Pending,
desc: `${songsNeedToSync.length}首歌曲需要解锁`,
progress: 0,
tip: "等待解锁",
createdAt: Date.now()
});
// async do the job
const job = (async () => {
const songs = songsNeedToSync;
logger.info(`${jobId}: try to unblock songs: ${JSON.stringify(songs)}`);
await JobManager.updateJob(uid, jobId, {
status: JobStatus.InProgress,
});
const succeedList = [];
const failedList = [];
// step 2. download the songs and upload to cloud
for (const song of songs) {
let tip = `[${(succeedList.length + failedList.length + 1)}/${songs.length}] 正在解锁歌曲:${song.songName}`;
await JobManager.updateJob(uid, jobId, {
tip,
});
const syncSucceed = await syncSingleSongWithMeta(uid, song);
if (syncSucceed) {
await JobManager.updateJob(uid, jobId, {
log: song.songName + ": 解锁成功",
});
succeedList.push({songName: song.songName, artist: song.artists[0]});
} else {
await JobManager.updateJob(uid, jobId, {
log: song.songName + ": 解锁失败",
});
failedList.push({songName: song.songName, artist: song.artists[0]});
}
await JobManager.updateJob(uid, jobId, {
progress: (succeedList.length + failedList.length) / songs.length,
});
}
let tip = `任务完成,成功${succeedList.length}首,失败${failedList.length}`;
await JobManager.updateJob(uid, jobId, {
progress: 1,
status: succeedList.length > 0 ? JobStatus.Finished : JobStatus.Failed,
tip,
data: {
succeedList,
failedList,
}
});
})().catch(async e => {
logger.error(`${jobId}: ${e}`);
let tip = '遇到不可思议的错误了哦,任务终止';
await JobManager.updateJob(uid, jobId, {
status: JobStatus.Failed,
tip,
});
});
// For sync execution, wait for job completion
if (!options.asyncExecute) {
await job;
}
return jobId;
}
async function syncSingleSongWithMeta(uid, wySongMeta) {
logger.info(`sync single song with meta: ${JSON.stringify(wySongMeta)}`);
// 获取 wycloud 的歌曲信息,有 id 就直接 get没有就 search meta 选一个最匹配的
const songFromWyCloud = await findTheBestMatchFromWyCloud(uid, {
songName: wySongMeta.songName,
artist: wySongMeta.artists[0],
album: wySongMeta.album,
musicPlatformSongId: wySongMeta.songId,
});
// Case 1: download the song from wy
if (!wySongMeta.isBlocked) {
const account = AccountService.getAccount(uid);
const playUrl = await getPlayUrl(uid, wySongMeta.songId, account.config.playlistSyncToWyCloudDisk.soundQualityPreference === SoundQuality.Lossless);
// if the playUrl is empty, we think the song is block as well. go through the search process
if (playUrl) {
const tmpPath = await downloadViaSourceUrl(playUrl);
// if download failed, we think due to network issue, just return false. It will retry in the next time
if (tmpPath === false) {
return false;
}
// add some magic
try {
await asyncFS.asyncAppendFile(tmpPath, '00000');
} catch (e) {
logger.error(`append file failed: ${tmpPath}`);
// 追加失败,可以继续
}
const isSucceed = await uploadWithRetryThenMatch(uid, tmpPath, null, songFromWyCloud);
if (isSucceed === true) {
return true;
}
return false;
}
}
// Case 2: search songs with the meta in the internet then upload to cloud
const searchListfilttered = await searchSongsWithSongMeta({
songName: wySongMeta.songName,
artist: wySongMeta.artists[0],
album: wySongMeta.album,
duration: wySongMeta.duration,
}, {
expectArtistAkas: songFromWyCloud.artists ? songFromWyCloud.artists : [],
allowSongsJustMatchDuration: false,
allowSongsNotMatchMeta: false,
});
logger.info(`use the searchListfilttered: ${JSON.stringify(searchListfilttered)}`);
if (searchListfilttered === false) {
return false;
}
// find the best match song
for (const searchItem of searchListfilttered) {
logger.info(`try to the search item: ${JSON.stringify(searchItem)}`);
const isUploadSucceed = await syncSingleSongWithUrl(uid, searchItem.url, {
songName: wySongMeta.songName,
artist: wySongMeta.artists[0],
album: wySongMeta.album,
songFromWyCloud,
});
if (isUploadSucceed === "IOFailed") {
logger.error(`not try others due to upload failed.`);
return false;
}
if (isUploadSucceed) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,102 @@
const { getSongInfo } = require('../music_platform/wycloud');
const syncSingleSongWithUrl = require('./sync_single_song_with_url');
const logger = require('consola');
const { findTheBestMatchFromWyCloud, searchSongsWithSongMeta } = require('../search_songs');
const JobManager = require('../job_manager');
const JobType = require('../../consts/job_type');
const JobStatus = require('../../consts/job_status');
const BusinessCode = require('../../consts/business_code');
module.exports = async function unblockMusiWithSongId(uid, source, songId) {
const songInfo = await getSongInfo(uid, songId);
if (songInfo === false) {
return false;
}
// create job
const args = `unblockMusicWithSongId: {"source":${source},"songId":${songId}}`;
if (await JobManager.findActiveJobByArgs(uid, args)) {
logger.info(`unblock music with songID job is already running.`);
return BusinessCode.StatusJobAlreadyExisted;
}
const jobId = await JobManager.createJob(uid, {
name: `解锁歌曲:${songInfo.songName}`,
args,
type: JobType.UnblockedSong,
status: JobStatus.Pending,
desc: `${songInfo.songName} - ${songInfo.artists.join(',')}`,
progress: 0,
tip: "等待解锁",
createdAt: Date.now()
});
// async do the job
(async () => {
logger.info(`${jobId}: try to unblock song: ${JSON.stringify(songInfo)}`);
// download the songs and upload to cloud
const syncSucceed = await syncSingleSongWithMeta(uid, songInfo);
let tip = songInfo.songName + (syncSucceed ? ": 解锁成功" : ": 解锁失败");
await JobManager.updateJob(uid, jobId, {
progress: 1,
status: syncSucceed ? JobStatus.Finished : JobStatus.Failed,
tip,
data: {}
});
})().catch(async e => {
logger.error(`${jobId}: ${e}`);
let tip = '遇到不可思议的错误了哦,任务终止';
await JobManager.updateJob(uid, jobId, {
status: JobStatus.Failed,
tip,
});
})
return jobId;
}
async function syncSingleSongWithMeta(uid, wySongMeta) {
logger.info(`sync single song with meta: ${JSON.stringify(wySongMeta)}`);
const songFromWyCloud = await findTheBestMatchFromWyCloud(uid, {
songName: wySongMeta.songName,
artist: wySongMeta.artists[0],
album: wySongMeta.album,
musicPlatformSongId: wySongMeta.songId,
});
// search songs with the meta
const searchListfilttered = await searchSongsWithSongMeta({
songName: wySongMeta.songName,
artist: wySongMeta.artists[0],
album: wySongMeta.album,
duration: wySongMeta.duration,
}, {
expectArtistAkas: songFromWyCloud.artists ? songFromWyCloud.artists : [],
allowSongsJustMatchDuration: false,
allowSongsNotMatchMeta: false,
});
logger.info(`use the searchListfilttered: ${JSON.stringify(searchListfilttered)}`);
if (searchListfilttered === false) {
return false;
}
// find the best match song
for (const searchItem of searchListfilttered) {
logger.info(`try to the search item: ${JSON.stringify(searchItem)}`);
const isUploadSucceed = await syncSingleSongWithUrl(uid, searchItem.url, {
songName: wySongMeta.songName,
artist: wySongMeta.artists[0],
album: wySongMeta.album,
songFromWyCloud,
});
if (isUploadSucceed === "IOFailed") {
logger.error(`not try others due to upload failed.`);
return false;
}
if (isUploadSucceed) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,50 @@
const { uploadSong, matchAndFixCloudSong } = require('../music_platform/wycloud');
const logger = require('consola');
const fs = require('fs');
const sleep = require('../../utils/sleep');
module.exports = async function uploadWithRetryThenMatch(uid, path, songInfo, songFromWyCloud) {
const startTime = new Date();
let isHandleSucceed = false;
let uploadResult;
for (let tryCount = 0; tryCount < 5; tryCount++) {
if (tryCount !== 0) {
logger.info(`upload song failed, try again: ${path}`);
}
uploadResult = await uploadSong(uid, path);
if (uploadResult === false) {
logger.error(`upload song failed, uid: ${uid}, path: ${path}`);
await sleep(3000);
continue;
} else {
isHandleSucceed = true;
break;
}
}
// del file async
fs.unlink(path, () => {});
if (!isHandleSucceed) {
logger.error(`upload song failed, uid: ${uid}, path: ${path}`);
return "IOFailed";
}
const costSeconds = (new Date() - startTime) / 1000;
logger.info(`upload song success, uid: ${uid}, path: ${path}, cost: ${costSeconds}s`);
if (uploadResult.matched) {
logger.info(`matched song already, uid: ${uid}, songId: ${uploadResult.songId}. ignore.`);
return true;
}
// fix match manually IF not matched in music platform
if (!songFromWyCloud) {
logger.info(`would not try to match from wycloud!!! uid: ${uid}, ${JSON.stringify(songInfo)}`);
return true;
}
const matchResult = await matchAndFixCloudSong(uid, uploadResult.songId, songFromWyCloud.songId);
logger.info(`match song ${matchResult ? 'success' : 'failed'}, uid: ${uid}, songId: ${uploadResult.songId}, wySongId: ${songFromWyCloud.songId}`);
return true;
}

32
backend/src/utils/cmd.js Normal file
View File

@@ -0,0 +1,32 @@
const logger = require("consola");
const spawnAsync = require("child_process").spawn;
module.exports = function exec(exe, args) {
return new Promise(function (resolve, reject) {
console.log(exe, args);
const process = spawnAsync(exe, args);
let stdout = "";
let stderr = "";
process.stdout.on("data", function (data) {
stdout += data;
});
process.stderr.on("data", function (data) {
stderr += data;
});
process.on("close", function (code) {
resolve({
code: stderr ? code : 0,
message: stderr ? stderr: stdout,
});
});
process.on("error", function (error) {
logger.error('exec error: ', error);
resolve({
code: -1,
message: error.message,
});
});
});
}

View File

@@ -0,0 +1,20 @@
const got = require('got');
const fs = require('fs');
const pipeline = require('stream').pipeline;
const { promisify } = require('util');
const streamPipeline = promisify(pipeline);
module.exports = async function downloadFile(url, destination) {
try {
await streamPipeline(
got.stream(url),
fs.createWriteStream(destination)
);
return true;
} catch (error) {
console.error('download failed:', error);
return false;
}
}

124
backend/src/utils/fs.js Normal file
View File

@@ -0,0 +1,124 @@
const fs = require('fs');
const crypto = require('crypto');
function asyncReadFile(filePath) {
return new Promise((resolve, reject) => {
fs.readFile(filePath, (err, data) => {
if (err) {
reject(err);
return;
}
resolve(data);
});
});
}
function asyncWriteFile(filePath, data) {
return new Promise((resolve, reject) => {
fs.writeFile(filePath, data, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
function asyncFileExisted(filePath) {
return new Promise((resolve, reject) => {
fs.access(filePath, fs.constants.F_OK, (err) => {
if (err) {
resolve(false);
return;
}
resolve(true);
});
});
}
function asyncMkdir(dirPath, options) {
return new Promise((resolve, reject) => {
fs.mkdir(dirPath, options, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
function asyncUnlinkFile(filePath) {
return new Promise((resolve, reject) => {
fs.unlink(filePath, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
const fsPromise = fs.promises;
async function asyncMoveFile(oldPath, newPath) {
await fsPromise.copyFile(oldPath, newPath)
await fsPromise.unlink(oldPath);
}
function asyncReadDir(dirPath) {
return new Promise((resolve, reject) => {
fs.readdir(dirPath, (err, files) => {
if (err) {
reject(err);
return;
}
resolve(files);
}
)});
}
async function asyncMd5(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5');
const stream = fs.createReadStream(filePath);
stream.on('data', (data) => {
hash.update(data);
});
stream.on('end', () => {
resolve(hash.digest('hex'));
});
stream.on('error', (error) => {
reject(error);
});
});
}
async function asyncAppendFile(filePath, str) {
return new Promise((resolve, reject) => {
fs.appendFile(filePath, str, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
module.exports = {
asyncReadFile,
asyncWriteFile,
asyncFileExisted,
asyncMkdir,
asyncUnlinkFile,
asyncMoveFile,
asyncReadDir,
asyncMd5,
asyncAppendFile,
};

View File

@@ -0,0 +1,25 @@
const https = require('https');
function asyncHttpsGet(url) {
return new Promise((resolve) => {
https.get(url, res => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => {
resolve(data.toString());
});
}).on('error', err => {
console.error(err);
resolve(null);
});
});
}
module.exports = {
asyncHttpsGet
}

View File

@@ -0,0 +1,9 @@
module.exports = {
matchUrlFromStr: (str) => {
const matched = str.match(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/);
if (!matched) {
return false;
}
return matched[0];
}
}

View File

@@ -0,0 +1,30 @@
const lockMap = {};
const sleep = require('./sleep');
async function lock(key, expireSeconds = 20) {
let retryCount = 20;
while (--retryCount >= 0) {
if (!lockMap[key]) {
lockMap[key] = true;
setTimeout(() => {
delete lockMap[key];
}, expireSeconds * 1000);
return true;
}
await sleep(200);
}
return false;
}
function unlock(key) {
delete lockMap[key];
}
module.exports = {
lock: lock,
unlock: unlock,
};

View File

@@ -0,0 +1,3 @@
module.exports = function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,5 @@
const { v4: uuidv4 } = require('uuid');
module.exports = function () {
return uuidv4().replace(/-/g, '');
}

View File

@@ -0,0 +1,5 @@
NODE_ENV = 'development'
VITE_APP_MODE = 'development'
VITE_APP_API_URL = 'http://172.16.252.1:5566/api'
VITE_APP_API_URL = 'http://10.0.0.2:5566/api'
VITE_APP_API_URL = 'http://127.0.0.1:5566/api'

3
frontend/.env.production Normal file
View File

@@ -0,0 +1,3 @@
NODE_ENV = 'production'
VITE_APP_MODE = 'production'
VITE_APP_API_URL = '/api'

1
frontend/.nvmrc Normal file
View File

@@ -0,0 +1 @@
v16.13.0

25
frontend/index.html Normal file
View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="referrer" content="never" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css" integrity="sha512-NhSC1YmyruXifcj/KFRWoC561YpHpc5Jtzgvbuzx5VozKpWvQ+4nXhPdFgmx8xqexRcpAglTj9sIBWINXa8x5w==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.8.1/font/bootstrap-icons.min.css" integrity="sha512-Oy+sz5W86PK0ZIkawrG0iv7XwWhYecM3exvUtMKNJMekGFJtVAhibhRPTpmyTj8+lJCkmWfnpxKgT2OopquBHA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>Melody - 我的音乐精灵</title>
</head>
<body>
<div id="app"></div>
<script type="text/javascript">
if (/phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone/i.test(
navigator.userAgent)) {
window.location.href = "/mobile.html";
}
</script>
<script type="module" src="/src/main.js"></script>
</body>
</html>

30
frontend/mobile.html Normal file
View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="referrer" content="never" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css" integrity="sha512-NhSC1YmyruXifcj/KFRWoC561YpHpc5Jtzgvbuzx5VozKpWvQ+4nXhPdFgmx8xqexRcpAglTj9sIBWINXa8x5w==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.8.1/font/bootstrap-icons.min.css" integrity="sha512-Oy+sz5W86PK0ZIkawrG0iv7XwWhYecM3exvUtMKNJMekGFJtVAhibhRPTpmyTj8+lJCkmWfnpxKgT2OopquBHA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<!-- <script type="text/javascript" src="https://vncdn.mobi88.cn/public/vconsole.min.js"></script> -->
<title>Melody - 我的音乐精灵</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/mobile.js"></script>
</body>
<style>
.popover-overlay {
background-color: rgba(0, 0, 0, 0.2);
}
</style>
<script>
// var vConsole = new VConsole();
</script>
</html>

29
frontend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "melody-frontend",
"private": true,
"version": "0.1.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^1.1.4",
"axios": "0.26.1",
"element-plus": "2.1.9",
"howler": "github:foamzou/howler.js#0.0.1-foam",
"vant": "^3.4.9",
"vue": "3.2.33",
"vue-router": "4.0.12",
"vue-virtual-scroller": "2.0.0-alpha.1",
"vuex": "4.0.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^2.3.1",
"rollup": "^2.70.2",
"vite": "^2.9.14",
"vite-plugin-cdn-import": "^0.3.5",
"vite-plugin-pwa": "^0.12.3",
"workbox-window": "^6.5.4"
}
}

3690
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
frontend/public/melody.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

451
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,451 @@
<template>
<div class="common-layout">
<el-container>
<el-header height="120px" style="padding: 0">
<el-row>
<el-col :span="24">
<el-row>
<el-col :span="2" :offset="10">
<el-image src="/melody.png" style="width: 90px; height: 90px" />
</el-col>
<el-col :span="5" style="text-align: left; margin-top: 28px">
<el-row>
<span style="font-size: 30px; font-weight: bold">Melody</span>
</el-row>
<el-row>
<span style="font-size: 12px; color: grey; margin-left: 25px"
>我的音乐精灵</span
>
</el-row>
</el-col>
<el-col :span="7">
<a
href="https://github.com/foamzou/melody"
class="github-corner"
aria-label="View source on GitHub"
><svg
width="80"
height="80"
viewBox="0 0 250 250"
style="
fill: #151513;
color: #fff;
position: absolute;
top: 0;
border: 0;
right: 0;
"
aria-hidden="true"
>
<path
d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"
></path>
<path
d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2"
fill="currentColor"
style="transform-origin: 130px 106px"
class="octo-arm"
></path>
<path
d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z"
fill="currentColor"
class="octo-body"
></path></svg
></a>
</el-col>
</el-row>
</el-col>
</el-row>
<el-row class="nav-container">
<el-col :span="12" :offset="6">
<div class="nav-menu">
<div
v-for="(item, index) in navItems"
:key="index"
class="nav-item"
:class="{ active: currentPath === item.path }"
@click="navigate(item.path)"
>
<i :class="item.icon"></i>
<span>{{ item.label }}</span>
</div>
</div>
</el-col>
</el-row>
</el-header>
<router-view
:playTheSong="playTheSong"
:playTheSongWithPlayUrl="playTheSongWithPlayUrl"
:abortTheSong="abortTheSong"
v-slot="{ Component }"
>
<transition>
<keep-alive>
<component :is="Component" />
</keep-alive>
</transition>
</router-view>
<el-footer
v-if="playerSongInfo.playUrl"
height="70px"
class="player-footer"
>
<div class="player-container">
<el-row align="middle" class="player-content">
<!-- 左侧封面和歌曲信息 -->
<el-col :span="6" class="song-info">
<div class="cover-image">
<el-image
:src="playerSongInfo.coverUrl"
fit="cover"
class="cover"
/>
</div>
<div class="song-details">
<div class="song-name">{{ playerSongInfo.songName }}</div>
<div class="artist-name">{{ playerSongInfo.artist }}</div>
</div>
</el-col>
<!-- 中间播放器控件 -->
<el-col :span="12" class="player-controls">
<audio
id="audio"
autoplay
:src="playerSongInfo.playUrl"
controls="controls"
class="audio-player"
/>
</el-col>
<!-- 右侧操作按钮 -->
<el-col :span="6" class="operation-buttons">
<el-tooltip
:content="
wyAccount
? '上传歌曲到云盘'
: '上传歌曲到云盘(请先绑定网易云账号)'
"
placement="top"
>
<el-button
circle
class="operation-btn"
:disabled="!playerSongInfo.pageUrl || !wyAccount"
@click="
uploadToCloud(
playerSongInfo.pageUrl,
playerSongInfo.suggestMatchSongId
)
"
>
<i class="bi bi-cloud-upload"></i>
</el-button>
</el-tooltip>
<el-tooltip content="在源站查看" placement="top">
<el-button
circle
class="operation-btn"
:disabled="!playerSongInfo.pageUrl"
@click="window.open(playerSongInfo.pageUrl, '_blank')"
>
<i class="bi bi-box-arrow-up-right"></i>
</el-button>
</el-tooltip>
</el-col>
</el-row>
</div>
</el-footer>
</el-container>
</div>
</template>
<script>
import { getPlayUrl, getSongsMeta, createSyncSongFromUrlJob } from "./api";
import { startTaskListener } from "./components/TaskNotification";
import storage from "./utils/storage";
import { getProperPlayUrl } from "./utils/audio";
export default {
data: () => {
return {
playerSongInfo: {
songName: "",
artist: "",
coverUrl: "/melody.png",
playUrl: "",
pageUrl: "",
suggestMatchSongId: "",
},
wyAccount: null,
navItems: [
{ label: "搜索", path: "/", icon: "bi bi-search" },
{ label: "我的歌单", path: "/playlist", icon: "bi bi-music-note-list" },
{ label: "我的音乐账号", path: "/account", icon: "bi bi-person" },
{ label: "设置", path: "/setting", icon: "bi bi-gear" },
],
currentPath: "/",
};
},
mounted() {
this.wyAccount = storage.get("wyAccount");
},
watch: {
$route(to) {
this.wyAccount = storage.get("wyAccount");
this.currentPath = to.path;
},
},
methods: {
async uploadToCloud(pageUrl, suggestMatchSongId) {
const ret = await createSyncSongFromUrlJob(pageUrl, suggestMatchSongId);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
search() {
this.$router.push("/");
},
account() {
this.$router.push("/account");
},
playlist() {
this.$router.push("/playlist");
},
setting() {
this.$router.push("/setting");
},
async playTheSong(metaInfo, pageUrl, suggestMatchSongId) {
console.log("------------------------");
console.log(metaInfo);
console.log(pageUrl);
let info = metaInfo;
if (!info) {
const ret = await getSongsMeta({ url: pageUrl });
info = ret.data.songMeta;
console.log(ret);
}
const resourceForbidden = info.resourceForbidden;
const songUrl = info.audios[0].url;
console.log("play: ", songUrl);
this.playerSongInfo.playUrl = getProperPlayUrl(
info.source,
songUrl,
pageUrl || info.pageUrl
);
this.playerSongInfo.coverUrl = info.coverUrl;
this.playerSongInfo.songName = info.songName;
this.playerSongInfo.artist = info.artist;
this.playerSongInfo.pageUrl = info.pageUrl || pageUrl;
this.playerSongInfo.suggestMatchSongId = suggestMatchSongId;
},
async playTheSongWithPlayUrl(playOption) {
if (!playOption.playUrl) {
const playUrlRet = await getPlayUrl(playOption.songId);
if (playUrlRet.data.playUrl) {
playOption.playUrl = playUrlRet.data.playUrl;
}
}
this.playerSongInfo.playUrl = getProperPlayUrl(
playOption.source,
playOption.playUrl,
playOption.pageUrl
);
this.playerSongInfo.coverUrl = playOption.coverUrl;
this.playerSongInfo.songName = playOption.songName;
this.playerSongInfo.artist = playOption.artist;
this.playerSongInfo.pageUrl = playOption.pageUrl;
return true;
},
abortTheSong() {
this.playerSongInfo.playUrl = "";
},
navigate(path) {
this.$router.push(path);
},
},
};
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 3px;
}
.nav span {
font-size: 18px;
}
.el-dialog {
margin-top: 30px;
}
.github-corner:hover .octo-arm {
animation: octocat-wave 560ms ease-in-out;
}
@keyframes octocat-wave {
0%,
100% {
transform: rotate(0);
}
20%,
60% {
transform: rotate(-25deg);
}
40%,
80% {
transform: rotate(10deg);
}
}
@media (max-width: 500px) {
.github-corner:hover .octo-arm {
animation: none;
}
.github-corner .octo-arm {
animation: octocat-wave 560ms ease-in-out;
}
}
<style scoped > .nav-container {
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
padding: 8px 0;
margin-top: 10px;
}
.nav-menu {
display: flex;
justify-content: space-around;
align-items: center;
}
.nav-item {
display: flex;
align-items: center;
padding: 8px 20px;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
color: #606266;
}
.nav-item:hover {
color: #409eff;
background: rgba(64, 158, 255, 0.1);
}
.nav-item.active {
color: #409eff;
background: rgba(64, 158, 255, 0.1);
font-weight: 500;
}
.nav-item i {
font-size: 18px;
margin-right: 6px;
}
.nav-item span {
font-size: 15px;
}
.player-footer {
position: fixed;
bottom: 0;
width: 100%;
background: linear-gradient(to right, #1a1a1a, #2d2d2d);
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
z-index: 100;
padding: 0;
}
.player-container {
max-width: 1400px;
margin: 0 auto;
height: 100%;
}
.player-content {
height: 70px;
padding: 0 20px;
}
.song-info {
display: flex;
align-items: center;
gap: 15px;
}
.cover-image {
width: 50px;
height: 50px;
border-radius: 6px;
overflow: hidden;
}
.cover {
width: 100%;
height: 100%;
object-fit: cover;
}
.song-details {
color: #fff;
}
.song-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.artist-name {
font-size: 12px;
color: #a8a8a8;
}
.player-controls {
display: flex;
align-items: center;
justify-content: center;
}
.audio-player {
width: 100%;
max-width: 600px;
height: 32px;
border-radius: 16px;
}
.player-footer .operation-btn {
border: 1px solid rgba(255, 255, 255, 0.2);
color: #000000;
transition: all 0.3s;
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.3);
}
&:disabled {
border-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.3);
}
i {
font-size: 16px;
}
}
</style>

234
frontend/src/Mobile.vue Normal file
View File

@@ -0,0 +1,234 @@
<template>
<div class="common-layout">
<van-row>
<van-col span="8">
<van-image src="/melody.png" style="width: 60px; height: 60px" />
</van-col>
<van-col span="8" style="margin-top: 15px">
<van-row>
<span style="font-size: 20px; font-weight: bold">Melody</span>
</van-row>
<van-row>
<span style="font-size: 12px; color: grey">我的音乐精灵</span>
</van-row>
</van-col>
<van-col span="8">
<a
href="https://github.com/foamzou/melody"
class="github-corner"
aria-label="View source on GitHub"
><svg
width="80"
height="80"
viewBox="0 0 250 250"
style="
fill: #151513;
color: #fff;
position: absolute;
top: 0;
border: 0;
right: 0;
"
aria-hidden="true"
>
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
<path
d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2"
fill="currentColor"
style="transform-origin: 130px 106px"
class="octo-arm"
></path>
<path
d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z"
fill="currentColor"
class="octo-body"
></path></svg
></a>
</van-col>
</van-row>
<router-view
:playTheSong="playTheSong"
:playTheSongWithPlayUrl="playTheSongWithPlayUrl"
v-slot="{ Component }"
:style="
songInfos.length > 0 ? 'margin-bottom: 136px;' : 'margin-bottom: 60px;'
"
>
<transition>
<keep-alive>
<component :is="Component" />
</keep-alive>
</transition>
</router-view>
<div
v-show="songInfos.length > 0"
style="
padding-bottom: 5px;
height: 80px;
width: 100%;
position: fixed;
bottom: 50px;
left: 0;
background-color: white;
z-index: 10;
"
>
<Player
:songInfos="songInfos"
:currentSongIndex="currentSongIndex"
:changedTime="changedTime"
></Player>
</div>
<van-tabbar v-model="active" route>
<van-tabbar-item icon="search" to="/" @click="search()"
>搜索</van-tabbar-item
>
<van-tabbar-item icon="like-o" to="/playlist" @click="playlist()"
>歌单</van-tabbar-item
>
<van-tabbar-item icon="contact" to="/account" @click="account()"
>音乐账号</van-tabbar-item
>
</van-tabbar>
</div>
</template>
<script>
import { ref } from "vue";
import {
searchSongs,
getSongsMeta,
createSyncSongFromUrlJob,
getPlayUrl,
} from "./api";
import { startTaskListener } from "./components/TaskNotification";
import Player from "./components/Player.vue";
import storage from "./utils/storage";
import { Notify } from "vant";
import { getProperPlayUrl } from "./utils/audio";
export default {
setup() {
const active = ref(0);
return {
active,
};
},
components: {
Player,
},
data: () => {
return {
songInfos: [],
currentSongIndex: 0,
changedTime: 0,
wyAccount: null,
};
},
mounted() {
this.wyAccount = storage.get("wyAccount");
},
watch: {
$route(to, from) {
this.wyAccount = storage.get("wyAccount");
},
},
methods: {
search() {
this.$router.push("/");
},
account() {
this.$router.push("/account");
},
playlist() {
this.$router.push("/playlist");
},
async playTheSong(metaInfo, pageUrl, suggestMatchSongId) {
console.log("------------------------");
console.log(metaInfo);
console.log(pageUrl);
let info = metaInfo;
if (!info) {
const ret = await getSongsMeta({ url: pageUrl });
info = ret.data.songMeta;
console.log(ret);
}
const songUrl = info.audios[0].url;
console.log("play: ", songUrl);
// 处理播放 URL
const processedPlayUrl = getProperPlayUrl(
info.source,
songUrl,
pageUrl || info.pageUrl
);
this.songInfos.push({
playUrl: processedPlayUrl,
coverUrl: info.coverUrl,
songName: info.songName,
artist: info.artist,
pageUrl: info.pageUrl || pageUrl,
suggestMatchSongId,
});
this.currentSongIndex = this.songInfos.length - 1;
this.changedTime = new Date().getTime();
},
async playTheSongWithPlayUrl(playOption) {
if (!playOption.playUrl) {
const playUrlRet = await getPlayUrl(playOption.songId);
if (!playUrlRet.data.playUrl) {
Notify({ type: "warning", message: "获取播放链接失败" });
return false;
}
playOption.playUrl = playUrlRet.data.playUrl;
}
this.songInfos.push(playOption);
this.currentSongIndex = this.songInfos.length - 1;
this.changedTime = new Date().getTime();
return true;
},
},
};
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 3px;
}
.github-corner:hover .octo-arm {
animation: octocat-wave 560ms ease-in-out;
}
@keyframes octocat-wave {
0%,
100% {
transform: rotate(0);
}
20%,
60% {
transform: rotate(-25deg);
}
40%,
80% {
transform: rotate(10deg);
}
}
@media (max-width: 500px) {
.github-corner:hover .octo-arm {
animation: none;
}
.github-corner .octo-arm {
animation: octocat-wave 560ms ease-in-out;
}
}
</style>

74
frontend/src/api/axios.js Normal file
View File

@@ -0,0 +1,74 @@
import axios from "axios";
const axiosApiInstance = axios.create();
import storage from "../utils/storage"
axiosApiInstance.defaults.baseURL = import.meta.env.VITE_APP_API_URL
//post请求头
//允许跨域携带cookie信息
axiosApiInstance.defaults.withCredentials = true;
//设置超时
axiosApiInstance.defaults.timeout = 12000;
axiosApiInstance.interceptors.request.use(
config => {
config.headers = {
'mk': (config.params && config.params['mk']) ? config.params['mk'] : storage.get('mk')
}
return config;
},
error => {
return Promise.reject(error);
}
);
axiosApiInstance.interceptors.response.use(
response => {
return Promise.resolve(response);
},
error => {
// 返回错误响应中的数据
if (error.response && error.response.data) {
return Promise.resolve(error.response);
}
// 如果没有response.data返回一个统一的错误格式
return Promise.resolve({
data: {
code: -1,
message: error.message || '网络错误'
}
});
}
);
export const post = (url, data) => {
return new Promise((resolve, reject) => {
axiosApiInstance({
method: 'post',
url,
data,
})
.then(res => {
resolve(res ? res.data : false)
})
.catch(err => {
reject(err.data)
});
})
};
export const get = (url, data) => {
return new Promise((resolve, reject) => {
axiosApiInstance({
method: 'get',
url,
params: data,
})
.then(res => {
resolve(res ? res.data : false)
})
.catch(err => {
reject(err)
})
})
}

78
frontend/src/api/index.js Normal file
View File

@@ -0,0 +1,78 @@
import { get, post} from "./axios";
export const searchSongs = data => get("/songs", data);
export const getSongsMeta = data => get("/songs-meta", data);
export const getPlayUrl = songId => get(`/songs/netease/${songId}/playUrl`);
export const getAccount = data => get("/account", data);
export const setAccount = data => post("/account", data);
export const qrLoginCreate = _ => get("/account/qrlogin-create", {});
export const qrLoginCheck = qrKey => get("/account/qrlogin-check", {qrKey});
export const getAllPlaylist = data => get("/playlists", data);
export const getPlaylistDetail = playlistId => get(`/playlists/netease/${playlistId}/songs`);
export const getJobDetail = jobId => get(`/sync-jobs/${jobId}`);
export const createSyncSongFromUrlJob = (url, songId = "") => {
return post("/sync-jobs", {
"jobType": "SyncSongFromUrl",
"urlJob": {
"url": url,
"meta": {
"songId": songId
}
}
});
};
export const createDownloadSongFromUrlJob = (url, songId = "") => {
return post("/sync-jobs", {
"jobType": "DownloadSongFromUrl",
"urlJob": {
"url": url,
"meta": {
"songId": songId
}
}
});
};
export const createSyncSongFromPlaylistJob = (playlistId, options) => {
return post("/sync-jobs", {
"jobType": "UnblockedPlaylist",
"playlist": {
"id": playlistId,
"source": "netease"
},
"options": options
});
};
export const createSyncThePlaylistToLocalServiceJob = (playlistId) => {
return post("/sync-jobs", {
"jobType": "SyncThePlaylistToLocalService",
"playlist": {
"id": playlistId,
"source": "netease"
}
});
};
export const createSyncSongWithSongIdJob = (songId) => {
return post("/sync-jobs", {
"jobType": "UnblockedSong",
"songId": songId,
"source": "netease"
});
};
export const checkMediaFetcherLib = data => get("/media-fetcher-lib/version-check", data);
export const updateMediaFetcherLib = (version) => {
return post("/media-fetcher-lib/update", {
"version": version,
});
};
export const getGlobalConfig = _ => get("/config/global", {});
export const setGlobalConfig = (config) => {
return post("/config/global", config);
};
export const getAllAccounts = _ => get("/accounts", {});
export const getNextRunInfo = () => get("/scheduler/next-run", {});

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1,277 @@
<template>
<div class="player-body">
<van-row style="height: 62px; overflow: hidden">
<van-col span="3">
<van-image
style="padding: 3px"
width="100%"
:src="currentSong.coverUrl"
/>
</van-col>
<van-col span="15" style="text-align: left; padding: 3px 0 0 10px">
<div style="font-weight: bold; font-size: 18px">
{{ keepSomeText(currentSong.songName, 22) }}
</div>
<div>
<span style="color: gray; font-size: 13px">
{{ currentSong.artist }}
</span>
</div>
</van-col>
<van-col span="3" style="height: 100%">
<i
v-if="isPlaying"
class="bi bi-pause-circle"
style="line-height: 60px; font-size: 20px; color: gray"
@click="pauseSong"
></i>
<i
v-if="!isPlaying"
class="bi bi-play-circle"
style="line-height: 60px; font-size: 20px; color: gray"
@click="continueSong"
></i>
</van-col>
<van-col span="3">
<van-popover
v-model:show="showPopover"
overlay
overlay-class="popover-overlay"
:actions="actions"
placement="left-end"
@select="onSelect"
>
<template #reference>
<i
style="line-height: 60px; font-size: 20px; color: gray"
class="bi bi-list"
></i>
</template>
</van-popover>
</van-col>
</van-row>
<van-row>
<van-col span="2" style="font-size: 10px; padding-left: 2px">{{
secondDurationToDisplayDuration(currentSeek, true)
}}</van-col>
<van-col span="20" style="padding: 5px 7px 0 7px">
<van-slider
:max="totalTime"
bar-height="2"
button-size="10"
v-model="currentSeek"
@change="onSlicerChange"
/>
</van-col>
<van-col
span="2"
style="font-size: 10px; color: gray; padding-right: 2px"
>{{ secondDurationToDisplayDuration(totalTime) }}</van-col
>
</van-row>
</div>
</template>
<style>
.player-body {
background-color: white;
padding-top: 10px;
}
:root {
--van-popover-action-font-size: 13px;
}
</style>
<script>
import { nextTick, ref } from "vue";
import * as Howler from "howler";
import { secondDurationToDisplayDuration, sleep } from "../utils";
import { createSyncSongFromUrlJob, createDownloadSongFromUrlJob } from "../api";
import { startTaskListener } from "./TaskNotificationForMobile";
let playerCtl;
let currentPlayId;
const ActionUpload = 0;
const ActionDownload = 1;
const ActionOpenRef = 2;
const ActionDownloadToLocalService = 3;
export default {
data() {
return {
totalTime: 0,
currentSeek: 0,
currentSong: this.songInfos[this.currentSongIndex] || {},
isPlaying: false,
};
},
props: {
songInfos: {
type: Array,
required: true,
},
currentSongIndex: {
type: Number,
required: false,
default: 0,
},
changedTime: {
type: Number,
required: false,
default: 0,
},
},
watch: {
changedTime: function () {
this.currentSong = this.songInfos[this.currentSongIndex];
this.playSong();
},
},
methods: {
secondDurationToDisplayDuration,
async timeChanged() {
const currentSeek = playerCtl.seek();
// for performance
if (parseInt(currentSeek) !== parseInt(this.currentSeek)) {
this.currentSeek = currentSeek;
} else {
await sleep(150);
}
requestAnimationFrame(this.timeChanged);
},
pauseSong() {
playerCtl.pause(currentPlayId);
this.isPlaying = false;
},
playSong() {
console.log(this.songInfos);
console.log(this.currentSong.playUrl);
if (playerCtl) {
playerCtl.stop();
}
playerCtl = new Howler.Howl({
src: [this.currentSong.playUrl],
html5: true,
preload: "metadata",
onload: () => {
console.log("load");
this.totalTime = playerCtl.duration();
},
onend: () => {
console.log("end");
this.isPlaying = false;
},
onloaderror: (id, err) => {
console.log("load error", id, err);
},
onplayerror: (id, err) => {
console.log("play error", id, err);
},
});
currentPlayId = playerCtl.play();
this.isPlaying = true;
this.timeChanged();
},
continueSong() {
if (!currentPlayId) {
this.playSong();
return;
}
playerCtl.play(currentPlayId);
this.isPlaying = true;
},
onSlicerChange(time) {
console.log("slicer changed", time);
playerCtl.seek(time);
},
keepSomeText(text, length) {
if (!text) {
return "";
}
if (text.length > length) {
return text.substring(0, length) + "...";
} else {
return text;
}
},
async uploadToCloud() {
if (!this.currentSong) {
return;
}
console.log(this.currentSong);
const ret = await createSyncSongFromUrlJob(
this.currentSong.pageUrl,
this.currentSong.suggestMatchSongId ?? 0
);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async downloadToLocalService() {
if (!this.currentSong) {
return;
}
console.log(this.currentSong);
const ret = await createDownloadSongFromUrlJob(
this.currentSong.pageUrl,
this.currentSong.suggestMatchSongId ?? 0
);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async onSelect(actionItem) {
switch (actionItem.action) {
case ActionUpload:
this.uploadToCloud();
break;
case ActionDownloadToLocalService:
this.downloadToLocalService();
break;
case ActionDownload:
const a = document.createElement("a");
a.target = "_blank";
a.href = this.currentSong.playUrl;
a.download = `${this.currentSong.songName}-${this.currentSong.artist}.mp3`;
a.style.display = "none";
document.body.appendChild(a);
a.click();
a.remove();
break;
case ActionOpenRef:
window.open(this.currentSong.pageUrl, "_blank").focus();
break;
}
},
},
setup(props, { emit }) {
// const playTheSong = (songMeta, pageUrl, index) => {
// props.playTheSong(songMeta, pageUrl, index);
// };
// const abortTheSong = () => {
// props.abortTheSong();
// };
const showPopover = ref(false);
const actions = [
{ text: "上传到云盘", icon: "upgrade", action: ActionUpload },
{ text: "下载到浏览器本地", icon: "down", action: ActionDownload },
{ text: "下载到服务器", icon: "down", action: ActionDownloadToLocalService },
{ text: "打开源站", icon: "share", action: ActionOpenRef },
];
return {
showPopover,
actions,
};
},
};
</script>

View File

@@ -0,0 +1,219 @@
<template>
<div style="width: 100%">
<van-col span="22">
<van-row
v-for="(item, i) in searchResult"
:key="i"
style="margin-top: 15px; text-align: left"
>
<van-col span="23" offset="1">
<van-row>
<van-col span="22">
<van-row @click="play(null, item.url, i)">
<van-col style="font-size: 16px">
<i
v-if="item.resourceForbidden"
class="bi bi-lock-fill"
style="font-size: 13px; padding-right: 5px; color: gray"
></i>
<span>
{{ ellipsis(item.songName, 18) }}
</span>
<span
style="color: #0f1c69; font-size: 13px; padding-left: 6px"
>
{{ item.sourceName }}
</span>
</van-col>
</van-row>
<van-row style="margin-top: 4px">
<van-col style="color: gray; font-size: 10px">
{{ item.artist }} / {{ ellipsis(item.album, 20) }} /
{{ item.duration }}
</van-col>
</van-row>
</van-col>
<van-col span="1" style="line-height: 32px; color: red">
<i v-show="currentSongIndex == i" class="bi bi-soundwave"></i>
</van-col>
<van-col span="1">
<van-col
span="2"
style="float: right; color: gray; line-height: 32px"
>
<van-popover
v-model:show="showPopover[i]"
:actions="[
{
text: '上传到云盘',
icon: 'upgrade',
action: ActionUpload,
songIndex: i,
},
{
text: '下载到浏览器本地',
icon: 'down',
action: ActionDownload,
songIndex: i,
},
{
text: '下载到服务器本地',
icon: 'down',
action: ActionDownloadToLocalService,
songIndex: i,
},
{
text: '打开源站',
icon: 'share',
action: ActionOpenRef,
songIndex: i,
},
]"
placement="left"
overlay
overlay-class="popover-overlay"
@select="onSelect"
>
<template #reference>
<i class="bi bi-three-dots-vertical"></i>
</template>
</van-popover>
</van-col>
</van-col>
</van-row>
</van-col>
</van-row>
</van-col>
</div>
</template>
<script>
import { ref } from "vue";
import {
createSyncSongFromUrlJob,
getSongsMeta,
createDownloadSongFromUrlJob,
} from "../api";
import { startTaskListener } from "./TaskNotificationForMobile";
import storage from "../utils/storage";
import { ellipsis } from "../utils";
const ActionUpload = 0;
const ActionDownload = 1;
const ActionOpenRef = 2;
const ActionDownloadToLocalService = 3;
export default {
data() {
return {
currentSongIndex: -1,
wyAccount: null,
};
},
props: {
playTheSong: {
type: Function,
required: true,
},
suggestMatchSongId: {
type: String,
required: false,
},
searchResult: {
type: Array,
required: true,
},
},
created() {
this.ActionUpload = ActionUpload;
this.ActionDownload = ActionDownload;
this.ActionOpenRef = ActionOpenRef;
this.ActionDownloadToLocalService = ActionDownloadToLocalService;
},
mounted() {
this.wyAccount = storage.get("wyAccount");
},
setup(props, { emit }) {
const playTheSong = (songMeta, pageUrl, suggestMatchSongId) => {
props.playTheSong(songMeta, pageUrl, suggestMatchSongId);
};
const showPopover = ref([]);
return {
playTheSong,
showPopover,
ellipsis,
};
},
watch: {
$route(to, from) {
this.wyAccount = storage.get("wyAccount");
},
searchResult: {
handler(val) {
this.currentSongIndex = -1;
},
deep: true,
},
},
methods: {
async uploadToCloud(pageUrl) {
const ret = await createSyncSongFromUrlJob(
pageUrl,
this.suggestMatchSongId
);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async downloadToLocalService(pageUrl) {
const ret = await createDownloadSongFromUrlJob(
pageUrl,
this.suggestMatchSongId
);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
play(songMeta, pageUrl, index) {
if (this.currentSongIndex === index) {
return;
}
this.currentSongIndex = index;
this.playTheSong(songMeta, pageUrl, this.suggestMatchSongId);
},
async onSelect(actionItem) {
const currentSong = this.searchResult[actionItem.songIndex];
console.log(currentSong);
switch (actionItem.action) {
case ActionUpload:
this.uploadToCloud(currentSong.url);
break;
case ActionDownloadToLocalService:
this.downloadToLocalService(currentSong.url);
break;
case ActionDownload:
const ret = await getSongsMeta({ url: currentSong.url });
const info = ret.data.songMeta;
console.log(ret);
const a = document.createElement("a");
a.href = info.audios[0].url;
a.download = `${currentSong.songName}-${currentSong.artist}.mp3`;
a.style.display = "none";
document.body.appendChild(a);
a.click();
a.remove();
break;
case ActionOpenRef:
window.open(currentSong.url, "_blank").focus();
break;
}
},
},
};
</script>

View File

@@ -0,0 +1,328 @@
<template>
<el-table
:data="searchResult"
:stripe="true"
class="search-result-table"
:header-cell-style="{
background: '#f5f7fa',
color: '#606266',
fontWeight: 'bold',
fontSize: '14px',
height: '50px',
}"
:row-style="{ height: '60px' }"
>
<el-table-column type="index" width="60" align="center" />
<el-table-column label="歌曲" min-width="300" prop="songName">
<template #default="scope">
<div class="song-name-cell">
<el-tooltip
v-if="scope.row.resourceForbidden"
content="可能无法播放 / 试听版本"
placement="top"
>
<i class="bi bi-lock-fill lock-icon"></i>
</el-tooltip>
<span class="song-name">{{ scope.row.songName }}</span>
</div>
</template>
</el-table-column>
<el-table-column
prop="artist"
label="歌手"
min-width="120"
align="center"
/>
<el-table-column prop="album" label="专辑" min-width="200" align="center" />
<el-table-column
prop="duration"
label="时长"
min-width="100"
align="center"
/>
<el-table-column
prop="sourceName"
label="来源"
min-width="120"
align="center"
/>
<el-table-column label="操作" min-width="200" fixed="right" align="center">
<template #default="scope">
<div class="operation-cell">
<div class="operation-buttons">
<el-tooltip
content="停止播放"
placement="top"
v-if="scope.row.url == currentSongUrl"
>
<el-button
@click="abort()"
type="primary"
circle
class="operation-btn"
>
<i class="bi bi-stop-circle"></i>
</el-button>
</el-tooltip>
<el-tooltip content="播放歌曲" placement="top" v-else>
<el-button
@click="play(null, scope.row.url)"
type="primary"
circle
:disabled="scope.row.url.indexOf('youtube') >= 0"
class="operation-btn"
>
<i class="bi bi-play-circle"></i>
</el-button>
</el-tooltip>
<el-tooltip
:content="
wyAccount
? '上传歌曲到云盘'
: '上传歌曲到云盘(请先绑定网易云账号)'
"
placement="top"
>
<el-button
type="success"
circle
@click="uploadToCloud(scope.row.url)"
:disabled="!wyAccount"
class="operation-btn"
>
<i class="bi bi-cloud-upload"></i>
</el-button>
</el-tooltip>
<el-tooltip
:content="
globalConfig.downloadPathExisted
? '下载到服务器'
: '下载到服务器(请先配置下载路径)'
"
placement="top"
>
<el-button
type="primary"
circle
@click="downloadToLocalService(scope.row.url)"
:disabled="!globalConfig.downloadPathExisted"
class="operation-btn"
>
<i class="bi bi-download"></i>
</el-button>
</el-tooltip>
<el-tooltip content="在源站查看" placement="top">
<el-button
type="warning"
circle
class="operation-btn"
@click="openSourceUrl(scope.row.url)"
>
<i class="bi bi-box-arrow-up-right"></i>
</el-button>
</el-tooltip>
</div>
</div>
</template>
</el-table-column>
</el-table>
</template>
<style scoped>
.search-result-table {
margin-top: 20px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
.song-name-cell {
display: flex;
align-items: center;
padding-left: 10px;
}
.lock-icon {
font-size: 16px;
color: #909399;
margin-right: 8px;
}
.song-name {
font-size: 14px;
color: #303133;
}
.operation-cell {
position: relative;
height: 100%;
}
.operation-buttons {
display: flex;
justify-content: center;
gap: 8px;
opacity: 0;
transition: opacity 0.3s ease;
position: relative;
z-index: 200;
}
/* 修改hover选择器的写法确保按钮在hover时不会消失 */
:deep(.el-table__row:hover) .operation-buttons,
.operation-buttons:hover {
opacity: 1;
}
.operation-btn {
padding: 6px;
font-size: 16px;
background-color: var(--el-button-bg-color);
}
:deep(.el-table__header) {
width: 100% !important;
}
:deep(.el-table__body) {
width: 100% !important;
}
:deep(.el-table__header-wrapper) {
width: 100% !important;
}
:deep(.el-table__body-wrapper) {
width: 100% !important;
}
</style>
<script>
import {
createSyncSongFromUrlJob,
createDownloadSongFromUrlJob,
getGlobalConfig,
} from "../api";
import { startTaskListener } from "../components/TaskNotification";
import storage from "../utils/storage";
export default {
data() {
return {
currentSongUrl: -1,
wyAccount: null,
globalConfig: {},
};
},
props: {
playTheSong: {
type: Function,
required: true,
},
abortTheSong: {
type: Function,
required: true,
},
suggestMatchSongId: {
type: String,
required: false,
},
searchResult: {
type: Array,
required: true,
},
},
mounted() {
this.wyAccount = storage.get("wyAccount");
this.loadGlobalConfig();
},
setup(props, { emit }) {
const playTheSong = (songMeta, pageUrl, suggestMatchSongId) => {
props.playTheSong(songMeta, pageUrl, suggestMatchSongId);
};
const abortTheSong = () => {
props.abortTheSong();
};
return {
playTheSong,
abortTheSong,
};
},
watch: {
$route(to, from) {
this.wyAccount = storage.get("wyAccount");
if (to.path === "/" || to.path === "/home" || to.path === "") {
this.loadGlobalConfig();
}
},
},
methods: {
async uploadToCloud(pageUrl) {
const ret = await createSyncSongFromUrlJob(
pageUrl,
this.suggestMatchSongId
);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async downloadToLocalService(pageUrl) {
const ret = await createDownloadSongFromUrlJob(
pageUrl,
this.suggestMatchSongId
);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async loadGlobalConfig() {
const globalConfig = await getGlobalConfig();
if (globalConfig !== false && globalConfig.data) {
this.globalConfig = globalConfig.data;
}
},
play(songMeta, pageUrl) {
this.currentSongUrl = pageUrl;
this.playTheSong(songMeta, pageUrl, this.suggestMatchSongId);
},
abort() {
this.currentSongUrl = -1;
this.abortTheSong();
},
openSourceUrl(url) {
try {
if (typeof window !== "undefined" && window?.open) {
window.open(url, "_blank", "noopener,noreferrer");
} else {
// 降级方案
const link = document.createElement("a");
link.href = url;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
} catch (error) {
console.error("Failed to open URL:", error);
// 可以添加用户提示
ElMessage.error("打开链接失败,请尝试复制链接手动打开");
}
},
},
};
</script>

View File

@@ -0,0 +1,53 @@
import { ElNotification } from 'element-plus'
import { getJobDetail } from "../api";
export function startTaskListener(jobID) {
let lastTip = '';
const task = (jobID) => {
getJobDetail(jobID).then(res => {
const status = res.data.jobs.status;
if (status === '已完成' || status === '失败') {
clearInterval(interval)
}
if (lastTip == res.data.jobs.tip) {
return;
};
lastTip = res.data.jobs.tip;
let title;
let type;
let duration = 4500;
if (status === '已完成') {
title = '任务完成';
type = 'success';
duration = 6000;
} else if (status === '失败') {
title = '任务失败';
type = 'error';
} else {
title = '任务进度提示';
type = 'info';
duration = 2500;
}
if (lastTip == "") {
duration = 4500;
}
ElNotification({
title,
message: "<strong>" + res.data.jobs.name + "</strong><br>" + "<strong>" + res.data.jobs.desc + "</strong><br>" + res.data.jobs.tip,
dangerouslyUseHTMLString: true,
type,
duration,
});
})
};
task(jobID);
const interval = setInterval(() => {
task(jobID);
}, 1000)
}

View File

@@ -0,0 +1,50 @@
import { getJobDetail } from "../api";
import { Notify } from 'vant';
export function startTaskListener(jobID) {
let lastTip = '';
const task = (jobID) => {
getJobDetail(jobID).then(res => {
const status = res.data.jobs.status;
if (status === '已完成' || status === '失败') {
clearInterval(interval)
}
if (lastTip == res.data.jobs.tip) {
return;
};
lastTip = res.data.jobs.tip;
let title;
let type;
let duration = 4500;
if (status === '已完成') {
type = 'success';
duration = 6000;
} else if (status === '失败') {
type = 'danger';
} else {
title = '任务进度提示';
type = 'primary';
duration = 2500;
}
if (lastTip == "") {
duration = 4500;
}
const options = {
message: `${res.data.jobs.tip}\n${res.data.jobs.name}\n${res.data.jobs.desc}`,
type,
duration,
};
Notify(options);
})
};
task(jobID);
const interval = setInterval(() => {
task(jobID);
}, 1000)
}

11
frontend/src/main.js Normal file
View File

@@ -0,0 +1,11 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import ElementPlusLocaleZhCn from 'element-plus/lib/locale/lang/zh-cn'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.use(ElementPlus, { locale: ElementPlusLocaleZhCn })
app.mount('#app')

18
frontend/src/mobile.js Normal file
View File

@@ -0,0 +1,18 @@
import vant from 'vant';
import 'vant/lib/index.css';
import VueVirtualScroller from 'vue-virtual-scroller'
import { createApp } from 'vue'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import { useRegisterSW } from 'virtual:pwa-register/vue';
import App from './Mobile.vue'
import router from './router/mobile'
useRegisterSW();
const app = createApp(App)
app.use(router)
app.use(vant)
app.use(VueVirtualScroller)
app.mount('#app')

View File

@@ -0,0 +1,50 @@
import { createRouter, createWebHistory, createWebHashHistory } from "vue-router"
import storage from "../utils/storage"
const PathPlaylist = '/playlist';
const PathSetting = '/setting';
const routes = [
{
path: '/',
component: () => import('../views/pc/Home.vue')
},
{
path: '/account',
name: "Account",
component: () => import('../views/pc/Account.vue')
},
{
path: PathPlaylist,
name: "Playlist",
component: () => import('../views/pc/Playlist.vue')
},
{
path: PathSetting,
name: "Setting",
component: () => import('../views/pc/Setting.vue')
},
]
export const router = createRouter({
history: createWebHashHistory(),
routes: routes
})
router.beforeEach((to, from, next) => {
if (to.path === "/account") {
next();
return;
}
const mk = storage.get('mk')
const wyAccount = storage.get('wyAccount')
if (!mk) {
next("/account");
}
if ([PathPlaylist].includes(to.path) && !wyAccount) {
next("/account");
return;
}
next();
});
export default router

View File

@@ -0,0 +1,42 @@
import { createRouter, createWebHistory, createWebHashHistory } from "vue-router"
import storage from "../utils/storage"
const routes = [
{
path: '/',
component: () => import('../views/mobile/Home.vue')
},
{
path: '/account',
name: "Account",
component: () => import('../views/mobile/Account.vue')
},
{
path: '/playlist',
name: "Playlist",
component: () => import('../views/mobile/Playlist.vue')
},
]
export const router = createRouter({
history: createWebHashHistory(),
routes: routes
})
router.beforeEach((to, from, next) => {
if (to.path === "/account") {
next();
return;
}
const mk = storage.get('mk')
const wyAccount = storage.get('wyAccount')
if (!mk) {
next("/account");
}
if (to.path === "/playlist" && !wyAccount) {
next("/account");
return;
}
next();
});
export default router

View File

@@ -0,0 +1,22 @@
/**
* Get the proper play URL based on source
* @param {string} source - The source platform (e.g., 'bilibili', 'netease')
* @param {string} url - The original audio URL
* @param {string} referer - The referer URL
* @returns {string} The processed play URL
*/
export function getProperPlayUrl(source, url, referer) {
console.log("--------getProperPlayUrl----------------");
console.log(source);
console.log(url);
console.log(referer);
if (source === "bilibili") {
const params = new URLSearchParams({
url: url,
source: 'bilibili',
referer: referer
});
return `${import.meta.env.VITE_APP_API_URL}/proxy/audio?${params}`;
}
return url;
}

View File

@@ -0,0 +1,46 @@
export function secondDurationToDisplayDuration(secondDuration, allowZero = false) {
if (!secondDuration) {
return allowZero ? "00:00" : " - ";
}
secondDuration = parseInt(secondDuration);
let duration = secondDuration;
let minute = Math.floor(duration / 60);
let second = duration % 60;
if (minute < 10) {
minute = "0" + minute;
}
if (second < 10) {
second = "0" + second;
}
return minute + ":" + second;
}
export function sourceCodeToName(source) {
return {
"qq": "QQ音乐",
"xiami": "虾米音乐",
"netease": "网易云音乐",
"kugou": "酷狗音乐",
"kuwo": "酷我音乐",
"migu": "咪咕音乐",
"bilibili": "Bilibili",
"douyin": "抖音",
"youtube": "YouTube",
}[source] || "未知";
}
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function ellipsis(value, maxLength) {
if (!value) {
return "";
}
value = value.trim();
if (!value) return "";
if (value.length > maxLength) {
return value.slice(0, maxLength) + "...";
}
return value;
}

24
frontend/src/utils/pwa.js Normal file
View File

@@ -0,0 +1,24 @@
let request;
let isInstallable = false;
// The file is not useful now.
window.addEventListener('beforeinstallprompt', (r) => {
console.log('beforeinstallprompt')
// Prevent Chrome 67 and earlier from automatically showing the prompt
r.preventDefault()
request = r
isInstallable = true;
});
export async function installPWA() {
if (request) {
console.log('start install')
let installResponse = await request.prompt();
console.info({installResponse});
return installResponse.outcome === 'accepted';
} else {
console.log('The request is not available');
return false;
}
}

View File

@@ -0,0 +1,20 @@
export default {
set (key, val) {
if (typeof val === 'object') {
val = JSON.stringify(val)
}
window.localStorage.setItem(key, val)
},
get (key) {
let data = window.localStorage.getItem(key)
try {
data = JSON.parse(data);
return data;
} catch {
return data;
}
},
del (key) {
window.localStorage.removeItem(key)
},
}

View File

@@ -0,0 +1,235 @@
<template>
<div>
<van-row v-if="!registedMK">
<van-col style="margin-top: 100px">
<h2 style="text-align: center; padding: 0 30px">
填写你的 Melody Key 就可以开始使用啦 😘
</h2>
<van-row>
<van-col :span="20" :offset="2">
<van-field
v-model="mk"
size="large"
maxlength="32"
required
placeholder="请输入。初始管理员的默认 key 为melody"
autofocus
clearable
@keyup.enter="checkMK"
></van-field>
</van-col>
</van-row>
<van-row>
<van-col :span="8" :offset="8" style="margin-top: 20px">
<van-button
round
type="success"
@click="checkMK"
:loading="MKChecking"
loading-text="校验中..."
>
开始使用
</van-button>
</van-col>
</van-row>
</van-col>
</van-row>
<van-row v-if="account.uid">
<van-col span="24">
<van-row style="margin-top: 10px">
<van-col :span="16" :offset="3" style="text-align: left">
当前 Melody Key: {{ account.uid }}
</van-col>
<van-col :span="4">
<van-button round type="warning" size="mini" @click="logoutMK"
>退出</van-button
>
</van-col>
</van-row>
<!-- <van-row style="margin-top: 10px">
<van-col :span="8" :offset="16" style="text-align: left">
<van-button @click="triggerInstallPWA">安装到桌面</van-button>
</van-col>
</van-row> -->
<van-row>
<van-col :offset="3" style="margin-top: 50px">
<h3>网易云账号信息</h3>
</van-col>
</van-row>
<van-row style="">
<van-image
round
:src="account.wyAccount.avatarUrl"
style="width: 80px; height: 80px; margin-left: calc(50% - 40px)"
/>
</van-row>
<van-row style="text-align: center; margin-top: 10px">
<van-col :span="24" v-if="account.wyAccount"
>{{ account.wyAccount.nickname }}已绑定</van-col
>
<span v-if="!account.wyAccount"> 请先绑定正确的网易云账号 </span>
</van-row>
<van-row style="text-align: left">
<van-col :offset="3" style="margin-top: 50px">
<van-radio-group
checked-color="#07c160"
v-model="account.loginType"
direction="horizontal"
>
<van-radio name="qrcode">扫码登录</van-radio>
<van-radio name="phone">手机号登录</van-radio>
<van-radio name="email">邮箱登录</van-radio>
</van-radio-group>
</van-col>
</van-row>
<van-row style="margin-top: 20px">
<van-col :offset="3">
<p v-if="account.loginType != 'qrcode'">
<van-field
v-if="account.loginType == 'phone'"
label="国际电话区号"
type="digit"
v-model="account.countryCode"
placeholder="默认86不需要输入 +"
maxlength="4"
></van-field>
<van-field label="账号" v-model="account.account"></van-field>
<van-field
label="密码"
v-model="account.password"
type="password"
></van-field>
</p>
<p v-else>扫码仅支持在 PC 端操作</p>
</van-col>
</van-row>
<van-row v-if="account.loginType != 'qrcode'">
<van-col :offset="8" style="margin-top: 20px">
<van-button round type="success" @click="updateAccount">
更新账号密码
</van-button>
</van-col>
</van-row>
</van-col>
</van-row>
</div>
</template>
<script>
import { getAccount, setAccount } from "../../api";
import storage from "../../utils/storage";
// import { installPWA } from "../../utils/pwa";
import { Notify } from "vant";
export default {
data: () => {
return {
mk: "",
account: {},
registedMK: false,
MKChecking: false,
};
},
async mounted() {
this.registedMK = storage.get("mk") ? true : false;
if (!this.registedMK) {
return;
}
console.log(this.registedMK);
const ret = await getAccount();
if (ret === false || !ret.data) {
this.registedMK = false;
return;
}
this.account = ret.data.account;
storage.set("wyAccount", ret.data.account.wyAccount);
console.log(this.account);
},
methods: {
// async triggerInstallPWA() {
// await installPWA();
// },
async checkMK() {
this.MKChecking = true;
this.mk = this.mk.trim();
if (!this.mk) {
this.MKChecking = false;
return;
}
const ret = await getAccount({ mk: this.mk });
if (ret !== false && ret.data) {
this.account = ret.data.account;
this.registedMK = true;
storage.set("mk", this.mk);
storage.set("wyAccount", ret.data.account.wyAccount);
this.MKChecking = false;
Notify({ type: "success", message: "Melody Key 设置成功" });
} else {
this.MKChecking = false;
Notify({ type: "warning", message: "Melody Key 不正确哦" });
}
},
async updateAccount() {
if (
!this.account.account ||
!this.account.password ||
!this.account.loginType
) {
return;
}
if (this.account.loginType == "phone") {
if (this.account.countryCode) {
if (!/^[\d]{0,4}$/.test(this.account.countryCode)) {
ElMessage({
center: true,
type: "error",
message: "国际电话区号不正确",
});
return;
}
}
}
if (this.account.loginType == "email") {
if (
!/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/.test(
this.account.account
)
) {
ElMessage({
center: true,
type: "error",
message: "邮箱格式不正确",
});
return;
}
}
const ret = await setAccount({
loginType: this.account.loginType,
countryCode: this.account.countryCode,
account: this.account.account,
password: this.account.password,
});
if (ret.data.account) {
this.account = ret.data.account;
storage.set("wyAccount", ret.data.account.wyAccount);
}
},
logoutMK() {
storage.del("mk");
this.registedMK = false;
this.mk = "";
this.account = {};
},
},
};
</script>

View File

@@ -0,0 +1,502 @@
<template>
<div style="margin-top: 20px">
<van-row justify="space-between" v-if="searchResult.length !== 0">
<van-col span="24">
<van-search
v-model="keyword"
shape="round"
show-action
@search="onSearch"
placeholder="网页链接 / 歌名"
input-align="center"
>
<template #action>
<div @click="onSearch">搜索</div>
</template>
</van-search>
</van-col>
</van-row>
<van-loading
v-show="searchResult.length !== 0 && isSearching"
type="spinner"
/>
<div v-if="searchResult.length === 0">
<van-row style="margin-top: 150px">
<van-col span="24">
<van-search
v-model="keyword"
shape="round"
@search="onSearch"
placeholder="网页链接 / 歌名"
input-align="center"
>
</van-search>
</van-col>
</van-row>
<van-row style="margin-top: 19px">
<van-col span="24">
<van-button
round
color="#07c160"
icon="search"
type="primary"
@click="onSearch"
:loading="isSearching"
loading-text="搜索中"
style="height: 32px"
>
</van-button>
</van-col>
</van-row>
</div>
<!-- 精准搜索卡片 -->
<van-row
v-if="songMetaInfo !== null"
style="padding: 0 8px"
@click="playTheSong(songMetaInfo)"
>
<van-col span="6">
<img
:src="songMetaInfo.coverUrl"
onerror="this.src='https://cdnmusic.migu.cn/v3/static/img/common/default/img_default_240x240.jpg'"
style="width: 100%; height: 100%"
/>
</van-col>
<van-col span="18">
<div
style="
position: relative;
z-index: 2;
overflow: hidden;
width: 100%;
height: 100%;
"
>
<div
style="
width: 100%;
height: 100%;
position: absolute;
top: 0;
color: white;
margin-top: 10px;
padding-left: 18px;
"
>
<van-row style="font-size: 14px; text-align: left">
{{ ellipsis(songMetaInfo.songName, 16) }}
</van-row>
<van-row>
<van-col>
<van-row
style="margin-top: 6px; font-size: 10px; text-align: left"
>
{{ songMetaInfo.artist }} {{ songMetaInfo.album }}
</van-row>
<van-row
style="margin-top: 6px; font-size: 10px; text-align: left"
>
时长 {{ songMetaInfo.duration }}
</van-row>
</van-col>
</van-row>
</div>
<div
style="
position: absolute;
filter: blur(32px);
transform: scale(1.2);
top: 0;
z-index: -1;
width: 100%;
height: 100%;
"
>
<img
:src="songMetaInfo.coverUrl"
onerror="this.src='https://cdnmusic.migu.cn/v3/static/img/common/default/img_default_240x240.jpg'"
style="width: 100%; height: 100%"
/>
</div>
</div>
</van-col>
</van-row>
<van-row style="margin-top: 10px">
<SearchResultList
:playTheSong="playTheSong"
:suggestMatchSongId="suggestMatchSongId"
:searchResult="searchResult"
>
</SearchResultList>
</van-row>
</div>
</template>
<script>
import { searchSongs, getSongsMeta, createSyncSongFromUrlJob } from "../../api";
import SearchResultList from "../../components/SearchResultListForMobile.vue";
import {
secondDurationToDisplayDuration,
sourceCodeToName,
ellipsis,
} from "../../utils";
import { startTaskListener } from "../../components/TaskNotification";
import storage from "../../utils/storage";
import { getProperPlayUrl } from "../../utils/audio";
export default {
data: () => {
return {
suggestMatchSongId: "",
songMetaInfo: null,
// songMetaInfo: {
// songName: "搁浅",
// artist: "单依纯",
// album: "专辑",
// duration: "03:06",
// coverUrl:
// "http://d.musicapp.migu.cn/prod/file-service/file-down/8121e8df41a5c12f48b69aea89b71dab/ba75041e9311e62d10b6fc32d11d84aa/1c51d605f00caeb4643414c6eb3f5fbe",
// url: "https://www.kuwo.cn/play_detail/157612752",
// resourceForbidden: false,
// source: "kuwo",
// fromMusicPlatform: true,
// score: 173.50300000000001,
// sourceName: "酷我音乐",
// },
playUrl: "",
keyword: "",
searchTip: "",
isSearching: false,
searchResult: [],
// searchResult: [
// {
// songName: "搁浅(中国新歌声)",
// artist: "羽田",
// album: "《中国新歌声第十一期》",
// duration: " - ",
// url: "https://music.migu.cn/v3/music/song/6404689Z0BD",
// resourceForbidden: false,
// source: "migu",
// fromMusicPlatform: true,
// score: 651.854,
// sourceName: "咪咕音乐",
// },
// {
// songName: "搁浅",
// artist: "周杰伦",
// album: "《七里香》",
// duration: " - ",
// url: "https://music.migu.cn/v3/music/song/60054701938",
// resourceForbidden: false,
// source: "migu",
// fromMusicPlatform: true,
// score: 640.803,
// sourceName: "咪咕音乐",
// },
// {
// songName: "搁浅 (Live)",
// artist: "杨丞琳",
// album: "《蒙面唱将猜猜猜第五期》",
// duration: " - ",
// url: "https://music.migu.cn/v3/music/song/64046801877",
// resourceForbidden: false,
// source: "migu",
// fromMusicPlatform: true,
// score: 583.073,
// sourceName: "咪咕音乐",
// },
// {
// songName: "搁浅",
// artist: "刘大壮",
// album: " - ",
// duration: "00:14",
// url: "https://www.kuwo.cn/play_detail/97836012",
// resourceForbidden: false,
// source: "kuwo",
// fromMusicPlatform: true,
// score: 173.50300000000001,
// sourceName: "酷我音乐",
// },
// {
// songName: "搁浅",
// artist: "周杰伦",
// album: " - ",
// duration: "03:38",
// url: "https://www.kuwo.cn/play_detail/171708289",
// resourceForbidden: false,
// source: "kuwo",
// fromMusicPlatform: true,
// score: 173.50300000000001,
// sourceName: "酷我音乐",
// },
// {
// songName: "搁浅",
// artist: "单依纯",
// album: " - ",
// duration: "03:06",
// url: "https://www.kuwo.cn/play_detail/157612752",
// resourceForbidden: false,
// source: "kuwo",
// fromMusicPlatform: true,
// score: 173.50300000000001,
// sourceName: "酷我音乐",
// },
// {
// songName: "【1080P修复版】周杰伦 - 搁浅MV",
// artist: "zyl2012",
// album: " - ",
// duration: "04:25",
// url: "https://www.bilibili.com/video/BV1M4411P7gM",
// resourceForbidden: false,
// source: "bilibili",
// fromMusicPlatform: false,
// score: 145.13400000000001,
// sourceName: "Bilibili",
// },
// {
// songName: "搁浅",
// artist: "张杰",
// album: "《搁浅》",
// duration: "04:34",
// url: "https://music.163.com/#/song?id=190964",
// resourceForbidden: false,
// source: "netease",
// fromMusicPlatform: true,
// score: 70.99400000000001,
// sourceName: "网易云音乐",
// },
// {
// songName: "搁浅",
// artist: "文太Vent.T",
// album: "《Losing Boat》",
// duration: "04:03",
// url: "https://music.163.com/#/song?id=523239317",
// resourceForbidden: false,
// source: "netease",
// fromMusicPlatform: true,
// score: 57.970000000000006,
// sourceName: "网易云音乐",
// },
// {
// songName: "搁浅",
// artist: "周杰伦",
// album: "《七里香》",
// duration: "04:00",
// url: "https://www.kugou.com/song/#hash=fbc234520fed713c30c1c026e7352770&album_id=971783",
// resourceForbidden: true,
// source: "kugou",
// fromMusicPlatform: true,
// score: 23.092,
// sourceName: "酷狗音乐",
// },
// {
// songName: "搁浅",
// artist: "周杰伦",
// album: "《七里香》",
// duration: "04:00",
// url: "https://y.qq.com/n/ryqq/songDetail/001Bbywq2gicae",
// resourceForbidden: true,
// source: "qq",
// fromMusicPlatform: true,
// score: 23.092,
// sourceName: "QQ音乐",
// },
// {
// songName: "周姐查房Npc点播翻唱《搁浅》 被惊艳到赞不绝口",
// artist: "周姐日常事",
// album: " - ",
// duration: "04:41",
// url: "https://www.bilibili.com/video/BV1uq4y157Fb",
// resourceForbidden: false,
// source: "bilibili",
// fromMusicPlatform: false,
// score: 12.318,
// sourceName: "Bilibili",
// },
// {
// songName:
// "4K60P丨《搁浅》有多难唱听听未修音的周董唱得怎么样周杰伦.2004无与伦比演唱会",
// artist: "诶呦葛格",
// album: " - ",
// duration: "04:24",
// url: "https://www.bilibili.com/video/BV1KA411H78W",
// resourceForbidden: false,
// source: "bilibili",
// fromMusicPlatform: false,
// score: 11.714,
// sourceName: "Bilibili",
// },
// {
// songName:
// '"明明看透了还深陷其中,真的很可怜"#周杰伦 《#搁浅 》#无损音乐 #周杰伦音乐 #音乐推荐 #jay #七里香周杰伦 ',
// artist: "周杰伦F.M首播",
// album: " - ",
// duration: "03:55",
// url: "https://www.douyin.com/video/7082032090337922334",
// resourceForbidden: false,
// source: "douyin",
// fromMusicPlatform: false,
// score: 11.238,
// sourceName: "抖音",
// },
// {
// songName: "搁浅 (Live)",
// artist: "杨丞琳",
// album: "《蒙面唱将猜猜猜 第五期》",
// duration: "03:51",
// url: "https://www.kugou.com/song/#hash=b0f400f85edea59951dbedff35d6fbb9&album_id=1796966",
// resourceForbidden: false,
// source: "kugou",
// fromMusicPlatform: true,
// score: 4.767,
// sourceName: "酷狗音乐",
// },
// {
// songName: "搁浅 (Live)",
// artist: "周杰伦",
// album: "《周杰伦 2004 无与伦比 演唱会 Live CD》",
// duration: "04:21",
// url: "https://y.qq.com/n/ryqq/songDetail/001d94K71ipdTB",
// resourceForbidden: false,
// source: "qq",
// fromMusicPlatform: true,
// score: 4.767,
// sourceName: "QQ音乐",
// },
// {
// songName: "搁浅 (Live)",
// artist: "杨丞琳",
// album: "《蒙面唱将猜猜猜 第5期》",
// duration: "03:51",
// url: "https://y.qq.com/n/ryqq/songDetail/001Gn3RQ0IDwEK",
// resourceForbidden: false,
// source: "qq",
// fromMusicPlatform: true,
// score: 4.767,
// sourceName: "QQ音乐",
// },
// {
// songName: "搁浅(抖音原版)",
// artist: "王梦露",
// album: "《爱恋之音》",
// duration: "02:14",
// url: "https://music.163.com/#/song?id=1831481912",
// resourceForbidden: false,
// source: "netease",
// fromMusicPlatform: true,
// score: 4.152,
// sourceName: "网易云音乐",
// },
// {
// songName: "搁浅 (Live)",
// artist: "曹杨",
// album: "《2020中国好声音 第7期》",
// duration: "03:50",
// url: "https://www.kugou.com/song/#hash=feeaa10cefb9b03d6a7d2a92d9db5b04&album_id=39445387",
// resourceForbidden: true,
// source: "kugou",
// fromMusicPlatform: true,
// score: -30.773999999999997,
// sourceName: "酷狗音乐",
// },
// ],
wyAccount: null,
};
},
props: {
playTheSong: {
type: Function,
required: true,
},
},
mounted() {
this.wyAccount = storage.get("wyAccount");
},
watch: {
$route(to, from) {
this.wyAccount = storage.get("wyAccount");
},
},
setup(props, { emit }) {
const playTheSong = (songMeta, pageUrl) => {
props.playTheSong(songMeta, pageUrl);
};
return {
playTheSong,
ellipsis,
};
},
components: {
SearchResultList,
},
methods: {
async uploadToCloud(pageUrl) {
const ret = await createSyncSongFromUrlJob(pageUrl); // TODO: add songID
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async onSearch() {
if (this.keyword.trim().length === 0) {
return;
}
this.songMetaInfo = null;
this.searchTip = `正在搜索 ${this.keyword}`;
this.isSearching = true;
if (
this.keyword.indexOf("163.com") >= 0 &&
this.keyword.indexOf("/song") >= 0
) {
const songIdMatch = this.keyword.match(/id=([\d]+)/);
if (songIdMatch && songIdMatch.length > 1) {
this.suggestMatchSongId = songIdMatch[1];
}
}
try {
if (this.keyword.indexOf("http") >= 0) {
getSongsMeta({ url: this.keyword }).then((ret) => {
const info = ret.data.songMeta;
if (info) {
info.album = info.album != "" ? info.album : "未知";
info.duration = secondDurationToDisplayDuration(info.duration);
info.sourceName = sourceCodeToName(info.source);
this.songMetaInfo = info;
} else {
this.songMetaInfo = null;
}
});
}
const result = await searchSongs({ keyword: this.keyword });
console.log(result);
const songs = result.data.songs
.map((song) => {
song.album = song.album != "" ? `${song.album}` : " - ";
song.duration = secondDurationToDisplayDuration(song.duration);
song.sourceName = sourceCodeToName(song.source);
return song;
})
.filter((song) => song.songName.length > 0);
console.log(JSON.stringify(songs));
this.searchResult = songs;
this.searchTip = "";
} catch (e) {
this.searchTip = "搜索失败";
} finally {
this.isSearching = false;
}
},
},
};
</script>

View File

@@ -0,0 +1,422 @@
<template>
<div>
<van-popup
v-model:show="showPopup"
round
position="top"
closeable
:lock-scroll="false"
safe-area-inset-top
:style="{ height: 'calc(100% - 280px)' }"
@close="closeThePopup"
>
<div v-if="searchResult.length === 0" style="height: 300px">
<van-loading style="padding-top: 230px" size="24px" vertical
>拼命搜索中...</van-loading
>
</div>
<van-row style="margin-top: 30px" v-if="searchResult.length > 0">
<SearchResultList
:playTheSong="playTheSong"
:suggestMatchSongId="suggestMatchSongId"
:searchResult="searchResult"
>
</SearchResultList>
</van-row>
</van-popup>
<van-tabs
v-model:active="active"
sticky
@rendered="onRendered"
@change="onTabChange"
>
<van-tab v-for="(item, i) in playlists" :key="i">
<template #title>
<van-image round width="40" height="40" :src="item.cover" />
<div style="width: 90px; height: 60px">
{{ item.name }}
</div>
</template>
<div v-if="!playlistDetails[i]" style="height: 300px">
<van-loading style="padding-top: 130px" size="24px" vertical
>歌单拼命加载中...</van-loading
>
</div>
<div v-if="playlistDetails[i]" style="width: 100%">
<van-col
span="24"
style="margin-top: 3px; color: gray; font-size: 10px"
>
<van-divider style="margin: 5px 0">
全部{{ playlistDetails[i].songs.length }} | 待解锁{{
playlistDetails[i].songs.filter((song) => song.isBlocked).length
}}
</van-divider>
</van-col>
<van-row>
<van-col span="10">
<van-button
type="success"
round
size="small"
@click="unblockThePlaylist(playlistDetails[i].id)"
style="height: 85%"
>
<span style="font-size: 10px">解锁全部</span>
</van-button>
<span
style="
font-size: 10px;
color: grey;
position: relative;
top: 5px;
"
>
(实验性功能)
</span>
</van-col>
<van-col span="10" style="position: relative; top: 5px">
<label for="switch-showBlockSongsOnly" style="font-size: 11px"
>仅展示待解锁
</label>
<van-switch
id="switch-showBlockSongsOnly"
v-model="showBlockSongsOnly"
size="10px"
active-color="#07c160"
inactive-color="#d7d7d7"
style="position: relative; top: 2px"
/>
</van-col>
<van-col span="3">
<span
style="
color: #483d8b;
font-size: 11px;
position: relative;
top: 5px;
"
@click="refreshThePlaylist(i)"
>
刷新
</span>
</van-col>
</van-row>
<van-col span="24" style="margin-top: 18px">
<RecycleScroller
style="height: 100%"
:items="
playlistDetails[i].songs.filter((item) => {
if (showBlockSongsOnly) {
return item.isBlocked;
}
return true;
})
"
:item-size="50"
key-field="songId"
v-slot="{ item, index: j }"
>
<van-col span="23" offset="1">
<van-row>
<van-col span="19">
<van-row
@click="
internalPlayTheSongWithPlayUrl(
{
songId: item.songId,
playUrl: item.playUrl,
coverUrl: item.cover,
songName: item.songName,
pageUrl: item.pageUrl,
artist: item.artists[0],
isBlocked: item.isBlocked,
},
i,
j
)
"
>
<van-col style="font-size: 16px">
<span>
{{ ellipsis(item.songName, 18) }}
</span>
<span
style="
color: #0f1c69;
font-size: 13px;
padding-left: 6px;
"
>
<i
v-if="item.isBlocked"
class="bi bi-lock-fill"
style="font-size: 13px; color: gray"
></i>
<i
v-else-if="item.isCloud"
class="bi bi-cloud"
style="font-size: 13px"
></i>
</span>
</van-col>
</van-row>
<van-row style="margin-top: 4px">
<van-col style="color: gray; font-size: 10px">
{{ item.artists[0] }} / {{ ellipsis(item.album, 20) }} /
{{ item.duration }}
</van-col>
</van-row>
</van-col>
<van-col span="1" style="line-height: 32px; color: red">
<i
v-show="currentSongUrl == item.pageUrl"
class="bi bi-soundwave"
></i>
</van-col>
<van-col
span="4"
style="float: right; color: gray; line-height: 32px"
>
<div v-if="item.isBlocked">
<span @click="unblockTheSong(item.songId)">
<i
class="bi bi-unlock-fill"
style="font-size: 16px"
></i>
</span>
<!-- <span @click="searchTheSong(item.pageUrl)">
<i class="bi bi-search" style="font-size: 16px"></i>
</span> -->
</div>
</van-col>
</van-row>
</van-col>
</RecycleScroller>
</van-col>
</div>
</van-tab>
</van-tabs>
</div>
</template>
<style>
:root {
--van-tabs-line-height: 110px;
}
.van-overlay {
height: calc(100% - 138px);
}
.van-popup__close-icon--top-right {
position: fixed;
padding: 5px 11px 11px 5px;
width: 10px;
height: 10px;
border-radius: 100%;
background: rgb(255, 255, 255);
}
</style>
<script>
import {
searchSongs,
getAllPlaylist,
getPlaylistDetail,
createSyncSongFromPlaylistJob,
createSyncSongWithSongIdJob,
} from "../../api";
import {
secondDurationToDisplayDuration,
sourceCodeToName,
ellipsis,
} from "../../utils";
import SearchResultList from "../../components/SearchResultListForMobile.vue";
import { startTaskListener } from "../../components/TaskNotificationForMobile";
import { Notify, Dialog } from "vant";
import { ref } from "vue";
import { getProperPlayUrl } from "../../utils/audio";
export default {
data: () => {
return {
currentSongUrl: "",
lastSearch: "",
showBlockSongsOnly: false,
playlists: [],
playlistDetails: [],
searchResult: [],
suggestMatchSongId: "",
};
},
components: {
SearchResultList: SearchResultList,
},
props: {
playTheSongWithPlayUrl: {
type: Function,
required: true,
},
playTheSong: {
type: Function,
required: true,
},
},
setup(props, { emit }) {
const tableRef = ref();
const active = ref(0);
const showPopup = ref(false);
return {
tableRef,
active,
ellipsis,
showPopup,
};
},
async mounted() {
const playlistRet = await getAllPlaylist();
this.playlists = playlistRet.data.playlists;
},
methods: {
async internalPlayTheSongWithPlayUrl(playOption, playlistIndex, songIndex) {
if (playOption.isBlocked) {
this.searchTheSong(playOption.pageUrl);
return;
}
// 处理 playUrl
if (playOption.playUrl) {
playOption.playUrl = getProperPlayUrl(
playOption.source,
playOption.playUrl,
playOption.pageUrl
);
}
if (await this.playTheSongWithPlayUrl(playOption)) {
this.currentSongUrl = playOption.pageUrl;
}
},
async unblockThePlaylist(playlistId) {
Dialog.confirm({
confirmButtonText: "解锁全部",
message:
"【智能解锁全部】是一个实验性功能,会根据歌曲名和歌手尝试寻找最合适的来源,但也可能会有货不对版的情况,请谨慎使用。你也可以点击下边单首歌曲的任意区域进入搜索页面,进行手动解锁",
}).then(async () => {
// on confirm
Notify({
message: "开始解锁歌单",
type: "primary",
duration: 1000,
});
const ret = await createSyncSongFromPlaylistJob(playlistId, {
// TODO 先 hard code后面 mobile 端再做配置
syncWySong: false,
syncNotWySong: true,
});
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
});
},
async refreshThePlaylist(tabIndex) {
Notify({
message: "开始刷新",
type: "primary",
duration: 1000,
});
if (await this.showPlaylistDetail(tabIndex, false)) {
Notify({
message: "歌单刷新成功",
type: "success",
duration: 1000,
});
} else {
Notify({
message: "歌单刷新失败",
type: "error",
duration: 1000,
});
}
},
async unblockTheSong(songId) {
const ret = await createSyncSongWithSongIdJob(songId);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async showPlaylistDetail(tabIndex, useCache = true) {
if (useCache && this.playlistDetails[tabIndex]) {
return;
}
const playlistId = this.playlists[tabIndex].id;
const detailRet = await getPlaylistDetail(playlistId);
const playlists = detailRet.data.playlists;
playlists.songs = playlists.songs.map((song) => {
song.duration = secondDurationToDisplayDuration(song.duration);
return song;
});
this.playlistDetails[tabIndex] = playlists;
return true;
},
async searchTheSong(pageUrl) {
this.showPopup = true;
if (this.lastSearch === pageUrl) {
return;
}
this.searchResult = [];
console.log(pageUrl);
if (pageUrl.indexOf("163.com") >= 0 && pageUrl.indexOf("/song") >= 0) {
const songIdMatch = pageUrl.match(/id=([\d]+)/);
if (songIdMatch && songIdMatch.length > 1) {
this.suggestMatchSongId = songIdMatch[1];
}
}
const result = await searchSongs({ keyword: pageUrl });
console.log(result);
const songs = result.data.songs
.map((song) => {
song.album = song.album != "" ? `${song.album}` : " - ";
song.duration = secondDurationToDisplayDuration(song.duration);
song.sourceName = sourceCodeToName(song.source);
return song;
})
.filter((song) => song.songName.length > 0);
console.log(JSON.stringify(songs));
this.searchResult = songs;
this.lastSearch = pageUrl;
},
onTabChange(tabIndex) {
this.showPlaylistDetail(tabIndex);
},
onRendered(tabIndex) {
if (tabIndex !== 0) {
return;
}
this.showPlaylistDetail(tabIndex);
},
closeThePopup() {
this.showPopup = false;
},
},
beforeRouteLeave(to, from, next) {
if (this.showPopup) {
this.showPopup = false;
next(false);
} else {
next();
}
},
};
</script>

View File

@@ -0,0 +1,842 @@
<template>
<el-container class="account-container">
<el-main>
<!-- Melody Key 设置部分 -->
<el-row v-if="!registedMK" class="mk-setup">
<el-col :span="16" :offset="4">
<el-card class="mk-card" shadow="hover">
<div class="welcome-text">
<h2>欢迎使用 Melody</h2>
<p class="subtitle">填写你的 Melody Key 开启音乐之旅</p>
</div>
<el-row class="input-row">
<el-col :span="16" :offset="4">
<el-input
v-model="mk"
placeholder="Key 默认为 melody , 如果你部署到公网,请到配置里修改该默认值(后续支持 UI 管理账号)"
size="large"
:prefix-icon="Key"
>
</el-input>
</el-col>
</el-row>
<el-row>
<el-col :span="16" :offset="4" class="btn-wrapper">
<el-button type="primary" @click="checkMK" size="large" round>
<el-icon class="icon"><Check /></el-icon>确认
</el-button>
</el-col>
</el-row>
</el-card>
</el-col>
</el-row>
<!-- 账号信息展示部分 -->
<el-row v-if="account.uid" class="account-info">
<el-col :span="16" :offset="4">
<!-- Melody Key 信息卡片 -->
<el-card class="info-card" shadow="hover">
<div class="mk-info">
<div class="mk-text">
<el-icon><Key /></el-icon>
<span>你的 Melody Key: </span>
<code>{{ account.uid }}</code>
</div>
<el-button
type="danger"
plain
size="small"
@click="logoutMK"
class="logout-btn"
>
退出 Melody 账号
</el-button>
</div>
</el-card>
<!-- 网易云账号卡片 -->
<el-card class="info-card wy-card" shadow="hover">
<template #header>
<div class="card-header">
<span>网易云账号信息</span>
</div>
</template>
<div class="wy-account-info">
<div class="avatar-section">
<el-image
:src="account.wyAccount?.avatarUrl"
class="avatar"
fit="cover"
>
<template #error>
<div class="avatar-placeholder">
<el-icon><UserFilled /></el-icon>
</div>
</template>
</el-image>
<div class="account-status">
<template v-if="account.wyAccount">
<span class="nickname">{{
account.wyAccount.nickname
}}</span>
<el-tag size="small" type="success">已绑定</el-tag>
</template>
<template v-else>
<span class="bind-tip">请先绑定正确的网易云账号</span>
</template>
</div>
</div>
<!-- 登录方式选择 -->
<div class="login-section">
<el-radio-group v-model="account.loginType" class="login-type">
<el-radio-button label="qrcode">扫码登录</el-radio-button>
<el-radio-button label="phone">手机号登录</el-radio-button>
<el-radio-button label="email">邮箱登录</el-radio-button>
</el-radio-group>
<!-- 登录表单 -->
<div
class="login-form"
v-if="['phone', 'email'].includes(account.loginType)"
>
<el-form label-position="top">
<el-form-item
v-if="account.loginType == 'phone'"
label="国际电话区号"
>
<el-input
v-model="account.countryCode"
placeholder="默认86不需要输入 +"
maxlength="4"
></el-input>
</el-form-item>
<el-form-item
:label="account.loginType == 'phone' ? '手机号' : '邮箱'"
>
<el-input v-model="account.account"></el-input>
</el-form-item>
<el-form-item label="密码">
<el-input
type="password"
v-model="account.password"
show-password
></el-input>
</el-form-item>
</el-form>
</div>
<!-- 二维码登录 -->
<div v-if="account.loginType === 'qrcode'" class="qr-section">
<div class="qr-wrapper">
<el-image :src="qrLogin.qrCode" class="qr-code">
<template #error>
<div class="qr-error">
<p>
{{
!account.wyAccount
? "二维码已失效,请点击刷新"
: "如需切换绑定的账号,点击按钮刷新二维码"
}}
</p>
</div>
</template>
</el-image>
<el-button
class="refresh-btn"
circle
type="primary"
@click="refreshQRCode"
>
<el-icon><Refresh /></el-icon>
</el-button>
</div>
<p class="qr-tip">请使用网易云音乐 app 扫码登录</p>
</div>
</div>
</div>
</el-card>
<!-- 昵称设置 -->
<el-card class="info-card name-card" shadow="hover">
<template #header>
<div class="card-header">
<span>昵称</span>
</div>
</template>
<el-input
v-model="account.name"
placeholder="请输入昵称"
></el-input>
</el-card>
<!-- 同步设置卡片 -->
<el-card class="info-card sync-card" shadow="hover">
<template #header>
<div class="card-header">
<span>备份歌单的歌曲到网易云云盘</span>
<el-tooltip placement="top">
<template #content>
<p>
1. 开启自动同步后Melody
会按照指定频率自动将你的歌单里的所有歌曲同步到网易云云盘
</p>
<p>
2.
当频率为小时时将在整点执行如每8小时则在0点816点执行
</p>
<p>3. 当频率为天时将在每天0点执行</p>
</template>
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</template>
<div class="sync-settings">
<el-form label-position="left" label-width="120px">
<el-form-item label="自动同步">
<el-switch
v-model="
account.config.playlistSyncToWyCloudDisk.autoSync.enable
"
/>
</el-form-item>
<el-form-item
label="同步频率"
v-if="
account.config.playlistSyncToWyCloudDisk.autoSync.enable
"
>
<div class="frequency-input">
<span>每</span>
<el-input-number
v-model="
account.config.playlistSyncToWyCloudDisk.autoSync
.frequency
"
:min="1"
:max="30"
controls-position="right"
/>
<el-radio-group
v-model="
account.config.playlistSyncToWyCloudDisk.autoSync
.frequencyUnit
"
>
<el-radio-button label="hour">小时</el-radio-button>
<el-radio-button label="day">天</el-radio-button>
</el-radio-group>
</div>
</el-form-item>
<el-form-item label="音质偏好">
<el-radio-group
v-model="
account.config.playlistSyncToWyCloudDisk
.soundQualityPreference
"
>
<el-radio-button label="high">高质量</el-radio-button>
<el-radio-button label="lossless">无损</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="同步选项">
<div class="sync-options">
<el-checkbox
v-model="
account.config.playlistSyncToWyCloudDisk.autoSync.onlyCreatedPlaylists
"
>
仅同步我创建的歌单
<el-tag size="small" type="success">推荐</el-tag>
</el-checkbox>
<el-checkbox
v-model="
account.config.playlistSyncToWyCloudDisk.syncWySong
"
>
上传网易云已有歌曲到云盘
<el-tag size="small" type="success">推荐</el-tag>
</el-checkbox>
<el-checkbox
v-model="
account.config.playlistSyncToWyCloudDisk.syncNotWySong
"
>
解锁灰色歌曲
<el-tag size="small" type="warning"
>不推荐自动同步</el-tag
>
</el-checkbox>
</div>
</el-form-item>
<el-card
class="info-card"
shadow="hover"
v-if="
account.config.playlistSyncToWyCloudDisk.autoSync.enable
"
>
<template #header>
<div class="card-header">
<el-icon><Timer /></el-icon>
<span class="header-text">下次同步时间</span>
</div>
</template>
<div
v-if="nextCloudRun && nextCloudRun[account.uid]"
class="next-run-content"
>
<el-row :gutter="20" justify="center" align="middle">
<el-col :span="12" class="next-run-item">
<div class="label">下次同步时间</div>
<div class="value">
{{
new Date(
nextCloudRun[account.uid].nextRunTime
).toLocaleString()
}}
</div>
</el-col>
<el-col :span="12" class="next-run-item">
<div class="label">距离下次同步</div>
<div class="value">
{{
Math.round(
nextCloudRun[account.uid].remainingMs / 1000 / 60
)
}}
分钟
</div>
</el-col>
</el-row>
</div>
<div v-else class="next-run-content">
<el-empty description="暂无调度信息" :image-size="60" />
</div>
</el-card>
</el-form>
</div>
</el-card>
<!-- 更新按钮 -->
<div class="update-btn-wrapper">
<el-button type="primary" @click="updateAccount" size="large" round>
<el-icon><Check /></el-icon>更新配置
</el-button>
</div>
</el-col>
</el-row>
</el-main>
</el-container>
</template>
<style scoped>
.account-container {
padding: 20px;
}
/* Melody Key 设置样式 */
.mk-setup {
margin-top: 60px;
}
.mk-card {
padding: 40px 20px;
}
.welcome-text {
text-align: center;
margin-bottom: 30px;
}
.welcome-text h2 {
color: #303133;
margin-bottom: 10px;
}
.subtitle {
color: #909399;
font-size: 14px;
}
.input-row {
margin-bottom: 20px;
}
.btn-wrapper {
text-align: center;
}
/* 账号信息卡片通用样式 */
.info-card {
margin-bottom: 20px;
transition: all 0.3s;
}
.info-card:hover {
transform: translateY(-2px);
}
.card-header {
display: flex;
align-items: center;
font-size: 16px;
font-weight: 500;
}
.card-header .el-icon {
margin-left: 8px;
color: #909399;
cursor: pointer;
}
/* Melody Key 信息样式 */
.mk-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
}
.mk-text {
display: flex;
align-items: center;
gap: 8px;
}
.mk-text code {
background: #f5f7fa;
padding: 4px 8px;
border-radius: 4px;
font-family: monospace;
}
/* 网易云账号样式 */
.wy-account-info {
padding: 20px;
}
.avatar-section {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 30px;
}
.avatar {
width: 80px;
height: 80px;
border-radius: 50%;
border: 2px solid #ebeef5;
}
.avatar-placeholder {
width: 100%;
height: 100%;
background: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
color: #dcdfe6;
}
.account-status {
display: flex;
flex-direction: column;
gap: 8px;
}
.nickname {
font-size: 18px;
font-weight: 500;
}
.bind-tip {
color: #909399;
}
/* 登录部分样式 */
.login-section {
margin-top: 20px;
}
.login-type {
margin-bottom: 20px;
}
.login-form {
max-width: 400px;
margin: 0 auto;
}
/* 二维码部分样式 */
.qr-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.qr-wrapper {
position: relative;
width: 180px;
height: 180px;
}
.qr-code {
width: 100%;
height: 100%;
}
.qr-error {
width: 100%;
height: 100%;
background: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 20px;
color: #909399;
font-size: 14px;
}
.refresh-btn {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
transition: opacity 0.3s;
}
.qr-wrapper:hover .refresh-btn {
opacity: 1;
}
.qr-tip {
color: #909399;
font-size: 14px;
}
/* 同步设置样式 */
.sync-settings {
padding: 20px;
}
.frequency-input {
display: flex;
align-items: center;
gap: 10px;
}
.sync-options {
display: flex;
flex-direction: column;
gap: 10px;
}
/* 更新按钮容器 */
.update-btn-wrapper {
text-align: center;
margin: 40px 0;
}
:deep(.el-form-item__label) {
font-weight: 500;
}
.next-run-content {
padding: 20px 0;
}
.next-run-item {
text-align: center;
}
.next-run-item .label {
color: #909399;
font-size: 13px;
margin-bottom: 8px;
}
.next-run-item .value {
color: #303133;
font-size: 14px;
font-weight: 500;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.header-text {
font-size: 14px;
font-weight: 500;
}
:deep(.el-empty) {
padding: 20px 0;
}
:deep(.el-card__header) {
padding: 15px 20px;
}
</style>
<script>
import {
Key,
Check,
Refresh,
UserFilled,
QuestionFilled,
Timer,
} from "@element-plus/icons-vue";
import { getAccount, setAccount, qrLoginCreate, qrLoginCheck } from "../../api";
import storage from "../../utils/storage";
import { ElMessage } from "element-plus";
import { getNextRunInfo } from "../../api";
export default {
components: {
Key,
Check,
Refresh,
UserFilled,
QuestionFilled,
Timer,
},
data: () => {
return {
mk: "",
account: {
loginType: "",
account: 0,
password: "",
platform: "wy",
uid: "",
countryCode: "",
config: {
playlistSyncToWyCloudDisk: {
autoSync: {
enable: false,
frequency: 1,
frequencyUnit: "day",
onlyCreatedPlaylists: true,
},
syncWySong: true,
syncNotWySong: false,
soundQualityPreference: "high",
},
},
name: "",
},
registedMK: false,
qrLogin: {
qrCode: "",
qrKey: "",
},
nextCloudRun: null,
};
},
async mounted() {
this.registedMK = storage.get("mk") ? true : false;
if (!this.registedMK) {
return;
}
console.log(this.registedMK);
const ret = await getAccount();
if (ret === false || !ret.data) {
this.registedMK = false;
return;
}
this.account = ret.data.account;
storage.set("wyAccount", ret.data.account.wyAccount);
console.log(this.account);
this.loadNextRunInfo();
},
methods: {
async refreshQRCode() {
const ret = await qrLoginCreate();
if (ret === false || ret.status != 0) {
ElMessage({
center: true,
type: "error",
message: "生成登录二维码失败",
});
return false;
}
this.qrLogin.qrKey = ret.data.qrKey;
this.qrLogin.qrCode = ret.data.qrCode;
// loop check qr code
if (this.qrLogin.qrKey) {
console.log("start check qr code");
const IntervalID = setInterval(async () => {
if (!this.qrLogin.qrKey) {
console.log("stop check qr code");
clearInterval(IntervalID);
return;
}
if (await this.checkQRCode()) {
this.qrLogin.qrKey = "";
this.qrLogin.qrCode = "";
clearInterval(IntervalID);
}
}, 1000);
}
},
async checkQRCode() {
if (!this.qrLogin.qrKey) {
return false;
}
const ret = await qrLoginCheck(this.qrLogin.qrKey);
if (ret === false || ret.status != 0 || !ret.data.wyQrStatus) {
console.log("checkQRCode failed");
return false;
}
// 800 为二维码过期; 801 为等待扫码; 802 为待认; 803 为授权登录成功
console.log(`checkQRCode wyStatus: ${ret.data.wyQrStatus}`);
if (ret.data.wyQrStatus == 800) {
ElMessage({
center: true,
type: "error",
message: "二维码已失效, 请手动刷新",
});
this.qrLogin.qrKey = "";
this.qrLogin.qrCode = "";
return false;
}
if (ret.data.wyQrStatus != 803) {
return false;
}
this.account = ret.data.account;
storage.set("wyAccount", ret.data.account.wyAccount);
return true;
},
async checkMK() {
this.mk = this.mk.trim();
if (!this.mk) {
return;
}
const ret = await getAccount({ mk: this.mk });
if (ret !== false && ret.data) {
this.account = ret.data.account;
this.registedMK = true;
storage.set("mk", this.mk);
storage.set("wyAccount", ret.data.account.wyAccount);
ElMessage({
center: true,
type: "success",
message: "Melody Key 设置成功",
});
} else {
ElMessage({
center: true,
type: "error",
message: "Melody Key 不正确哦",
});
}
},
async updateAccount() {
if (this.account.loginType !== "qrcode") {
if (
!this.account.account ||
!this.account.password ||
!this.account.loginType
) {
return;
}
}
if (this.account.loginType == "phone") {
if (this.account.countryCode) {
if (!/^[\d]{0,4}$/.test(this.account.countryCode)) {
ElMessage({
center: true,
type: "error",
message: "国际电话区号不正确",
});
return;
}
}
}
if (this.account.loginType == "email") {
if (
!/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/.test(
this.account.account
)
) {
ElMessage({
center: true,
type: "error",
message: "邮箱格式不正确",
});
return;
}
}
const ret = await setAccount({
loginType: this.account.loginType,
countryCode: this.account.countryCode,
account: this.account.account,
password: this.account.password,
config: this.account.config,
name: this.account.name,
});
if (ret.status != 0) {
ElMessage({
center: true,
type: "error",
message: ret.message,
});
return;
}
if (ret.data.account) {
this.account = ret.data.account;
storage.set("wyAccount", ret.data.account.wyAccount);
}
ElMessage({
center: true,
type: "success",
message: "更新配置成功",
});
},
logoutMK() {
storage.del("mk");
this.registedMK = false;
this.mk = "";
this.account = {};
},
async loadNextRunInfo() {
const ret = await getNextRunInfo();
if (ret.status === 0) {
this.nextCloudRun = ret.data.cloudNextRuns;
}
},
},
watch: {
"account.config.playlistSyncToWyCloudDisk.autoSync": {
deep: true,
handler() {
this.loadNextRunInfo();
},
},
},
};
</script>

View File

@@ -0,0 +1,514 @@
<template>
<el-main>
<!-- 搜索区域 -->
<div class="search-container">
<el-row justify="center">
<el-col :span="16">
<el-row>
<el-col :span="20">
<el-input
v-model="keyword"
placeholder="网页链接 / 歌名"
clearable
@keyup.enter.native="onSearch"
class="search-input"
>
<template #prefix>
<i class="bi bi-search"></i>
</template>
</el-input>
</el-col>
<el-col :span="4">
<el-button
type="primary"
@click="onSearch"
class="search-btn"
:loading="isSearching"
>
搜索
</el-button>
</el-col>
</el-row>
</el-col>
</el-row>
<!-- 搜索提示 -->
<el-row v-if="searchTip" class="search-tip-row">
<el-col :span="12" :offset="3">
<div class="search-tip">
<span>{{ searchTip }}</span>
<span v-loading="isSearching"></span>
</div>
</el-col>
</el-row>
</div>
<!-- 精准搜索卡片 -->
<transition name="fade">
<el-row v-if="songMetaInfo !== null" class="song-card-container">
<el-col :span="13" :offset="4">
<el-card class="song-card" shadow="hover">
<el-row>
<el-col :span="6" style="width: 160px; height: 160px">
<img
:src="songMetaInfo.coverUrl"
onerror="this.src='https://cdnmusic.migu.cn/v3/static/img/common/default/img_default_240x240.jpg'"
class="image"
style="width: 100%; height: 100%"
/>
</el-col>
<el-col :span="18">
<!-- <div style="padding: 14px"></div> -->
<div
style="
position: relative;
z-index: 2;
overflow: hidden;
width: 100%;
height: 100%;
"
>
<div
style="
width: 100%;
height: 100%;
position: absolute;
top: 0;
color: white;
margin-top: 20px;
padding-left: 18px;
"
>
<el-row style="font-size: 20px; text-align: left">
{{ songMetaInfo.songName }}
</el-row>
<el-row>
<el-col :span="10">
<el-row
style="
margin-top: 20px;
font-size: 13px;
text-align: left;
"
>
歌手 {{ songMetaInfo.artist }}
</el-row>
<el-row
style="
margin-top: 13px;
font-size: 13px;
text-align: left;
"
>
专辑{{ songMetaInfo.album }}
</el-row>
<el-row
style="
margin-top: 13px;
font-size: 13px;
text-align: left;
"
>
时长 {{ songMetaInfo.duration }}
</el-row>
</el-col>
<el-col
:span="14"
style="text-align: left; margin-top: 20px"
>
<el-link
@click="playTheSong(songMetaInfo)"
:underline="false"
style="color: white"
>
<i
class="bi bi-play-circle"
style="font-size: 40px"
></i>
</el-link>
<el-tooltip
:content="
wyAccount
? '上传歌曲到云盘'
: '上传歌曲到云盘(请先绑定网易云账号)'
"
placement="bottom"
>
<el-link
@click="uploadToCloud(songMetaInfo.pageUrl)"
:underline="false"
:disabled="!wyAccount ? true : false"
style="
color: white;
margin-left: 20px;
margin-top: 6px;
"
>
<i
class="bi bi-cloud-upload"
style="font-size: 40px"
></i>
</el-link>
</el-tooltip>
<!-- download to service local -->
<el-tooltip
:content="
globalConfig.downloadPathExisted
? '下载到服务器'
: '下载到服务器(请先配置下载路径)'
"
placement="bottom"
>
<el-link
@click="
downloadToLocalService(songMetaInfo.pageUrl)
"
:underline="false"
:disabled="
!globalConfig.downloadPathExisted ? true : false
"
style="
color: white;
margin-left: 20px;
margin-top: 6px;
"
>
<i
class="bi bi-cloud-download"
style="font-size: 40px"
></i>
</el-link>
</el-tooltip>
</el-col>
</el-row>
</div>
<div
style="
position: absolute;
filter: blur(32px);
transform: scale(1.2);
top: 0;
z-index: -1;
width: 100%;
height: 100%;
"
>
<img
:src="songMetaInfo.coverUrl"
onerror="this.src='https://cdnmusic.migu.cn/v3/static/img/common/default/img_default_240x240.jpg'"
style="width: 100%; height: 100%"
/>
</div>
</div>
</el-col>
</el-row>
</el-card>
</el-col>
</el-row>
</transition>
<!-- 搜索结果表格 -->
<transition name="slide-fade">
<el-row v-if="searchResult.length > 0" class="search-result-container">
<SearchResultTable
:playTheSong="playTheSong"
:abortTheSong="abortTheSong"
:suggestMatchSongId="suggestMatchSongId"
:searchResult="searchResult"
/>
</el-row>
<!-- Add music animation when no search -->
<el-row v-else class="music-animation-container">
<div class="music-notes">
<i class="bi bi-music-note-beamed note"></i>
<i class="bi bi-music-note note"></i>
<i class="bi bi-vinyl note"></i>
<i class="bi bi-music-note-beamed note"></i>
<i class="bi bi-music-note note"></i>
</div>
</el-row>
</transition>
</el-main>
</template>
<script>
import {
searchSongs,
getSongsMeta,
createSyncSongFromUrlJob,
getGlobalConfig,
} from "../../api";
import SearchResultTable from "../../components/SearchResultTable.vue";
import { secondDurationToDisplayDuration, sourceCodeToName } from "../../utils";
import { startTaskListener } from "../../components/TaskNotification";
import storage from "../../utils/storage";
export default {
data: () => {
return {
suggestMatchSongId: "",
songMetaInfo: null,
playUrl: "",
keyword: "",
searchTip: "",
isSearching: false,
searchResult: [],
wyAccount: null,
globalConfig: null,
};
},
props: {
playTheSong: {
type: Function,
required: true,
},
abortTheSong: {
type: Function,
required: true,
},
},
async mounted() {
this.wyAccount = storage.get("wyAccount");
this.loadGlobalConfig();
},
watch: {
$route(to, from) {
this.wyAccount = storage.get("wyAccount");
if (to.path === "/" || to.path === "/home" || to.path === "") {
this.loadGlobalConfig();
}
},
},
setup(props, { emit }) {
const playTheSong = (songMeta, pageUrl, suggestMatchSongId) => {
props.playTheSong(songMeta, pageUrl, suggestMatchSongId);
};
const abortTheSong = () => {
props.abortTheSong();
};
return {
abortTheSong,
playTheSong,
};
},
components: {
SearchResultTable,
},
methods: {
async uploadToCloud(pageUrl) {
const ret = await createSyncSongFromUrlJob(pageUrl);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async loadGlobalConfig() {
const globalConfig = await getGlobalConfig();
if (globalConfig !== false && globalConfig.data) {
this.globalConfig = globalConfig.data;
}
},
async onSearch() {
if (this.keyword.trim().length === 0) {
return;
}
this.songMetaInfo = null;
this.searchTip = `正在搜索 ${this.keyword}`;
this.isSearching = true;
if (
this.keyword.indexOf("163.com") >= 0 &&
this.keyword.indexOf("/song") >= 0
) {
const songIdMatch = this.keyword.match(/id=([\d]+)/);
if (songIdMatch && songIdMatch.length > 1) {
this.suggestMatchSongId = songIdMatch[1];
}
}
try {
if (this.keyword.indexOf("http") >= 0) {
getSongsMeta({ url: this.keyword }).then((ret) => {
const info = ret.data.songMeta;
if (info) {
info.album = info.album != "" ? info.album : "未知";
info.duration = secondDurationToDisplayDuration(info.duration);
info.sourceName = sourceCodeToName(info.source);
this.songMetaInfo = info;
} else {
this.songMetaInfo = null;
}
});
}
const result = await searchSongs({ keyword: this.keyword });
console.log(result);
const songs = result.data.songs
.map((song) => {
song.album = song.album != "" ? `${song.album}` : " - ";
song.duration = secondDurationToDisplayDuration(song.duration);
song.sourceName = sourceCodeToName(song.source);
return song;
})
.filter((song) => song.songName.length > 0);
console.log(JSON.stringify(songs));
this.searchResult = songs;
this.searchTip = "";
} catch (e) {
this.searchTip = "搜索失败";
} finally {
this.isSearching = false;
}
},
},
};
</script>
<style scoped>
.search-container {
margin-top: 40px;
margin-bottom: 30px;
}
.search-input {
.el-input__inner {
border-radius: 24px;
padding-left: 45px;
height: 48px;
font-size: 16px;
}
.el-input__prefix {
left: 15px;
font-size: 18px;
color: #909399;
}
}
.search-btn {
width: 100px;
height: 48px;
border-radius: 24px;
font-size: 16px;
}
.search-tip {
font-size: 18px;
color: #606266;
margin-top: 15px;
}
.song-card-container {
margin-bottom: 30px;
}
.song-card {
transition: all 0.3s;
&:hover {
transform: translateY(-5px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
}
.search-result-container {
margin-bottom: 60px;
}
/* 动画效果 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-fade-enter-active {
transition: all 0.3s ease-out;
}
.slide-fade-leave-active {
transition: all 0.3s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateY(20px);
opacity: 0;
}
.music-animation-container {
height: 400px;
margin-top: 60px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.music-notes {
position: relative;
width: 300px;
}
.note {
position: absolute;
font-size: 24px;
color: #409eff;
opacity: 0;
animation: float 4s ease-in-out infinite;
}
.note:nth-child(1) {
left: 10%;
animation-delay: 0s;
}
.note:nth-child(2) {
left: 30%;
animation-delay: 1s;
}
.note:nth-child(3) {
left: 50%;
font-size: 32px;
animation-delay: 2s;
}
.note:nth-child(4) {
left: 70%;
animation-delay: 1.5s;
}
.note:nth-child(5) {
left: 90%;
animation-delay: 0.5s;
}
@keyframes float {
0% {
transform: translateY(120px) rotate(5deg);
opacity: 0;
}
20% {
opacity: 0.8;
}
80% {
opacity: 0.8;
}
100% {
transform: translateY(-120px) rotate(-5deg);
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,465 @@
<template>
<el-container style="margin-top: 20px">
<el-aside width="300px" style="margin-left: 80px">
<el-scrollbar height="600px">
<div v-for="item in playlists" :key="item" class="scrollbar-item">
<el-link :underline="false" @click="showPlaylistDetail(item.id)">
<el-row>
<el-col :span="5">
<el-image
style="width: 100%; height: 100%; float: left"
:src="item.cover"
fit="cover"
/>
</el-col>
<el-col :span="16" style="margin-top: 20px">
{{ item.name }}
</el-col>
</el-row>
</el-link>
</div>
</el-scrollbar>
</el-aside>
<el-main style="padding: 0; margin-left: 30px">
<el-dialog v-model="showSearchPage" width="1080px" center>
<el-scrollbar class="search-scollbar">
<p v-if="this.searchTip" style="font-size: 20px; text-align: center">
{{ this.searchTip }}
</p>
<SearchResultTable
:playTheSong="playTheSong"
:abortTheSong="abortTheSong"
:suggestMatchSongId="suggestMatchSongId"
:searchResult="searchResult"
>
</SearchResultTable>
</el-scrollbar>
</el-dialog>
<el-row v-if="playlistDetail.id" justify="center" style="height: 150px">
<el-col :span="2">
<el-image
style="width: 100px; height: 100px"
:src="playlistDetail.cover"
fit="cover"
/>
</el-col>
<el-col :span="18" :offset="1">
<el-row style="font-size: 20px">{{ playlistDetail.name }}</el-row>
<el-row style="color: grey; font-size: 10px; margin-top: 5px">
全部: {{ playlistDetail.songs.length }} | 待解锁:
{{ playlistDetail.songs.filter((song) => song.isBlocked).length }}
</el-row>
<el-row style="margin-top: 20px; text-align: left">
<el-col :span="6" style="height: 32px; line-height: 32px">
<el-link
type="primary"
:underline="false"
@click="unblockThePlaylist(playlistDetail.id)"
>
<i
class="bi bi-unlock-fill"
style="font-size: 18px; padding-right: 3px"
></i>
<span style="font-size: 15px"> 解锁全部 </span>
<span style="font-size: 10px"> (实验性功能) </span>
</el-link>
</el-col>
<el-col :span="6" style="height: 32px; line-height: 32px">
<el-link
type="primary"
:underline="false"
@click="unblockThePlaylistForWySong(playlistDetail.id)"
>
<i
class="bi bi-upload"
style="font-size: 18px; padding-right: 3px"
></i>
<span style="font-size: 15px"> 备份到网易云云盘 </span>
</el-link>
</el-col>
<el-col :span="6" style="height: 32px; line-height: 32px">
<el-link
type="primary"
:underline="false"
@click="syncThePlaylistToLocalService(playlistDetail.id)"
>
<i
class="bi bi-download"
style="font-size: 18px; padding-right: 3px"
></i>
<span style="font-size: 15px"> 同步到服务器本地 </span>
</el-link>
</el-col>
<el-col :span="7">
<el-switch
style="float: left"
v-model="showBlockSongsOnly"
active-text="仅展示无法播放的歌曲"
@change="filterHandlerChange($event)"
/>
</el-col>
</el-row>
</el-col>
</el-row>
<el-row
v-if="playlistDetail.id"
justify="center"
style="margin-bottom: 60px"
>
<el-scrollbar height="800px">
<el-table
ref="tableRef"
:data="playlistDetail.songs"
height="800"
empty-text="开心本歌单没有无法播放的歌~"
:stripe="true"
:key="tableKey"
>
<el-table-column type="index" width="50" />
<el-table-column prop="songName" label="歌曲" width="300" />
<el-table-column prop="artists[0]" label="歌手" width="100" />
<el-table-column prop="album" label="专辑" width="200" />
<el-table-column prop="duration" label="时长" width="100" />
<el-table-column
label="状态"
width="100"
:filters="[
{ text: '全部', value: 'all' },
{ text: '无法播放', value: 'blocked' },
{ text: '云盘歌曲', value: 'cloud' },
]"
:filter-method="filterHandler"
:filtered-value="tableFilterValues"
>
<template #default="scope">
<i
v-if="scope.row.isBlocked"
class="bi bi-lock-fill"
style="font-size: 20px; color: gray"
></i>
<i
v-else-if="scope.row.isCloud"
class="bi bi-cloud"
style="font-size: 20px"
></i>
<i
v-else
class="bi bi-heart-fill"
style="color: red; font-size: 20px"
></i>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="scope">
<div v-if="scope.row.isBlocked">
<el-tooltip content="搜索歌曲" placement="top">
<el-link
type="primary"
:underline="false"
@click="searchTheSong(scope.row.pageUrl)"
>
<i class="bi bi-search" style="font-size: 20px"></i>
</el-link>
</el-tooltip>
<el-tooltip content="尝试解锁歌曲" placement="top">
<el-link
type="primary"
:underline="false"
@click="unblockTheSong(scope.row.songId)"
style="margin-left: 15px"
>
<i class="bi bi-unlock-fill" style="font-size: 20px"></i>
</el-link>
</el-tooltip>
</div>
<div v-else-if="!scope.row.isBlocked">
<el-tooltip content="播放歌曲" placement="top">
<el-link
type="primary"
:underline="false"
@click="
playTheSongWithPlayUrl({
songId: scope.row.songId,
playUrl: scope.row.playUrl,
coverUrl: scope.row.cover,
songName: scope.row.songName,
pageUrl: scope.row.pageUrl,
artist: scope.row.artists[0],
})
"
>
<i class="bi bi-play-circle" style="font-size: 20px"></i>
</el-link>
</el-tooltip>
</div>
</template>
</el-table-column>
</el-table>
</el-scrollbar>
</el-row>
</el-main>
</el-container>
</template>
<style>
.el-overlay,
.el-overlay-dialog {
height: calc(100% - 60px);
}
</style>
<style scoped>
.scrollbar-item {
display: flex;
align-items: center;
height: 60px;
margin: 8px 12px;
padding: 0 15px;
border-radius: 8px;
background: var(--el-color-primary-light-9);
transition: all 0.3s ease;
cursor: pointer;
}
.scrollbar-item:hover {
transform: translateX(5px);
background: var(--el-color-primary-light-8);
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.scrollbar-item .el-link {
width: 100%;
}
.scrollbar-item .el-row {
width: 100%;
}
.scrollbar-item .el-image {
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.scrollbar-item .el-col {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
<script>
import {
searchSongs,
getAllPlaylist,
getPlaylistDetail,
createSyncSongFromPlaylistJob,
createSyncSongWithSongIdJob,
createSyncThePlaylistToLocalServiceJob,
} from "../../api";
import { secondDurationToDisplayDuration, sourceCodeToName } from "../../utils";
import SearchResultTable from "../../components/SearchResultTable.vue";
import { startTaskListener } from "../../components/TaskNotification";
import { ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
export default {
data: () => {
return {
searchTip: "",
showSearchPage: false,
tableKey: 1,
showBlockSongsOnly: true,
tableFilterValues: ["blocked"],
playlists: [],
playlistDetail: {},
searchResult: [],
suggestMatchSongId: "",
lastSearch: "",
};
},
components: {
SearchResultTable,
},
props: {
playTheSongWithPlayUrl: {
type: Function,
required: true,
},
abortTheSong: {
type: Function,
required: true,
},
playTheSong: {
type: Function,
required: true,
},
},
setup(props, { emit }) {
const tableRef = ref();
const playTheSongWithPlayUrl = (playOption) => {
props.playTheSongWithPlayUrl(playOption);
};
const playTheSong = (metaInfo, playUrl, suggestMatchSongId) => {
props.playTheSong(metaInfo, playUrl, suggestMatchSongId);
};
const abortTheSong = () => {
props.abortTheSong();
};
return {
playTheSongWithPlayUrl,
playTheSong,
abortTheSong,
tableRef,
};
},
async mounted() {
console.log("mounted");
const playlistRet = await getAllPlaylist();
this.playlists = playlistRet.data.playlists;
},
methods: {
async unblockThePlaylist(playlistId) {
ElMessageBox.confirm(
"【智能解锁全部】是一个实验性功能会根据歌曲名<E69BB2><E5908D>歌手尝试寻找最合适的来源但也可能会有货不对版的情况请谨慎使用。你也可以点击下边单首歌曲的搜索图标进入搜索页面进行手动解锁",
"Warning",
{
confirmButtonText: "解锁全部",
cancelButtonText: "取消",
type: "warning",
}
).then(async () => {
// on confirm
ElMessage({
message: "开始解锁歌单",
type: "info",
duration: 1000,
});
const ret = await createSyncSongFromPlaylistJob(playlistId, {
syncWySong: false,
syncNotWySong: true,
});
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
});
},
async unblockThePlaylistForWySong(playlistId) {
ElMessage({
message: "开始将歌单中的歌曲备份到网易云云盘",
type: "info",
duration: 1000,
});
const ret = await createSyncSongFromPlaylistJob(playlistId, {
syncWySong: true,
syncNotWySong: false,
});
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async syncThePlaylistToLocalService(playlistId) {
const ret = await createSyncThePlaylistToLocalServiceJob(playlistId);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async unblockTheSong(songId) {
const ret = await createSyncSongWithSongIdJob(songId);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async showPlaylistDetail(playlistId) {
console.log(`click playlist ${playlistId}`);
const detailRet = await getPlaylistDetail(playlistId);
const playlists = detailRet.data.playlists;
playlists.songs = playlists.songs.map((song) => {
song.duration = secondDurationToDisplayDuration(song.duration);
return song;
});
this.playlistDetail = playlists;
},
filterHandler(value, row, column) {
if (value == "all") {
return true;
}
if (value == "cloud") {
return row.isCloud;
}
if (value == "blocked") {
return row.isBlocked;
}
},
filterHandlerChange(showBlockedOnly) {
if (showBlockedOnly) {
this.showBlockedOnly = true;
this.tableFilterValues = ["blocked"];
} else {
this.showBlockedOnly = false;
this.tableFilterValues = ["all"];
}
this.tableKey++;
},
async searchTheSong(pageUrl) {
this.showSearchPage = true;
if (this.lastSearch === pageUrl) {
return;
}
this.searchTip = "正在搜索...";
this.searchResult = [];
console.log(pageUrl);
if (pageUrl.indexOf("163.com") >= 0 && pageUrl.indexOf("/song") >= 0) {
const songIdMatch = pageUrl.match(/id=([\d]+)/);
if (songIdMatch && songIdMatch.length > 1) {
this.suggestMatchSongId = songIdMatch[1];
}
}
const result = await searchSongs({ keyword: pageUrl });
console.log(result);
const songs = result.data.songs
.map((song) => {
song.album = song.album != "" ? `${song.album}` : " - ";
song.duration = secondDurationToDisplayDuration(song.duration);
song.sourceName = sourceCodeToName(song.source);
return song;
})
.filter((song) => song.songName.length > 0);
console.log(JSON.stringify(songs));
this.searchResult = songs;
this.searchTip = "";
this.lastSearch = pageUrl;
},
},
};
</script>
<style scoped>
@media (min-height: 500px) {
.search-scollbar {
height: 600px;
}
}
@media (min-height: 900px) {
.search-scollbar {
height: 700px;
}
}
</style>

View File

@@ -0,0 +1,513 @@
<template>
<el-container class="setting-container">
<el-main>
<!-- 组件更新 -->
<el-card class="setting-card">
<template #header>
<div class="card-header">
<span>核心组件版本更新</span>
</div>
</template>
<el-row align="middle" class="version-info">
<el-col :span="16">
<span class="label">当前使用的 media-get 版本号:</span>
<span class="version">{{ mediaGetVersion }}</span>
<span class="label">最新的版本号:</span>
<span class="version">{{ latestVersion }}</span>
</el-col>
<el-col :span="8" style="text-align: right">
<el-button
type="primary"
:disabled="updating"
@click="updateMediaGet"
class="update-btn"
>
<template v-if="!updating">更新 media-get</template>
<template v-else>更新中</template>
</el-button>
</el-col>
</el-row>
</el-card>
<!-- 本地下载配置 -->
<el-card class="setting-card">
<template #header>
<div class="card-header">
<span>本地下载配置</span>
</div>
</template>
<el-form label-position="right" label-width="180px">
<el-form-item label="下载路径">
<el-col :span="16">
<el-input v-model="downloadPath" placeholder="下载路径">
<template #append>
<el-tooltip placement="top">
<template #content>
<p>
1. 下载路径格式Mac/Linux: /path/to/... | Windows:
C:\Users\YourUserName\Downloads
</p>
<p>
2. 请注意如果本服务部署在 Docker 下载路径应当为
Docker
容器内的路径你需要将容器内的下载路径映射到宿主机的相应目录
</p>
</template>
<i class="bi bi-question-circle"></i>
</el-tooltip>
</template>
</el-input>
</el-col>
</el-form-item>
<el-form-item label="单曲下载的文件名格式">
<el-col :span="16">
<el-input
v-model="filenameFormat"
placeholder="留空则默认为:{songName}-{artist}"
>
<template #append>
<el-tooltip placement="top">
<template #content>
<p>支持的变量: {songName}, {artist}, {album}</p>
<p>示例: {album}-{artist}-{songName}</p>
<p>支持目录结构: {artist}/{album}/{songName}</p>
</template>
<i class="bi bi-question-circle"></i>
</el-tooltip>
</template>
</el-input>
</el-col>
</el-form-item>
</el-form>
</el-card>
<!-- 歌单同步配置 -->
<el-card class="setting-card">
<template #header>
<div class="card-header">
<span>歌单同步到本地</span>
<el-tooltip placement="top">
<template #content>
<p>1. 开启自动同步后Melody 会按照指定频率自动将你的歌单里的所有歌曲下载到本地</p>
<p>2. 当频率为小时时将在整点执行如每8小时则在0点816点执行</p>
<p>3. 当频率为天时将在每天0点执行</p>
</template>
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</template>
<el-form label-position="right" label-width="180px">
<el-form-item label="自动同步">
<el-col :span="4">
<el-switch
v-model="playlistSyncToLocal.autoSync.enable"
></el-switch>
</el-col>
<el-col :span="20" v-if="playlistSyncToLocal.autoSync.enable">
<span></span>
<el-input-number
v-model="playlistSyncToLocal.autoSync.frequency"
:min="1"
:max="30"
controls-position="right"
style="width: 120px; margin: 0 10px"
/>
<el-radio-group
v-model="playlistSyncToLocal.autoSync.frequencyUnit"
>
<el-radio-button label="hour">小时</el-radio-button>
<el-radio-button label="day"></el-radio-button>
</el-radio-group>
</el-col>
</el-form-item>
<el-form-item>
<el-checkbox v-model="playlistSyncToLocal.deleteLocalFile">
当歌单里的歌曲移除时同时删除本地对应的歌曲文件
</el-checkbox>
</el-form-item>
<el-form-item label="歌单歌曲的文件名格式">
<el-col :span="16">
<el-input
v-model="playlistSyncToLocal.filenameFormat"
placeholder="留空则默认为:{playlistName}/{songName}-{artist}"
>
<template #append>
<el-tooltip placement="top">
<template #content>
<p>
支持的变量: {playlistName}, {songName}, {artist},
{album}
</p>
<p>示例: {playlistName}/{album}-{artist}-{songName}</p>
</template>
<i class="bi bi-question-circle"></i>
</el-tooltip>
</template>
</el-input>
</el-col>
</el-form-item>
<!-- 添加音质选择配置 -->
<el-form-item label="音质偏好">
<el-radio-group
v-model="playlistSyncToLocal.soundQualityPreference"
>
<el-radio-button label="high">高质量</el-radio-button>
<el-radio-button label="lossless">无损</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
label="同步账号"
v-if="playlistSyncToLocal.autoSync.enable"
>
<el-checkbox-group v-model="playlistSyncToLocal.syncAccounts">
<el-checkbox
v-for="account in accounts"
:key="account.uid"
:label="account.uid"
>
{{ account.name || account.uid }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-form>
<!-- 移动到这里作为表单的补充信息 -->
<div v-if="playlistSyncToLocal.autoSync.enable" class="next-run-info">
<el-divider>
<el-icon><Timer /></el-icon>
<span class="divider-text">下次同步时间</span>
</el-divider>
<div v-if="nextLocalRun" class="next-run-content">
<el-row :gutter="20" justify="center" align="middle">
<el-col :span="12" class="next-run-item">
<div class="label">下次同步时间</div>
<div class="value">
{{ new Date(nextLocalRun.nextRunTime).toLocaleString() }}
</div>
</el-col>
<el-col :span="12" class="next-run-item">
<div class="label">距离下次同步</div>
<div class="value">
{{ Math.round(nextLocalRun.remainingMs / 1000 / 60) }} 分钟
</div>
</el-col>
</el-row>
</div>
<div v-else class="next-run-content">
<el-empty description="暂无调度信息" :image-size="60" />
</div>
</div>
</el-card>
<!-- 搜索站点配置 -->
<el-card class="setting-card">
<template #header>
<div class="card-header">
<span>搜索站点配置</span>
</div>
</template>
<div class="source-tip">
搜索耗时取决于最慢的网站请尽量勾选你的服务所在网络能够访问的网站
</div>
<el-checkbox-group v-model="checkedSources" class="source-list">
<el-checkbox
v-for="s in supportedSources"
:key="s.code"
:label="s.code"
>
{{ s.label }}
</el-checkbox>
</el-checkbox-group>
</el-card>
<div class="submit-container">
<el-button type="primary" @click="updateConfig" size="large">
保存设置
</el-button>
</div>
</el-main>
</el-container>
</template>
<style scoped>
.setting-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.setting-card {
margin-bottom: 20px;
transition: all 0.3s;
}
.setting-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.card-header {
display: flex;
align-items: center;
font-size: 16px;
font-weight: bold;
}
.version-info {
line-height: 32px;
}
.version-info .label {
color: #606266;
margin-right: 8px;
}
.version-info .version {
font-weight: 500;
margin-right: 20px;
}
.update-btn {
min-width: 120px;
}
.source-tip {
color: #909399;
margin-bottom: 15px;
}
.source-list {
display: flex;
flex-wrap: wrap;
gap: 15px;
}
.submit-container {
text-align: center;
margin: 40px 0;
padding: 20px 0;
border-top: 1px solid #ebeef5;
}
:deep(.el-form-item__label) {
font-weight: 500;
}
:deep(.el-input-group__append) {
padding: 0 10px;
cursor: pointer;
}
:deep(.el-card__header) {
border-bottom: 2px solid #f0f2f5;
padding: 15px 20px;
}
.next-run-info {
margin-top: 30px;
padding: 0 20px;
}
.divider-text {
margin-left: 8px;
font-size: 14px;
color: #909399;
}
.next-run-content {
padding: 20px 0;
}
.next-run-item {
text-align: center;
}
.next-run-item .label {
color: #909399;
font-size: 13px;
margin-bottom: 8px;
}
.next-run-item .value {
color: #303133;
font-size: 14px;
font-weight: 500;
}
:deep(.el-divider__text) {
display: flex;
align-items: center;
background-color: #fff;
}
:deep(.el-empty) {
padding: 20px 0;
}
</style>
<script>
import {
checkMediaFetcherLib,
updateMediaFetcherLib,
getGlobalConfig,
setGlobalConfig,
getAllAccounts,
getNextRunInfo,
} from "../../api";
import { ElMessage } from "element-plus";
import { Timer } from "@element-plus/icons-vue";
export default {
data: () => {
return {
mediaGetVersion: "查询中",
latestVersion: "查询中",
updating: false,
downloadPath: "",
filenameFormat: "",
checkedSources: [],
supportedSources: [], //[{code: "bilibili", label: "哔哩哔哩"}]
playlistSyncToLocal: {
autoSync: {
enable: false,
frequency: 1,
frequencyUnit: "day",
},
deleteLocalFile: false,
filenameFormat: "",
soundQualityPreference: "high",
syncAccounts: [],
},
accounts: [],
nextLocalRun: null,
};
},
components: {
Timer,
},
async mounted() {
const globalConfig = await getGlobalConfig();
if (globalConfig !== false && globalConfig.data) {
this.downloadPath = globalConfig.data.downloadPath;
this.filenameFormat = globalConfig.data.filenameFormat;
this.playlistSyncToLocal = globalConfig.data.playlistSyncToLocal;
this.supportedSources = Object.values(globalConfig.data.sourceConsts);
this.checkedSources = globalConfig.data.sources;
}
this.checklib();
this.loadNextRunInfo();
},
methods: {
async checklib() {
const ret = await checkMediaFetcherLib({ lib: "mediaGet" });
if (ret !== false && ret.data) {
this.mediaGetVersion = ret.data.mediaGetInfo.versionInfo;
this.latestVersion = ret.data.latestVersion;
}
},
async updateMediaGet() {
if (this.mediaGetVersion === this.latestVersion) {
ElMessage({
center: true,
type: "info",
message: "已经是最新版本",
});
return false;
}
this.updating = true; // Disable the button and show "更新中" text
const ret = await updateMediaFetcherLib(this.latestVersion);
if (ret === false || ret.status != 0) {
ElMessage({
center: true,
type: "error",
message: "更新失败",
});
this.updating = false; // Enable the button again
return false;
}
ElMessage({
center: true,
type: "success",
message: "更新成功",
});
this.checklib();
this.updating = false; // Enable the button again
},
async updateConfig() {
if (
(this.filenameFormat &&
this.filenameFormat.indexOf("{songName}") === -1) ||
(this.playlistSyncToLocal.filenameFormat &&
this.playlistSyncToLocal.filenameFormat.indexOf("{songName}") === -1)
) {
ElMessage({
center: true,
type: "error",
message: "文件名格式必须包含 {songName}",
});
return false;
}
const ret = await setGlobalConfig({
downloadPath: this.downloadPath,
sources: this.checkedSources,
filenameFormat: this.filenameFormat,
playlistSyncToLocal: this.playlistSyncToLocal,
});
if (ret === false || ret.status != 0) {
ElMessage({
center: true,
type: "error",
message: "更新失败",
});
this.updating = false; // Enable the button again
return false;
}
ElMessage({
center: true,
type: "success",
message: "更新成功",
});
},
async loadNextRunInfo() {
const ret = await getNextRunInfo();
if (ret.status === 0) {
this.nextLocalRun = ret.data.localNextRun;
}
},
},
async created() {
// 获取所有账号信息
const response = await getAllAccounts();
if (response.data) {
this.accounts = Object.entries(response.data).map(([uid, account]) => ({
uid,
name: account.name || uid,
}));
}
},
watch: {
"playlistSyncToLocal.autoSync": {
deep: true,
handler() {
this.loadNextRunInfo();
},
},
},
};
</script>

100
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,100 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import importToCDN from 'vite-plugin-cdn-import'
import path from 'path'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig(({command, mode}) => {
return {
server: {
host: '0.0.0.0'
},
base: './',
define: {
'process.env': process.env
},
plugins: [
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'Melody',
short_name: 'Melody',
description: 'Enjoy your music with Melody',
theme_color: '#ffffff',
start_url:"./mobile.html",
icons: [
{
src: 'melody-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'melody-512x512.png',
sizes: '512x512',
type: 'image/png'
}
]
}
}),
vue(),
importToCDN({
modules:[
{
name: "vue",
var: "Vue",
path: "https://cdnjs.cloudflare.com/ajax/libs/vue/3.2.33/vue.global.min.js",
},
{
name: "vue-router",
var: "VueRouter",
path: "https://cdnjs.cloudflare.com/ajax/libs/vue-router/4.0.14/vue-router.global.min.js",
},
{
name: "vuex",
var: "Vuex",
path: 'https://cdnjs.cloudflare.com/ajax/libs/vuex/4.0.2/vuex.global.min.js',
},
{
name: "axios",
var: "axios",
path: 'https://cdnjs.cloudflare.com/ajax/libs/axios/0.26.1/axios.min.js',
},
{
name: "element-plus",
var: "ElementPlus",
path: 'https://cdnjs.cloudflare.com/ajax/libs/element-plus/2.1.9/index.full.js',
css: ["https://cdnjs.cloudflare.com/ajax/libs/element-plus/2.1.9/theme-chalk/index.min.css"],
},
{
name: "@element-plus/icons-vue",
var: "ElementPlusIconsVue",
path: 'https://cdn.jsdelivr.net/npm/@element-plus/icons-vue@1.1.4/dist/index.iife.min.js',
},
{
name: "element-plus/lib/locale/lang/zh-cn",
var: "ElementPlusLocaleZhCn",
path: 'https://cdnjs.cloudflare.com/ajax/libs/element-plus/2.1.9/locale/zh-cn.min.js',
},
{
name: "vant",
var: "vant",
path: 'https://cdnjs.cloudflare.com/ajax/libs/vant/3.4.8/vant.js',
css: ["https://cdnjs.cloudflare.com/ajax/libs/vant/3.4.8/index.min.css"],
}
]
}),
],
build: {
rollupOptions: {
input: {
index: path.resolve(__dirname, 'index.html'),
mobile: path.resolve(__dirname, 'mobile.html'),
}, output: {
chunkFileNames: 'static/js/[name]-[hash].js',
entryFileNames: "static/js/[name]-[hash].js",
assetFileNames: "static/[ext]/name-[hash].[ext]"
}
},
}
}
})

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