初始化提交
This commit is contained in:
22
.dockerignore
Normal file
22
.dockerignore
Normal 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
139
.gitignore
vendored
Normal 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
57
Dockerfile
Normal 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
201
LICENSE
Normal 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
187
README.md
Normal 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,点击跳转到其他页面时,无法返回到原来页面?
|
||||||
|
|
||||||
|
A:PWA 在移动端不支持使用外部浏览器打开外链,只能在应用内打开,因此会有各种奇怪问题。此时,只能先杀死应用。
|
||||||
|
|
||||||
|
4. Q:为什么我部署的服务,PWA 始终出不了?
|
||||||
|
|
||||||
|
A:PWA 要求服务必须是 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
226
api.md
Normal 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
1
backend/.nvmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
v16.13.0
|
||||||
14
backend/accounts.sample.json
Normal file
14
backend/accounts.sample.json
Normal 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
0
backend/bin/.gitkeep
Normal file
33
backend/package.json
Normal file
33
backend/package.json
Normal 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
1699
backend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
backend/src/consts/business_code.js
Normal file
5
backend/src/consts/business_code.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
StatusJobAlreadyExisted: 40010,
|
||||||
|
StatusJobNoNeedToCreate: 40011,
|
||||||
|
StatusNoNeedToSync: 40012,
|
||||||
|
}
|
||||||
6
backend/src/consts/job_status.js
Normal file
6
backend/src/consts/job_status.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
Pending: "待开始",
|
||||||
|
InProgress: "进行中",
|
||||||
|
Failed: "失败",
|
||||||
|
Finished: "已完成",
|
||||||
|
}
|
||||||
7
backend/src/consts/job_type.js
Normal file
7
backend/src/consts/job_type.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
UnblockedPlaylist: "UnblockedPlaylist",
|
||||||
|
UnblockedSong: "UnblockedSong",
|
||||||
|
SyncSongFromUrl: "SyncSongFromUrl",
|
||||||
|
DownloadSongFromUrl: "DownloadSongFromUrl",
|
||||||
|
SyncThePlaylistToLocalService: "SyncThePlaylistToLocalService",
|
||||||
|
}
|
||||||
4
backend/src/consts/sound_quality.js
Normal file
4
backend/src/consts/sound_quality.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
module.exports = {
|
||||||
|
High: "high",
|
||||||
|
Lossless: "lossless",
|
||||||
|
}
|
||||||
40
backend/src/consts/source.js
Normal file
40
backend/src/consts/source.js
Normal 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歌',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
6
backend/src/errors/account_not_existed.js
Normal file
6
backend/src/errors/account_not_existed.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = class AccountNotExisted extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AccountNotExisted';
|
||||||
|
}
|
||||||
|
}
|
||||||
108
backend/src/handler/account.js
Normal file
108
backend/src/handler/account.js
Normal 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,
|
||||||
|
}
|
||||||
23
backend/src/handler/config.js
Normal file
23
backend/src/handler/config.js
Normal 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,
|
||||||
|
}
|
||||||
41
backend/src/handler/media_fetcher_lib.js
Normal file
41
backend/src/handler/media_fetcher_lib.js
Normal 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,
|
||||||
|
}
|
||||||
92
backend/src/handler/playlists.js
Normal file
92
backend/src/handler/playlists.js
Normal 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,
|
||||||
|
}
|
||||||
46
backend/src/handler/proxy.js
Normal file
46
backend/src/handler/proxy.js
Normal 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
|
||||||
|
};
|
||||||
27
backend/src/handler/scheduler.js
Normal file
27
backend/src/handler/scheduler.js
Normal 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
|
||||||
|
};
|
||||||
31
backend/src/handler/song_meta.js
Normal file
31
backend/src/handler/song_meta.js
Normal 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
|
||||||
|
}
|
||||||
76
backend/src/handler/songs.js
Normal file
76
backend/src/handler/songs.js
Normal 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
|
||||||
|
}
|
||||||
183
backend/src/handler/sync_jobs.js
Normal file
183
backend/src/handler/sync_jobs.js
Normal 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
39
backend/src/index.js
Normal 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
48
backend/src/init_app.js
Normal 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
|
||||||
|
}
|
||||||
19
backend/src/middleware/auth.js
Normal file
19
backend/src/middleware/auth.js
Normal 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()
|
||||||
|
}
|
||||||
19
backend/src/middleware/handle_error.js
Normal file
19
backend/src/middleware/handle_error.js
Normal 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
42
backend/src/router.js
Normal 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;
|
||||||
|
|
||||||
117
backend/src/service/account.js
Normal file
117
backend/src/service/account.js
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
85
backend/src/service/config_manager/index.js
Normal file
85
backend/src/service/config_manager/index.js
Normal 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,
|
||||||
|
}
|
||||||
0
backend/src/service/cronjob/index.js
Normal file
0
backend/src/service/cronjob/index.js
Normal file
189
backend/src/service/job_manager/index.js
Normal file
189
backend/src/service/job_manager/index.js
Normal 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,
|
||||||
|
}
|
||||||
88
backend/src/service/kv/index.js
Normal file
88
backend/src/service/kv/index.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
180
backend/src/service/media_fetcher/index.js
Normal file
180
backend/src/service/media_fetcher/index.js
Normal 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,
|
||||||
|
}
|
||||||
228
backend/src/service/media_fetcher/media_get.js
Normal file
228
backend/src/service/media_fetcher/media_get.js
Normal 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,
|
||||||
|
}
|
||||||
52
backend/src/service/music_platform/tunehub.js
Normal file
52
backend/src/service/music_platform/tunehub.js
Normal 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,
|
||||||
|
};
|
||||||
312
backend/src/service/music_platform/wycloud/index.js
Normal file
312
backend/src/service/music_platform/wycloud/index.js
Normal 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,
|
||||||
|
}
|
||||||
151
backend/src/service/music_platform/wycloud/transport.js
Normal file
151
backend/src/service/music_platform/wycloud/transport.js
Normal 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,
|
||||||
|
}
|
||||||
69
backend/src/service/remote_config/index.js
Normal file
69
backend/src/service/remote_config/index.js
Normal 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,
|
||||||
|
}
|
||||||
221
backend/src/service/scheduler/index.js
Normal file
221
backend/src/service/scheduler/index.js
Normal 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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
19
backend/src/service/search_songs/index.js
Normal file
19
backend/src/service/search_songs/index.js
Normal 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,
|
||||||
|
}
|
||||||
131
backend/src/service/search_songs/search_songs_with_song_meta.js
Normal file
131
backend/src/service/search_songs/search_songs_with_song_meta.js
Normal 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;
|
||||||
|
}
|
||||||
14
backend/src/service/songs_info/index.js
Normal file
14
backend/src/service/songs_info/index.js
Normal 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,
|
||||||
|
}
|
||||||
64
backend/src/service/sync_music/download_to_local.js
Normal file
64
backend/src/service/sync_music/download_to_local.js
Normal 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}`
|
||||||
|
}
|
||||||
11
backend/src/service/sync_music/index.js
Normal file
11
backend/src/service/sync_music/index.js
Normal 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,
|
||||||
|
};
|
||||||
382
backend/src/service/sync_music/sync_playlist.js
Normal file
382
backend/src/service/sync_music/sync_playlist.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
backend/src/service/sync_music/sync_single_song_with_url.js
Normal file
83
backend/src/service/sync_music/sync_single_song_with_url.js
Normal 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: "任务开始",
|
||||||
|
});
|
||||||
|
}
|
||||||
205
backend/src/service/sync_music/unblock_music_in_playlist.js
Normal file
205
backend/src/service/sync_music/unblock_music_in_playlist.js
Normal 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;
|
||||||
|
}
|
||||||
102
backend/src/service/sync_music/unblock_music_with_song_id.js
Normal file
102
backend/src/service/sync_music/unblock_music_with_song_id.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
32
backend/src/utils/cmd.js
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
20
backend/src/utils/download.js
Normal file
20
backend/src/utils/download.js
Normal 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
124
backend/src/utils/fs.js
Normal 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,
|
||||||
|
};
|
||||||
25
backend/src/utils/network.js
Normal file
25
backend/src/utils/network.js
Normal 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
|
||||||
|
}
|
||||||
9
backend/src/utils/regex.js
Normal file
9
backend/src/utils/regex.js
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
backend/src/utils/simple_locker.js
Normal file
30
backend/src/utils/simple_locker.js
Normal 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,
|
||||||
|
};
|
||||||
3
backend/src/utils/sleep.js
Normal file
3
backend/src/utils/sleep.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
5
backend/src/utils/uuid.js
Normal file
5
backend/src/utils/uuid.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
|
||||||
|
module.exports = function () {
|
||||||
|
return uuidv4().replace(/-/g, '');
|
||||||
|
}
|
||||||
5
frontend/.env.development
Normal file
5
frontend/.env.development
Normal 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
3
frontend/.env.production
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
NODE_ENV = 'production'
|
||||||
|
VITE_APP_MODE = 'production'
|
||||||
|
VITE_APP_API_URL = '/api'
|
||||||
1
frontend/.nvmrc
Normal file
1
frontend/.nvmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
v16.13.0
|
||||||
25
frontend/index.html
Normal file
25
frontend/index.html
Normal 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
30
frontend/mobile.html
Normal 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
29
frontend/package.json
Normal 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
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
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
frontend/public/github-logo.png
Normal file
BIN
frontend/public/github-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend/public/melody-192x192.png
Normal file
BIN
frontend/public/melody-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
frontend/public/melody-512x512.png
Normal file
BIN
frontend/public/melody-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
BIN
frontend/public/melody.png
Normal file
BIN
frontend/public/melody.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
451
frontend/src/App.vue
Normal file
451
frontend/src/App.vue
Normal 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
234
frontend/src/Mobile.vue
Normal 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
74
frontend/src/api/axios.js
Normal 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
78
frontend/src/api/index.js
Normal 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", {});
|
||||||
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
277
frontend/src/components/Player.vue
Normal file
277
frontend/src/components/Player.vue
Normal 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>
|
||||||
219
frontend/src/components/SearchResultListForMobile.vue
Normal file
219
frontend/src/components/SearchResultListForMobile.vue
Normal 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>
|
||||||
328
frontend/src/components/SearchResultTable.vue
Normal file
328
frontend/src/components/SearchResultTable.vue
Normal 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>
|
||||||
53
frontend/src/components/TaskNotification.js
Normal file
53
frontend/src/components/TaskNotification.js
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
50
frontend/src/components/TaskNotificationForMobile.js
Normal file
50
frontend/src/components/TaskNotificationForMobile.js
Normal 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
11
frontend/src/main.js
Normal 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
18
frontend/src/mobile.js
Normal 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')
|
||||||
|
|
||||||
50
frontend/src/router/index.js
Normal file
50
frontend/src/router/index.js
Normal 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
|
||||||
42
frontend/src/router/mobile.js
Normal file
42
frontend/src/router/mobile.js
Normal 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
|
||||||
22
frontend/src/utils/audio.js
Normal file
22
frontend/src/utils/audio.js
Normal 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;
|
||||||
|
}
|
||||||
46
frontend/src/utils/index.js
Normal file
46
frontend/src/utils/index.js
Normal 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
24
frontend/src/utils/pwa.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
frontend/src/utils/storage.js
Normal file
20
frontend/src/utils/storage.js
Normal 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)
|
||||||
|
},
|
||||||
|
}
|
||||||
235
frontend/src/views/mobile/Account.vue
Normal file
235
frontend/src/views/mobile/Account.vue
Normal 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>
|
||||||
502
frontend/src/views/mobile/Home.vue
Normal file
502
frontend/src/views/mobile/Home.vue
Normal 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>
|
||||||
422
frontend/src/views/mobile/Playlist.vue
Normal file
422
frontend/src/views/mobile/Playlist.vue
Normal 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>
|
||||||
842
frontend/src/views/pc/Account.vue
Normal file
842
frontend/src/views/pc/Account.vue
Normal 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点、8点、16点执行
|
||||||
|
</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>
|
||||||
514
frontend/src/views/pc/Home.vue
Normal file
514
frontend/src/views/pc/Home.vue
Normal 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>
|
||||||
465
frontend/src/views/pc/Playlist.vue
Normal file
465
frontend/src/views/pc/Playlist.vue
Normal 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>
|
||||||
513
frontend/src/views/pc/Setting.vue
Normal file
513
frontend/src/views/pc/Setting.vue
Normal 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点、8点、16点执行</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
100
frontend/vite.config.js
Normal 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
Reference in New Issue
Block a user