Compare commits
101 commits
master
...
v2.0.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1cd31840e | ||
|
|
ce757215a3 | ||
|
|
cb0a809b16 | ||
|
|
840a5b8e9b | ||
|
|
9a52681687 | ||
|
|
ccebe0a67a | ||
|
|
7ce516877e | ||
|
|
c6c59b2cd9 | ||
|
|
884f3df41a | ||
|
|
a1b0bcf4d3 | ||
|
|
ebebf2a733 | ||
|
|
47e41dea9b | ||
|
|
222fb02355 | ||
|
|
0b4baa3eff | ||
|
|
793db0127c | ||
|
|
cec4c5909d | ||
|
|
f340a90117 | ||
|
|
8f4c3d8e5b | ||
|
|
196a974a64 | ||
|
|
4c90db789b | ||
|
|
d4d8dd817b | ||
|
|
133881d287 | ||
|
|
d09c5fd171 | ||
|
|
0e58bb6e80 | ||
|
|
cf7a4528dd | ||
|
|
dd5361b8c4 | ||
|
|
ffcc60b793 | ||
|
|
b2cd51bbc8 | ||
|
|
e9d82dd792 | ||
|
|
0520af8466 | ||
|
|
8d7ae405a6 | ||
|
|
ca4725a46e | ||
|
|
c3ae012d06 | ||
|
|
48869266e4 | ||
|
|
78ba547138 | ||
|
|
2e7e6588a8 | ||
|
|
678cc15390 | ||
|
|
42089d4996 | ||
|
|
4d54060a4f | ||
|
|
4d59401549 | ||
|
|
07d7564b1e | ||
|
|
d3e44541fb | ||
|
|
ffdf66b57e | ||
|
|
b1fd51233a | ||
|
|
bc696ca59f | ||
|
|
7e892997bd | ||
|
|
0061a66124 | ||
|
|
7b6579e068 | ||
|
|
fc1c25f404 | ||
|
|
97233bc750 | ||
|
|
4c625b172c | ||
|
|
d3089b8940 | ||
|
|
24af937e70 | ||
|
|
13281d3f08 | ||
|
|
0c89b4aa53 | ||
|
|
e6b1b91c01 | ||
|
|
3a204dc7bb | ||
|
|
3626095d97 | ||
|
|
1591586735 | ||
|
|
766e866497 | ||
|
|
a95524ff34 | ||
|
|
70d1de0e0f | ||
|
|
1444bbefa2 | ||
|
|
24798a0bf3 | ||
|
|
530581ba82 | ||
|
|
1eb38937fc | ||
|
|
abedbe7531 | ||
|
|
49bb849982 | ||
|
|
db5730dfdd | ||
|
|
2e41001d02 | ||
|
|
bbd5299341 | ||
|
|
bbcf1f9340 | ||
|
|
9971418b8c | ||
|
|
e748155032 | ||
|
|
f5ab5ea754 | ||
|
|
744247143b | ||
|
|
3ef7675696 | ||
|
|
86f088e5c9 | ||
|
|
719a3a60d4 | ||
|
|
e99c4833f7 | ||
|
|
46fe2d734e | ||
|
|
1b86cbbee1 | ||
|
|
c4219afd3d | ||
|
|
b4590c3c34 | ||
|
|
4d7bc14827 | ||
|
|
9f890072d3 | ||
|
|
b5f681631e | ||
|
|
74dcb36e67 | ||
|
|
46349e8314 | ||
|
|
3b9d728410 | ||
|
|
7ac084f73d | ||
|
|
36603dc3a0 | ||
|
|
7d20e6c5de | ||
|
|
3de55936a5 | ||
|
|
1b05297885 | ||
|
|
fb21405bf9 | ||
|
|
08abf8229f | ||
|
|
e3486ab550 | ||
|
|
d96bd2a547 | ||
|
|
f54d2ded5c | ||
|
|
950f72d4e8 |
|
|
@ -1,16 +0,0 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
.dockerignore
|
||||
.git
|
||||
.github
|
||||
.gitignore
|
||||
README.md
|
||||
LICENSE
|
||||
.vscode
|
||||
dist
|
||||
dist_electron
|
||||
build
|
||||
images
|
||||
script
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = false
|
||||
10
.env.example
|
|
@ -1,7 +1,3 @@
|
|||
VUE_APP_NETEASE_API_URL=/api
|
||||
VUE_APP_ELECTRON_API_URL=/api
|
||||
VUE_APP_ELECTRON_API_URL_DEV=http://127.0.0.1:10754
|
||||
VUE_APP_LASTFM_API_KEY=09c55292403d961aa517ff7f5e8a3d9c
|
||||
VUE_APP_LASTFM_API_SHARED_SECRET=307c9fda32b3904e53654baff215cb67
|
||||
DEV_SERVER_PORT=20201
|
||||
|
||||
ELECTRON_WEB_SERVER_PORT = 42710
|
||||
ELECTRON_DEV_NETEASE_API_PORT = 30001
|
||||
VITE_APP_NETEASE_API_URL = /netease
|
||||
|
|
|
|||
27
.eslintrc.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['react', '@typescript-eslint', 'react-hooks'],
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'@typescript-eslint/no-inferrable-types': 'off',
|
||||
},
|
||||
}
|
||||
21
.github/ISSUE_TEMPLATE/----------.md
vendored
|
|
@ -1,21 +0,0 @@
|
|||
---
|
||||
name: 反馈问题或请求新功能
|
||||
about: bug & feature
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
# 尽量每个 issue 只提一个 bug 或新功能
|
||||
|
||||
### 提新 issue 前请确认 👉
|
||||
|
||||
- 没人提过这个 issue([这里看所有 issue](https://github.com/qier222/YesPlayMusic/issues))
|
||||
- 项目的 Todo 里没有与你 issue 相关的内容([这里看 Todo](https://github.com/qier222/YesPlayMusic/projects/1))
|
||||
|
||||
### 反馈 bug 需要的信息
|
||||
|
||||
- 用的是网页版还是客户端
|
||||
- 浏览器名称或电脑操作系统
|
||||
- 控制台 Console 页面的截图(按 F12 可打开控制台)
|
||||
75
.github/workflows/build.yaml
vendored
|
|
@ -1,12 +1,9 @@
|
|||
name: Release
|
||||
name: Build/Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- "ci/*"
|
||||
tags:
|
||||
- v*
|
||||
- react
|
||||
|
||||
jobs:
|
||||
release:
|
||||
|
|
@ -18,17 +15,27 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: "recursive"
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Install Node.js, NPM and Yarn
|
||||
uses: actions/setup-node@v3
|
||||
- uses: pnpm/action-setup@v2.0.1
|
||||
with:
|
||||
version: 6.29.0
|
||||
|
||||
- name: Install Node.js 16
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16
|
||||
cache: 'yarn'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install RPM & Pacman (on Ubuntu)
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile false
|
||||
|
||||
- name: Build sqlite3 binaries
|
||||
run: node ./scripts/build.sqlite3.js
|
||||
|
||||
- name: Install RPM & Pacman (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update &&
|
||||
|
|
@ -36,44 +43,48 @@ jobs:
|
|||
sudo apt-get install --no-install-recommends -y bsdtar &&
|
||||
sudo apt-get install --no-install-recommends -y libopenjp2-tools
|
||||
|
||||
- name: Install Snapcraft (on Ubuntu)
|
||||
- name: Install Snapcraft (Linux)
|
||||
uses: samuelmeuli/action-snapcraft@v1
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
with:
|
||||
snapcraft_token: ${{ secrets.snapcraft_token }}
|
||||
# with:
|
||||
# Disable since the Snapcraft token is currently not working
|
||||
# snapcraft_token: ${{ secrets.snapcraft_token }}
|
||||
|
||||
- name: Build/release Electron app
|
||||
uses: samuelmeuli/action-electron-builder@v1.6.0
|
||||
- name: Build/Release Electron app
|
||||
uses: njzydark/action-electron-builder-pnpm@v1.1.0-pnpm
|
||||
env:
|
||||
VUE_APP_ELECTRON_API_URL: /api
|
||||
VUE_APP_ELECTRON_API_URL_DEV: http://127.0.0.1:10754
|
||||
VUE_APP_LASTFM_API_KEY: 09c55292403d961aa517ff7f5e8a3d9c
|
||||
VUE_APP_LASTFM_API_SHARED_SECRET: 307c9fda32b3904e53654baff215cb67
|
||||
ELECTRON_WEB_SERVER_PORT: 42710
|
||||
VITE_APP_NETEASE_API_URL: /netease
|
||||
VITE_APP_LASTFM_API_KEY: 09c55292403d961aa517ff7f5e8a3d9c
|
||||
VITE_APP_LASTFM_API_SHARED_SECRET: 307c9fda32b3904e53654baff215cb67
|
||||
with:
|
||||
# GitHub token, automatically provided to the action
|
||||
# (No need to define this secret in the repo settings)
|
||||
github_token: ${{ secrets.github_token }}
|
||||
|
||||
# If the commit is tagged with a version (e.g. "v1.0.0"),
|
||||
# release the app after building
|
||||
release: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
args: --config .electron-builder.config.js
|
||||
skip_package_manager_install: true
|
||||
package_manager: pnpm
|
||||
|
||||
use_vue_cli: true
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
- name: Upload Artifact (macOS)
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: YesPlayMusic-mac
|
||||
path: dist_electron/*-universal.dmg
|
||||
name: R3PLAY-mac
|
||||
path: release/*-universal.dmg
|
||||
if-no-files-found: ignore
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
- name: Upload Artifact (Windows)
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: YesPlayMusic-win
|
||||
path: dist_electron/*Setup*.exe
|
||||
name: R3PLAY-win
|
||||
path: release/*x64-Setup.exe
|
||||
if-no-files-found: ignore
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
- name: Upload Artifact (Linux)
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: YesPlayMusic-linux
|
||||
path: dist_electron/*.AppImage
|
||||
name: R3PLAY-linux
|
||||
path: release/*.AppImage
|
||||
if-no-files-found: ignore
|
||||
|
|
|
|||
78
.gitignore
vendored
|
|
@ -1,34 +1,60 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
.vercel
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
#Electron-builder output
|
||||
/dist_electron
|
||||
NeteaseCloudMusicApi-master
|
||||
NeteaseCloudMusicApi-master.zip
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env.test
|
||||
|
||||
# ----
|
||||
**/dist
|
||||
**/.tmp
|
||||
/tmp
|
||||
release
|
||||
**/.DS_Store
|
||||
*.local
|
||||
.vscode/settings.json
|
||||
.turbo
|
||||
.env
|
||||
vercel.json
|
||||
.vercel
|
||||
packages/web/bundle-stats-renderer.html
|
||||
packages/web/bundle-stats.html
|
||||
packages/web/storybook-static
|
||||
packages/desktop/esbuild-kit
|
||||
|
|
|
|||
4
.npmrc
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node-linker=hoisted
|
||||
public-hoist-pattern=*
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
build
|
||||
coverage
|
||||
tmp
|
||||
node_modules
|
||||
release
|
||||
dist
|
||||
|
|
|
|||
12
.prettierrc
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf",
|
||||
"bracketSpacing": true,
|
||||
"htmlWhitespaceSensitivity": "strict"
|
||||
}
|
||||
4
.vercelignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/**/*/node_modules
|
||||
tmp
|
||||
/**/*/dist
|
||||
/packages/desktop/release
|
||||
25
.vscode/i18n-ally-custom-framework.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# .vscode/i18n-ally-custom-framework.yml
|
||||
|
||||
# An array of strings which contain Language Ids defined by VS Code
|
||||
# You can check avaliable language ids here: https://code.visualstudio.com/docs/languages/overview#_language-id
|
||||
languageIds:
|
||||
- json
|
||||
|
||||
# An array of RegExes to find the key usage. **The key should be captured in the first match group**.
|
||||
# You should unescape RegEx strings in order to fit in the YAML file
|
||||
# To help with this, you can use https://www.freeformatter.com/json-escape.html
|
||||
usageMatchRegex:
|
||||
# The following example shows how to detect `t("your.i18n.keys")`
|
||||
# the `{key}` will be placed by a proper keypath matching regex,
|
||||
# you can ignore it and use your own matching rules as well
|
||||
- 't`({key})`'
|
||||
|
||||
# An array of strings containing refactor templates.
|
||||
# The "$1" will be replaced by the keypath specified.
|
||||
# Optional: uncomment the following two lines to use
|
||||
|
||||
# refactorTemplates:
|
||||
# - i18n.get("$1")
|
||||
|
||||
# If set to true, only enables this custom framework (will disable all built-in frameworks)
|
||||
monopoly: false
|
||||
43
Dockerfile
|
|
@ -1,43 +0,0 @@
|
|||
FROM node:16.13.1-alpine as build
|
||||
ENV VUE_APP_NETEASE_API_URL=/api
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++ git
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install
|
||||
COPY . .
|
||||
RUN yarn build
|
||||
|
||||
FROM nginx:1.20.2-alpine as app
|
||||
RUN echo $'server { \n\
|
||||
gzip on;\n\
|
||||
listen 80; \n\
|
||||
listen [::]:80; \n\
|
||||
server_name localhost; \n\
|
||||
\n\
|
||||
location / { \n\
|
||||
root /usr/share/nginx/html; \n\
|
||||
index index.html; \n\
|
||||
try_files $uri $uri/ /index.html; \n\
|
||||
} \n\
|
||||
\n\
|
||||
location @rewrites { \n\
|
||||
rewrite ^(.*)$ /index.html last; \n\
|
||||
} \n\
|
||||
\n\
|
||||
location /api/ { \n\
|
||||
proxy_set_header Host $host; \n\
|
||||
proxy_set_header X-Real-IP $remote_addr; \n\
|
||||
proxy_set_header X-Forwarded-For $remote_addr; \n\
|
||||
proxy_set_header X-Forwarded-Host $remote_addr; \n\
|
||||
proxy_set_header X-NginX-Proxy true; \n\
|
||||
proxy_pass http://localhost:3000/; \n\
|
||||
} \n\
|
||||
}' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
RUN apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.14/main libuv \
|
||||
&& apk add --no-cache --update-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.14/main nodejs npm \
|
||||
&& npm i -g NeteaseCloudMusicApi
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
CMD nginx ; exec npx NeteaseCloudMusicApi
|
||||
674
LICENSE
|
|
@ -1,21 +1,661 @@
|
|||
MIT License
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (c) 2020-2022 qier222
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Preamble
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
|
|
|||
203
README.md
|
|
@ -1,14 +1,14 @@
|
|||
<br />
|
||||
<p align="center">
|
||||
<a href="https://music.qier222.com" target="blank">
|
||||
<!-- <a href="https://music.qier222.com" target="blank">
|
||||
<img src="images/logo.png" alt="Logo" width="156" height="156">
|
||||
</a>
|
||||
<h2 align="center" style="font-weight: 600">YesPlayMusic</h2>
|
||||
</a> -->
|
||||
<h2 align="center" style="font-weight: 600">R3PLAY</h2>
|
||||
|
||||
<p align="center">
|
||||
高颜值的第三方网易云播放器
|
||||
高颜值的第三方网易云播放器(原YesPlayMusic)
|
||||
<br />
|
||||
<a href="https://music.qier222.com" target="blank"><strong>🌎 访问DEMO</strong></a> |
|
||||
<a href="https://r3play.app" target="blank"><strong>🌎 访问DEMO</strong></a> |
|
||||
<a href="#%EF%B8%8F-安装" target="blank"><strong>📦️ 下载安装包</strong></a> |
|
||||
<a href="https://t.me/yesplaymusic" target="blank"><strong>💬 加入交流群</strong></a>
|
||||
<br />
|
||||
|
|
@ -16,206 +16,53 @@
|
|||
</p>
|
||||
</p>
|
||||
|
||||
[![Library][library-screenshot]](https://music.qier222.com)
|
||||
<!-- [![Library][library-screenshot]](https://music.qier222.com) -->
|
||||
|
||||
## 关于 Alpha 版本
|
||||
|
||||
目前 R3PLAY 处于 Alpha 阶段,仍在开发中,功能尚未完善。建议每次更新时,先卸载旧版本的 R3PLAY,再重新安装。如遇到问题,欢迎加入[Telegram 交流群](https://t.me/yesplaymusic)反馈。
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- ✅ 使用 Vue.js 全家桶开发
|
||||
- ✅ 使用 React + Electron 开发
|
||||
- 🔴 网易云账号登录(扫码/手机/邮箱登录)
|
||||
- 📺 支持 MV 播放
|
||||
- 📃 支持歌词显示
|
||||
- 📻 支持私人 FM / 每日推荐歌曲
|
||||
- 🚫🤝 无任何社交功能
|
||||
- 🌎️ 海外用户可直接播放(需要登录网易云账号)
|
||||
- 🔐 支持 [UnblockNeteaseMusic](https://github.com/UnblockNeteaseMusic/server#音源清单),自动使用[各类音源](https://github.com/UnblockNeteaseMusic/server#音源清单)替换变灰歌曲链接 (网页版不支持)
|
||||
- 「各类音源」指默认启用的音源。
|
||||
- YouTube 音源需自行安装 `yt-dlp`。
|
||||
- ✔️ 每日自动签到(手机端和电脑端同时签到)
|
||||
- 🌚 Light/Dark Mode 自动切换
|
||||
- 👆 支持 Touch Bar
|
||||
- 🖥️ 支持 PWA,可在 Chrome/Edge 里点击地址栏右边的 ➕ 安装到电脑
|
||||
- 🟥 支持 Last.fm Scrobble
|
||||
- ☁️ 支持音乐云盘
|
||||
- ⌨️ 自定义快捷键和全局快捷键
|
||||
- 🎧 支持Mpris
|
||||
- 🛠 更多特性开发中
|
||||
|
||||
## 📦️ 安装
|
||||
|
||||
Electron 版本由 [@hawtim](https://github.com/hawtim) 和 [@qier222](https://github.com/qier222) 适配并维护,支持 macOS、Windows、Linux。
|
||||
|
||||
访问本项目的 [Releases](https://github.com/qier222/YesPlayMusic/releases)
|
||||
页面下载安装包。
|
||||
|
||||
macOS 用户也可以通过 `brew install --cask yesplaymusic` 来安装。
|
||||
<!-- - macOS 用户可以通过 Homebrew 来安装:`brew install --cask yesplaymusic`
|
||||
|
||||
## ⚙️ 部署至 Vercel
|
||||
|
||||
除了下载安装包使用,你还可以将本项目部署到 Vercel 或你的服务器上。下面是部署到 Vercel 的方法。
|
||||
|
||||
1. 部署网易云 API,详情参见 [Binaryify/NeteaseCloudMusicApi](https://neteasecloudmusicapi.vercel.app/#/?id=%e5%ae%89%e8%a3%85)
|
||||
。你也可以将 API 部署到 Vercel。
|
||||
|
||||
2. 点击本仓库右上角的 Fork,复制本仓库到你的 GitHub 账号。
|
||||
|
||||
3. 点击仓库的 Add File,选择 Create new file,输入 `vercel.json`,将下面的内容复制粘贴到文件中,并将 `https://your-netease-api.example.com` 替换为你刚刚部署的网易云 API 地址:
|
||||
|
||||
```json
|
||||
{
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/api/:match*",
|
||||
"destination": "https://your-netease-api.example.com/:match*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
4. 打开 [Vercel.com](https://vercel.com),使用 GitHub 登录。
|
||||
|
||||
5. 点击 Import Git Repository 并选择你刚刚复制的仓库并点击 Import。
|
||||
|
||||
6. 点击 PERSONAL ACCOUNT 旁边的 Select。
|
||||
|
||||
7. 点击 Environment Variables,填写 Name 为 `VUE_APP_NETEASE_API_URL`,Value 为 `/api`,点击 Add。最后点击底部的 Deploy 就可以部署到
|
||||
Vercel 了。
|
||||
|
||||
## ⚙️ 部署到自己的服务器
|
||||
|
||||
除了部署到 Vercel,你还可以部署到自己的服务器上
|
||||
|
||||
1. 部署网易云 API,详情参见 [Binaryify/NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi)
|
||||
2. 克隆本仓库
|
||||
|
||||
```sh
|
||||
git clone --recursive https://github.com/qier222/YesPlayMusic.git
|
||||
```
|
||||
|
||||
3. 安装依赖
|
||||
|
||||
```sh
|
||||
yarn install
|
||||
|
||||
```
|
||||
|
||||
4. (可选)使用 Nginx 反向代理 API,将 API 路径映射为 `/api`,如果 API 和网页不在同一个域名下的话(跨域),会有一些 bug。
|
||||
|
||||
5. 复制 `/.env.example` 文件为 `/.env`,修改里面 `VUE_APP_NETEASE_API_URL` 的值为网易云 API 地址。本地开发的话可以填写 API 地址为 `http://localhost:3000`,YesPlayMusic 地址为 `http://localhost:8080`。如果你使用了反向代理 API,可以填写 API 地址为 `/api`。
|
||||
|
||||
```
|
||||
VUE_APP_NETEASE_API_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
6. 编译打包
|
||||
|
||||
```sh
|
||||
yarn run build
|
||||
```
|
||||
|
||||
7. 将 `/dist` 目录下的文件上传到你的 Web 服务器
|
||||
|
||||
## ⚙️ Docker 部署
|
||||
|
||||
1. 构建 Docker Image
|
||||
|
||||
```sh
|
||||
docker build -t yesplaymusic .
|
||||
```
|
||||
|
||||
2. 启动 Docker Container
|
||||
|
||||
```sh
|
||||
docker run -d --name YesPlayMusic -p 80:80 yesplaymusic
|
||||
```
|
||||
|
||||
3. Docker Compose 启动
|
||||
|
||||
```sh
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
YesPlayMusic 地址为 `http://localhost`
|
||||
|
||||
## 👷♂️ 打包客户端
|
||||
|
||||
如果在 Release 页面没有找到适合你的设备的安装包的话,你可以根据下面的步骤来打包自己的客户端。
|
||||
|
||||
1. 打包 Electron 需要用到 Node.js 和 Yarn。可前往 [Node.js 官网](https://nodejs.org/zh-cn/) 下载安装包。安装 Node.js
|
||||
后可在终端里执行 `npm install -g yarn` 来安装 Yarn。
|
||||
|
||||
2. 使用 `git clone --recursive https://github.com/qier222/YesPlayMusic.git` 克隆本仓库到本地。
|
||||
|
||||
3. 使用 `yarn install` 安装项目依赖。
|
||||
|
||||
4. 复制 `/.env.example` 文件为 `/.env` 。
|
||||
|
||||
5. 选择下列表格的命令来打包适合的你的安装包,打包出来的文件在 `/dist_electron` 目录下。了解更多信息可访问 [electron-builder 文档](https://www.electron.build/cli)
|
||||
|
||||
| 命令 | 说明 |
|
||||
| ------------------------------------------ | ------------------------- |
|
||||
| `yarn electron:build --windows nsis:ia32` | Windows 32 位 |
|
||||
| `yarn electron:build --windows nsis:arm64` | Windows ARM |
|
||||
| `yarn electron:build --linux deb:armv7l` | Debian armv7l(树莓派等) |
|
||||
| `yarn electron:build --macos dir:arm64` | macOS ARM |
|
||||
|
||||
## :computer: 配置开发环境
|
||||
|
||||
本项目由 [NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi) 提供 API。
|
||||
|
||||
运行本项目
|
||||
|
||||
```shell
|
||||
# 安装依赖
|
||||
yarn install
|
||||
|
||||
# 创建本地环境变量
|
||||
cp .env.example .env
|
||||
|
||||
# 运行(网页端)
|
||||
yarn serve
|
||||
|
||||
# 运行(electron)
|
||||
yarn electron:serve
|
||||
```
|
||||
|
||||
本地运行 NeteaseCloudMusicApi,或者将 API [部署至 Vercel](#%EF%B8%8F-部署至-vercel)
|
||||
|
||||
```shell
|
||||
# 运行 API (默认 3000 端口)
|
||||
yarn netease_api:run
|
||||
```
|
||||
|
||||
## ☑️ Todo
|
||||
|
||||
查看 Todo 请访问本项目的 [Projects](https://github.com/qier222/YesPlayMusic/projects/1)
|
||||
|
||||
欢迎提 Issue 和 Pull request。
|
||||
- Windows 用户可以通过 Scoop 来安装:`scoop install extras/yesplaymusic` -->
|
||||
|
||||
## 📜 开源许可
|
||||
|
||||
本项目仅供个人学习研究使用,禁止用于商业及非法用途。
|
||||
|
||||
基于 [MIT license](https://opensource.org/licenses/MIT) 许可进行开源。
|
||||
|
||||
## 灵感来源
|
||||
|
||||
API 源代码来自 [Binaryify/NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi)
|
||||
|
||||
- [Apple Music](https://music.apple.com)
|
||||
- [YouTube Music](https://music.youtube.com)
|
||||
- [Spotify](https://www.spotify.com)
|
||||
- [网易云音乐](https://music.163.com)
|
||||
本项目仅供个人学习研究之目的,不得用于任何商业或非法活动。
|
||||
|
||||
## 🖼️ 截图
|
||||
基于 [AGPL license](https://opensource.org/licenses/AGPL) 许可进行开源。
|
||||
|
||||
![lyrics][lyrics-screenshot]
|
||||
任何基于此项目开发的项目都必须遵守开源协议,在项目 README/应用内的关于页面和介绍网站中明确说明基于此项目开发,并附上此项目 GitHub 页面的链接。
|
||||
|
||||
## Credit
|
||||
|
||||
Designed by [JACKCRING](https://jackcring.com)
|
||||
|
||||
<!-- ## 🖼️ 截图 -->
|
||||
|
||||
<!-- ![lyrics][lyrics-screenshot]
|
||||
![library-dark][library-dark-screenshot]
|
||||
![album][album-screenshot]
|
||||
![home-2][home-2-screenshot]
|
||||
![artist][artist-screenshot]
|
||||
![search][search-screenshot]
|
||||
![home][home-screenshot]
|
||||
![explore][explore-screenshot]
|
||||
![explore][explore-screenshot] -->
|
||||
|
||||
<!-- MARKDOWN LINKS & IMAGES -->
|
||||
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
[
|
||||
'@vue/cli-plugin-babel/preset',
|
||||
{
|
||||
useBuiltIns: 'usage',
|
||||
shippedProposals: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
Before Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 474 B |
|
Before Width: | Height: | Size: 750 B |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 965 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 276 KiB |
|
|
@ -1,9 +0,0 @@
|
|||
services:
|
||||
YesPlayMusic:
|
||||
build:
|
||||
context: .
|
||||
image: yesplaymusic
|
||||
container_name: YesPlayMusic
|
||||
ports:
|
||||
- 80:80
|
||||
restart: always
|
||||
BIN
images/album.png
|
Before Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 312 KiB |
BIN
images/home.png
|
Before Width: | Height: | Size: 389 KiB |
|
Before Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 324 KiB |
BIN
images/logo.png
|
Before Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 339 KiB |
|
Before Width: | Height: | Size: 276 KiB |
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
// 支持 @ 的别名解析
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"target": "ES6",
|
||||
"module": "commonjs",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
150
package.json
|
|
@ -1,130 +1,34 @@
|
|||
{
|
||||
"name": "yesplaymusic",
|
||||
"version": "0.4.4",
|
||||
"name": "r3play",
|
||||
"productName": "R3PLAY",
|
||||
"private": true,
|
||||
"description": "A third party music player for Netease Music",
|
||||
"author": "qier222<qier222@outlook.com>",
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint",
|
||||
"electron:build": "vue-cli-service electron:build -p never",
|
||||
"electron:build-all": "vue-cli-service electron:build -p never -mwl",
|
||||
"electron:build-mac": "vue-cli-service electron:build -p never -m",
|
||||
"electron:build-win": "vue-cli-service electron:build -p never -w",
|
||||
"electron:build-linux": "vue-cli-service electron:build -p never -l",
|
||||
"electron:serve": "vue-cli-service electron:serve",
|
||||
"electron:buildicon": "electron-icon-builder --input=./build/icons/icon.png --output=build --flatten",
|
||||
"electron:publish": "vue-cli-service electron:build -mwl -p always",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"postuninstall": "electron-builder install-app-deps",
|
||||
"prettier": "npx prettier --write ./src",
|
||||
"netease_api:run": "npx NeteaseCloudMusicApi"
|
||||
"version": "2.0.0",
|
||||
"description": "A nifty third-party NetEase Music player",
|
||||
"homepage": "https://github.com/qier222/YesPlayMusic",
|
||||
"license": "MIT",
|
||||
"author": "qier222 <qier222@outlook.com>",
|
||||
"repository": "github:qier222/YesPlayMusic",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"main": "background.js",
|
||||
"dependencies": {
|
||||
"@unblockneteasemusic/server": "v0.27.0-rc.4",
|
||||
"NeteaseCloudMusicApi": "^4.5.2",
|
||||
"axios": "^0.21.0",
|
||||
"change-case": "^4.1.2",
|
||||
"cli-color": "^2.0.0",
|
||||
"color": "^3.1.3",
|
||||
"core-js": "^3.6.5",
|
||||
"crypto-js": "^4.0.0",
|
||||
"dayjs": "^1.8.36",
|
||||
"dexie": "^3.0.3",
|
||||
"discord-rich-presence": "^0.0.8",
|
||||
"electron": "^13.6.7",
|
||||
"electron-builder": "^23.0.0",
|
||||
"electron-context-menu": "^2.3.0",
|
||||
"electron-debug": "^3.1.0",
|
||||
"electron-devtools-installer": "^3.2",
|
||||
"electron-icon-builder": "^1.0.2",
|
||||
"electron-is-dev": "^1.2.0",
|
||||
"electron-log": "^4.3.0",
|
||||
"electron-store": "^6.0.1",
|
||||
"electron-updater": "^4.3.5",
|
||||
"express": "^4.17.1",
|
||||
"express-fileupload": "^1.2.0",
|
||||
"express-http-proxy": "^1.6.2",
|
||||
"extract-zip": "^2.0.1",
|
||||
"howler": "^2.2.3",
|
||||
"js-cookie": "^2.2.1",
|
||||
"jsbi": "^4.1.0",
|
||||
"lodash": "^4.17.20",
|
||||
"md5": "^2.3.0",
|
||||
"mpris-service": "^2.1.2",
|
||||
"music-metadata": "^7.5.3",
|
||||
"node-vibrant": "^3.2.1-alpha.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"pac-proxy-agent": "^4.1.0",
|
||||
"plyr": "^3.6.2",
|
||||
"prettier": "2.5.1",
|
||||
"qrcode": "^1.4.4",
|
||||
"register-service-worker": "^1.7.1",
|
||||
"svg-sprite-loader": "^5.0.0",
|
||||
"tunnel": "^0.0.6",
|
||||
"vscode-codicons": "^0.0.17",
|
||||
"vue": "^2.6.11",
|
||||
"vue-analytics": "^5.22.1",
|
||||
"vue-clipboard2": "^0.3.1",
|
||||
"vue-i18n": "^8.22.0",
|
||||
"vue-router": "^3.4.3",
|
||||
"vue-slider-component": "^3.2.5",
|
||||
"vuex": "^3.4.0",
|
||||
"x11": "^2.3.0"
|
||||
"packageManager": "pnpm@7.20.0",
|
||||
"scripts": {
|
||||
"install": "turbo run post-install --parallel --no-cache",
|
||||
"build": "cross-env-shell IS_ELECTRON=yes turbo run build",
|
||||
"build:web": "turbo run build:web",
|
||||
"pack": "turbo run build && turbo run pack",
|
||||
"pack:test": "turbo run build && turbo run pack:test",
|
||||
"dev": "cross-env-shell IS_ELECTRON=yes turbo run dev --parallel",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,mjs,js,jsx,md,css}\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^17.0.0",
|
||||
"@vue/cli-plugin-babel": "~4.5.0",
|
||||
"@vue/cli-plugin-eslint": "~4.5.0",
|
||||
"@vue/cli-plugin-pwa": "~4.5.0",
|
||||
"@vue/cli-plugin-vuex": "~4.5.0",
|
||||
"@vue/cli-service": "~4.5.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^6.7.2",
|
||||
"eslint-config-prettier": "^8.1.0",
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-plugin-vue": "^7.9.0",
|
||||
"husky": "^4.3.0",
|
||||
"sass": "^1.26.11",
|
||||
"sass-loader": "^10.0.2",
|
||||
"vue-cli-plugin-electron-builder": "~2.1.1",
|
||||
"vue-template-compiler": "^2.6.11"
|
||||
},
|
||||
"resolutions": {
|
||||
"icon-gen": "3.0.0",
|
||||
"degenerator": "2.2.0",
|
||||
"electron-builder": "^23.0.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true,
|
||||
"browser": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:vue/essential",
|
||||
"plugin:vue/recommended",
|
||||
"plugin:prettier/recommended",
|
||||
"eslint:recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"parser": "babel-eslint"
|
||||
},
|
||||
"globals": {
|
||||
"ipcRenderer": "off"
|
||||
},
|
||||
"rules": {}
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not dead"
|
||||
],
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npm run prettier"
|
||||
}
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.31.0",
|
||||
"prettier": "^2.8.1",
|
||||
"turbo": "^1.8.3",
|
||||
"typescript": "^4.9.5",
|
||||
"tsx": "^3.12.1",
|
||||
"prettier-plugin-tailwindcss": "^0.2.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
135
packages/desktop/.electron-builder.config.js
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* @type {import('electron-builder').Configuration}
|
||||
* @see https://www.electron.build/configuration/configuration
|
||||
*/
|
||||
|
||||
const pkg = require('./package.json')
|
||||
const electronVersion = pkg.devDependencies.electron.replaceAll('^', '')
|
||||
|
||||
module.exports = {
|
||||
appId: 'app.r3play',
|
||||
productName: pkg.productName,
|
||||
executableName: pkg.productName,
|
||||
copyright: 'Copyright © 2023 qier222',
|
||||
asar: true,
|
||||
directories: {
|
||||
output: 'release',
|
||||
buildResources: 'build',
|
||||
},
|
||||
npmRebuild: false,
|
||||
buildDependenciesFromSource: false,
|
||||
electronVersion,
|
||||
forceCodeSigning: false,
|
||||
afterPack: './scripts/copySQLite3.js',
|
||||
publish: [
|
||||
{
|
||||
provider: 'github',
|
||||
owner: 'qier222',
|
||||
repo: 'YesPlayMusic',
|
||||
vPrefixedTagName: true,
|
||||
releaseType: 'draft',
|
||||
},
|
||||
],
|
||||
win: {
|
||||
target: [
|
||||
{
|
||||
target: 'nsis',
|
||||
arch: ['x64'],
|
||||
},
|
||||
// {
|
||||
// target: 'portable',
|
||||
// arch: ['x64'],
|
||||
// },
|
||||
],
|
||||
publisherName: 'qier222',
|
||||
icon: 'build/icons/icon.ico',
|
||||
},
|
||||
nsis: {
|
||||
oneClick: false,
|
||||
perMachine: true,
|
||||
allowToChangeInstallationDirectory: true,
|
||||
deleteAppDataOnUninstall: true,
|
||||
artifactName: '${productName}-${version}-${os}-${arch}-Setup.${ext}',
|
||||
},
|
||||
portable: {
|
||||
artifactName: '${productName}-${version}-${os}-${arch}-Portable.${ext}',
|
||||
},
|
||||
mac: {
|
||||
target: [
|
||||
{
|
||||
target: 'dmg',
|
||||
arch: ['x64', 'arm64', 'universal'],
|
||||
},
|
||||
],
|
||||
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
||||
darkModeSupport: true,
|
||||
category: 'public.app-category.music',
|
||||
identity: null,
|
||||
},
|
||||
dmg: {
|
||||
icon: 'build/icons/icon.icns',
|
||||
},
|
||||
linux: {
|
||||
target: [
|
||||
// {
|
||||
// target: 'deb',
|
||||
// arch: [
|
||||
// 'x64',
|
||||
// // 'arm64',
|
||||
// // 'armv7l'
|
||||
// ],
|
||||
// },
|
||||
{
|
||||
target: 'AppImage',
|
||||
arch: ['x64'],
|
||||
},
|
||||
// {
|
||||
// target: 'snap',
|
||||
// arch: ['x64'],
|
||||
// },
|
||||
// {
|
||||
// target: 'pacman',
|
||||
// arch: ['x64'],
|
||||
// },
|
||||
// {
|
||||
// target: 'rpm',
|
||||
// arch: ['x64'],
|
||||
// },
|
||||
// {
|
||||
// target: 'tar.gz',
|
||||
// arch: ['x64'],
|
||||
// },
|
||||
],
|
||||
artifactName: '${productName}-${version}-${os}.${ext}',
|
||||
category: 'Music',
|
||||
icon: './build/icon.icns',
|
||||
},
|
||||
files: [
|
||||
'!**/*.ts',
|
||||
'!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}',
|
||||
'!.editorconfig',
|
||||
'!**/._*',
|
||||
'!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}',
|
||||
'!**/{pnpm-lock.yaml}',
|
||||
'!**/*.{map,debug.min.js}',
|
||||
'!**/node_modules/*',
|
||||
|
||||
{
|
||||
from: './dist',
|
||||
to: './main',
|
||||
},
|
||||
{
|
||||
from: '../web/dist',
|
||||
to: './web',
|
||||
},
|
||||
{
|
||||
from: './migrations',
|
||||
to: 'main/migrations',
|
||||
},
|
||||
{
|
||||
from: './assets',
|
||||
to: 'main/assets',
|
||||
},
|
||||
'./main',
|
||||
],
|
||||
}
|
||||
BIN
packages/desktop/assets/icons/taskbar/next.png
Normal file
|
After Width: | Height: | Size: 936 B |
BIN
packages/desktop/assets/icons/taskbar/pause.png
Normal file
|
After Width: | Height: | Size: 612 B |
BIN
packages/desktop/assets/icons/taskbar/play.png
Normal file
|
After Width: | Height: | Size: 844 B |
BIN
packages/desktop/assets/icons/taskbar/previous.png
Normal file
|
After Width: | Height: | Size: 890 B |
|
Before Width: | Height: | Size: 223 B After Width: | Height: | Size: 223 B |
|
Before Width: | Height: | Size: 191 B After Width: | Height: | Size: 191 B |
|
Before Width: | Height: | Size: 308 B After Width: | Height: | Size: 308 B |
|
Before Width: | Height: | Size: 311 B After Width: | Height: | Size: 311 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 953 B After Width: | Height: | Size: 953 B |
|
Before Width: | Height: | Size: 396 B After Width: | Height: | Size: 396 B |
|
Before Width: | Height: | Size: 344 B After Width: | Height: | Size: 344 B |
|
Before Width: | Height: | Size: 218 B After Width: | Height: | Size: 218 B |
|
Before Width: | Height: | Size: 932 B After Width: | Height: | Size: 932 B |
BIN
packages/desktop/build/icons/1024x1024.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
packages/desktop/build/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
packages/desktop/build/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
packages/desktop/build/icons/24x24.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
packages/desktop/build/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
packages/desktop/build/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
packages/desktop/build/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
packages/desktop/build/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
packages/desktop/build/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
packages/desktop/build/icons/icon.icns
Normal file
BIN
packages/desktop/build/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
packages/desktop/build/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
42
packages/desktop/main/appServer/appServer.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import path from 'path'
|
||||
import { isProd } from '../env'
|
||||
import log from '../log'
|
||||
import appleMusic from './routes/r3play/appleMusic'
|
||||
import netease from './routes/netease/netease'
|
||||
import audio from './routes/r3play/audio'
|
||||
import fastifyCookie from '@fastify/cookie'
|
||||
import fastifyMultipart from '@fastify/multipart'
|
||||
import fastifyStatic from '@fastify/static'
|
||||
import fastify from 'fastify'
|
||||
|
||||
log.info('[electron] appServer/appServer.ts')
|
||||
|
||||
const initAppServer = async () => {
|
||||
const server = fastify({
|
||||
ignoreTrailingSlash: true,
|
||||
})
|
||||
|
||||
server.register(fastifyCookie)
|
||||
server.register(fastifyMultipart)
|
||||
if (isProd) {
|
||||
server.register(fastifyStatic, {
|
||||
root: path.join(__dirname, '../web'),
|
||||
})
|
||||
}
|
||||
|
||||
server.register(netease)
|
||||
server.register(audio)
|
||||
server.register(appleMusic)
|
||||
|
||||
const port = Number(
|
||||
isProd
|
||||
? process.env.ELECTRON_WEB_SERVER_PORT || 42710
|
||||
: process.env.ELECTRON_DEV_NETEASE_API_PORT || 30001
|
||||
)
|
||||
await server.listen({ port })
|
||||
log.info(`[appServer] http server listening on port ${port}`)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
export default initAppServer
|
||||
65
packages/desktop/main/appServer/routes/netease/netease.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import cache from '../../../cache'
|
||||
import log from '@/desktop/main/log'
|
||||
import { CacheAPIs } from '@/shared/CacheAPIs'
|
||||
import { pathCase, snakeCase } from 'change-case'
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import NeteaseCloudMusicApi from 'NeteaseCloudMusicApi'
|
||||
|
||||
log.info('[electron] appServer/routes/netease.ts')
|
||||
|
||||
async function netease(fastify: FastifyInstance) {
|
||||
const getHandler = (name: string, neteaseApi: (params: any) => any) => {
|
||||
return async (
|
||||
req: FastifyRequest<{ Querystring: { [key: string]: string } }>,
|
||||
reply: FastifyReply
|
||||
) => {
|
||||
console.log(req.routerPath)
|
||||
// Get track details from cache
|
||||
if (name === CacheAPIs.Track) {
|
||||
const cacheData = await cache.get(name, req.query as any)
|
||||
if (cacheData) {
|
||||
return cacheData
|
||||
}
|
||||
}
|
||||
|
||||
// Request netease api
|
||||
try {
|
||||
const result = await neteaseApi({
|
||||
...req.query,
|
||||
cookie: req.cookies,
|
||||
})
|
||||
|
||||
cache.set(name as CacheAPIs, result.body, req.query)
|
||||
|
||||
return reply.send(result.body)
|
||||
} catch (error: any) {
|
||||
if ([400, 301].includes(error.status)) {
|
||||
return reply.status(error.status).send(error.body)
|
||||
}
|
||||
return reply.status(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 循环注册NeteaseCloudMusicApi所有接口
|
||||
Object.entries(NeteaseCloudMusicApi).forEach(([nameInSnakeCase, neteaseApi]: [string, any]) => {
|
||||
// 例外
|
||||
if (
|
||||
['serveNcmApi', 'getModulesDefinitions', snakeCase(CacheAPIs.SongUrl)].includes(
|
||||
nameInSnakeCase
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const name = pathCase(nameInSnakeCase)
|
||||
const handler = getHandler(name, neteaseApi)
|
||||
|
||||
fastify.get(`/netease/${name}`, handler)
|
||||
fastify.post(`/netease/${name}`, handler)
|
||||
})
|
||||
|
||||
fastify.get('/netease', () => 'NeteaseCloudMusicApi')
|
||||
}
|
||||
|
||||
export default netease
|
||||
16
packages/desktop/main/appServer/routes/r3play/appleMusic.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { FastifyInstance } from 'fastify'
|
||||
import proxy from '@fastify/http-proxy'
|
||||
import { isDev } from '@/desktop/main/env'
|
||||
import log from '@/desktop/main/log'
|
||||
|
||||
log.info('[electron] appServer/routes/r3play/appleMusic.ts')
|
||||
|
||||
async function appleMusic(fastify: FastifyInstance) {
|
||||
fastify.register(proxy, {
|
||||
upstream: isDev ? 'http://127.0.0.1:35530/' : 'http://168.138.174.244:35530/',
|
||||
prefix: '/r3play/apple-music',
|
||||
rewritePrefix: '/apple-music',
|
||||
})
|
||||
}
|
||||
|
||||
export default appleMusic
|
||||
224
packages/desktop/main/appServer/routes/r3play/audio.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import NeteaseCloudMusicApi, { SoundQualityType } from 'NeteaseCloudMusicApi'
|
||||
import { app } from 'electron'
|
||||
import log from '@/desktop/main/log'
|
||||
import { appName } from '@/desktop/main/env'
|
||||
import cache from '@/desktop/main/cache'
|
||||
import fs from 'fs'
|
||||
import youtube from '@/desktop/main/youtube'
|
||||
import { CacheAPIs } from '@/shared/CacheAPIs'
|
||||
import { FetchTracksResponse } from '@/shared/api/Track'
|
||||
import store from '@/desktop/main/store'
|
||||
import { db, Tables } from '@/desktop/main/db'
|
||||
|
||||
log.info('[electron] appServer/routes/r3play/audio.ts')
|
||||
|
||||
const getAudioFromCache = async (id: number) => {
|
||||
// get from cache
|
||||
const cache = await db.find(Tables.Audio, id)
|
||||
if (!cache) return
|
||||
|
||||
const audioFileName = `${cache.id}-${cache.bitRate}.${cache.format}`
|
||||
|
||||
const isAudioFileExists = fs.existsSync(`${app.getPath('userData')}/audio_cache/${audioFileName}`)
|
||||
if (!isAudioFileExists) return
|
||||
|
||||
log.debug(`[server] Audio cache hit ${id}`)
|
||||
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
source: cache.source,
|
||||
id: cache.id,
|
||||
url: `http://127.0.0.1:${
|
||||
process.env.ELECTRON_WEB_SERVER_PORT
|
||||
}/${appName.toLowerCase()}/audio/${audioFileName}`,
|
||||
br: cache.bitRate,
|
||||
size: 0,
|
||||
md5: '',
|
||||
code: 200,
|
||||
expi: 0,
|
||||
type: cache.format,
|
||||
gain: 0,
|
||||
fee: 8,
|
||||
uf: null,
|
||||
payed: 0,
|
||||
flag: 4,
|
||||
canExtend: false,
|
||||
freeTrialInfo: null,
|
||||
level: 'standard',
|
||||
encodeType: cache.format,
|
||||
freeTrialPrivilege: {
|
||||
resConsumable: false,
|
||||
userConsumable: false,
|
||||
listenType: null,
|
||||
},
|
||||
freeTimeTrialPrivilege: {
|
||||
resConsumable: false,
|
||||
userConsumable: false,
|
||||
type: 0,
|
||||
remainTime: 0,
|
||||
},
|
||||
urlSource: 0,
|
||||
},
|
||||
],
|
||||
code: 200,
|
||||
}
|
||||
}
|
||||
|
||||
const getAudioFromYouTube = async (id: number) => {
|
||||
let fetchTrackResult: FetchTracksResponse | undefined = await cache.get(CacheAPIs.Track, {
|
||||
ids: String(id),
|
||||
})
|
||||
if (!fetchTrackResult) {
|
||||
log.info(`[audio] getAudioFromYouTube no fetchTrackResult, fetch from netease api`)
|
||||
fetchTrackResult = (await NeteaseCloudMusicApi.song_detail({
|
||||
ids: String(id),
|
||||
})) as unknown as FetchTracksResponse
|
||||
}
|
||||
const track = fetchTrackResult?.songs?.[0]
|
||||
if (!track) return
|
||||
|
||||
try {
|
||||
const data = await youtube.matchTrack(track.ar[0].name, track.name)
|
||||
if (!data) return
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
source: 'youtube',
|
||||
id,
|
||||
url: data.url,
|
||||
br: data.bitRate,
|
||||
size: 0,
|
||||
md5: '',
|
||||
code: 200,
|
||||
expi: 0,
|
||||
type: 'opus',
|
||||
gain: 0,
|
||||
fee: 8,
|
||||
uf: null,
|
||||
payed: 0,
|
||||
flag: 4,
|
||||
canExtend: false,
|
||||
freeTrialInfo: null,
|
||||
level: 'standard',
|
||||
encodeType: 'opus',
|
||||
freeTrialPrivilege: {
|
||||
resConsumable: false,
|
||||
userConsumable: false,
|
||||
listenType: null,
|
||||
},
|
||||
freeTimeTrialPrivilege: {
|
||||
resConsumable: false,
|
||||
userConsumable: false,
|
||||
type: 0,
|
||||
remainTime: 0,
|
||||
},
|
||||
urlSource: 0,
|
||||
r3play: {
|
||||
youtube: data,
|
||||
},
|
||||
},
|
||||
],
|
||||
code: 200,
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('getAudioFromYouTube error', id, e)
|
||||
}
|
||||
}
|
||||
|
||||
async function audio(fastify: FastifyInstance) {
|
||||
// 劫持网易云的song/url api,将url替换成缓存的音频文件url
|
||||
fastify.get(
|
||||
'/netease/song/url/v1',
|
||||
async (
|
||||
req: FastifyRequest<{ Querystring: { id: string | number; level: SoundQualityType } }>,
|
||||
reply
|
||||
) => {
|
||||
const id = Number(req.query.id) || 0
|
||||
if (!id || isNaN(id)) {
|
||||
return reply.status(400).send({
|
||||
code: 400,
|
||||
msg: 'id is required or id is invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const cache = await getAudioFromCache(id)
|
||||
if (cache) {
|
||||
return cache
|
||||
}
|
||||
|
||||
const { body: fromNetease }: { body: any } = await NeteaseCloudMusicApi.song_url_v1({
|
||||
...req.query,
|
||||
cookie: req.cookies as unknown as any,
|
||||
})
|
||||
if (
|
||||
fromNetease?.code === 200 &&
|
||||
!fromNetease?.data?.[0]?.freeTrialInfo &&
|
||||
fromNetease?.data?.[0]?.url
|
||||
) {
|
||||
reply.status(200).send(fromNetease)
|
||||
return
|
||||
}
|
||||
|
||||
if (store.get('settings.enableFindTrackOnYouTube')) {
|
||||
const fromYoutube = getAudioFromYouTube(id)
|
||||
if (fromYoutube) {
|
||||
return fromYoutube
|
||||
}
|
||||
}
|
||||
|
||||
// 是试听歌曲就把url删掉
|
||||
if (fromNetease?.data?.[0].freeTrialInfo) {
|
||||
fromNetease.data[0].url = ''
|
||||
}
|
||||
|
||||
reply.status(fromNetease?.code ?? 500).send(fromNetease)
|
||||
}
|
||||
)
|
||||
|
||||
// 获取缓存的音频数据
|
||||
fastify.get(
|
||||
`/${appName.toLowerCase()}/audio/:filename`,
|
||||
(req: FastifyRequest<{ Params: { filename: string } }>, reply) => {
|
||||
const filename = req.params.filename
|
||||
cache.getAudio(filename, reply)
|
||||
}
|
||||
)
|
||||
|
||||
// 缓存音频数据
|
||||
fastify.post(
|
||||
`/${appName.toLowerCase()}/audio/:id`,
|
||||
async (
|
||||
req: FastifyRequest<{
|
||||
Params: { id: string }
|
||||
Querystring: { url: string; bitrate: number }
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
const id = Number(req.params.id)
|
||||
const { url, bitrate } = req.query
|
||||
if (isNaN(id)) {
|
||||
return reply.status(400).send({ error: 'Invalid param id' })
|
||||
}
|
||||
if (!url) {
|
||||
return reply.status(400).send({ error: 'Invalid query url' })
|
||||
}
|
||||
|
||||
const data = await req.file()
|
||||
|
||||
if (!data?.file) {
|
||||
return reply.status(400).send({ error: 'No file' })
|
||||
}
|
||||
|
||||
try {
|
||||
await cache.setAudio(await data.toBuffer(), { id, url, bitrate })
|
||||
reply.status(200).send('Audio cached!')
|
||||
} catch (error) {
|
||||
reply.status(500).send({ error })
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export default audio
|
||||
324
packages/desktop/main/cache.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import { db, Tables } from './db'
|
||||
import type { FetchTracksResponse } from '@/shared/api/Track'
|
||||
import { app } from 'electron'
|
||||
import log from './log'
|
||||
import fs from 'fs'
|
||||
import * as musicMetadata from 'music-metadata'
|
||||
import { CacheAPIs, CacheAPIsParams } from '@/shared/CacheAPIs'
|
||||
import { TablesStructures } from './db'
|
||||
import { FastifyReply } from 'fastify'
|
||||
|
||||
log.info('[electron] cache.ts')
|
||||
|
||||
class Cache {
|
||||
constructor() {
|
||||
//
|
||||
}
|
||||
|
||||
async set(api: string, data: any, query: any = {}) {
|
||||
switch (api) {
|
||||
case CacheAPIs.UserPlaylist:
|
||||
case CacheAPIs.UserAccount:
|
||||
case CacheAPIs.Personalized:
|
||||
case CacheAPIs.RecommendResource:
|
||||
case CacheAPIs.UserAlbums:
|
||||
case CacheAPIs.UserArtists:
|
||||
case CacheAPIs.ListenedRecords:
|
||||
case CacheAPIs.Likelist: {
|
||||
if (!data) return
|
||||
db.upsert(Tables.AccountData, {
|
||||
id: api,
|
||||
json: JSON.stringify(data),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
case CacheAPIs.Track: {
|
||||
const res = data as FetchTracksResponse
|
||||
if (!res.songs) return
|
||||
const tracks = res.songs.map(t => ({
|
||||
id: t.id,
|
||||
json: JSON.stringify(t),
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
db.upsertMany(Tables.Track, tracks)
|
||||
break
|
||||
}
|
||||
case CacheAPIs.Album: {
|
||||
if (!data.album) return
|
||||
data.album.songs = data.songs
|
||||
db.upsert(Tables.Album, {
|
||||
id: data.album.id,
|
||||
json: JSON.stringify(data.album),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
case CacheAPIs.Playlist: {
|
||||
if (!data.playlist) return
|
||||
db.upsert(Tables.Playlist, {
|
||||
id: data.playlist.id,
|
||||
json: JSON.stringify(data),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
case CacheAPIs.Artist: {
|
||||
if (!data.artist) return
|
||||
db.upsert(Tables.Artist, {
|
||||
id: data.artist.id,
|
||||
json: JSON.stringify(data),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
case CacheAPIs.ArtistAlbum: {
|
||||
if (!data.hotAlbums) return
|
||||
db.createMany(
|
||||
Tables.Album,
|
||||
data.hotAlbums.map((a: Album) => ({
|
||||
id: a.id,
|
||||
json: JSON.stringify(a),
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
)
|
||||
const modifiedData = {
|
||||
...data,
|
||||
hotAlbums: data.hotAlbums.map((a: Album) => a.id),
|
||||
}
|
||||
db.upsert(Tables.ArtistAlbum, {
|
||||
id: data.artist.id,
|
||||
json: JSON.stringify(modifiedData),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
case CacheAPIs.Lyric: {
|
||||
if (!data.lrc) return
|
||||
db.upsert(Tables.Lyrics, {
|
||||
id: query.id,
|
||||
json: JSON.stringify(data),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
case CacheAPIs.CoverColor: {
|
||||
if (!data.id || !data.color) return
|
||||
if (/^#([a-fA-F0-9]){3}$|[a-fA-F0-9]{6}$/.test(data.color) === false) {
|
||||
return
|
||||
}
|
||||
db.upsert(Tables.CoverColor, {
|
||||
id: data.id,
|
||||
color: data.color,
|
||||
queriedAt: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
case CacheAPIs.AppleMusicAlbum: {
|
||||
if (!data.id) return
|
||||
db.upsert(Tables.AppleMusicAlbum, {
|
||||
id: data.id,
|
||||
json: data.album ? JSON.stringify(data.album) : 'no',
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
case CacheAPIs.AppleMusicArtist: {
|
||||
if (!data) return
|
||||
db.upsert(Tables.AppleMusicArtist, {
|
||||
id: data.id,
|
||||
json: data.artist ? JSON.stringify(data.artist) : 'no',
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get<T extends keyof CacheAPIsParams>(api: T, params: any): any {
|
||||
switch (api) {
|
||||
case CacheAPIs.UserPlaylist:
|
||||
case CacheAPIs.UserAccount:
|
||||
case CacheAPIs.Personalized:
|
||||
case CacheAPIs.RecommendResource:
|
||||
case CacheAPIs.UserArtists:
|
||||
case CacheAPIs.ListenedRecords:
|
||||
case CacheAPIs.Likelist: {
|
||||
const data = db.find(Tables.AccountData, api)
|
||||
if (data?.json) return JSON.parse(data.json)
|
||||
break
|
||||
}
|
||||
case CacheAPIs.Track: {
|
||||
const ids: number[] = params?.ids.split(',').map((id: string) => Number(id))
|
||||
if (ids.length === 0) return
|
||||
|
||||
if (ids.includes(NaN)) return
|
||||
|
||||
const tracksRaw = db.findMany(Tables.Track, ids)
|
||||
|
||||
if (tracksRaw.length !== ids.length) {
|
||||
return
|
||||
}
|
||||
const tracks = ids.map(id => {
|
||||
const track = tracksRaw.find(t => t.id === Number(id)) as any
|
||||
return JSON.parse(track.json)
|
||||
})
|
||||
return {
|
||||
code: 200,
|
||||
songs: tracks,
|
||||
privileges: {},
|
||||
}
|
||||
}
|
||||
case CacheAPIs.Album: {
|
||||
if (isNaN(Number(params?.id))) return
|
||||
const data = db.find(Tables.Album, params.id)
|
||||
if (data?.json)
|
||||
return {
|
||||
resourceState: true,
|
||||
songs: [],
|
||||
code: 200,
|
||||
album: JSON.parse(data.json),
|
||||
}
|
||||
break
|
||||
}
|
||||
case CacheAPIs.Playlist: {
|
||||
if (isNaN(Number(params?.id))) return
|
||||
const data = db.find(Tables.Playlist, params.id)
|
||||
if (data?.json) return JSON.parse(data.json)
|
||||
break
|
||||
}
|
||||
case CacheAPIs.Artist: {
|
||||
if (isNaN(Number(params?.id))) return
|
||||
const data = db.find(Tables.Artist, params.id)
|
||||
const fromAppleData = db.find(Tables.AppleMusicArtist, params.id)
|
||||
const fromApple = fromAppleData?.json && JSON.parse(fromAppleData.json)
|
||||
const fromNetease = data?.json && JSON.parse(data.json)
|
||||
if (fromNetease && fromApple && fromApple !== 'no') {
|
||||
fromNetease.artist.img1v1Url = fromApple.attributes.artwork.url
|
||||
fromNetease.artist.briefDesc = fromApple.attributes.artistBio
|
||||
}
|
||||
return fromNetease ? fromNetease : undefined
|
||||
}
|
||||
case CacheAPIs.ArtistAlbum: {
|
||||
if (isNaN(Number(params?.id))) return
|
||||
|
||||
const artistAlbumsRaw = db.find(Tables.ArtistAlbum, params.id)
|
||||
if (!artistAlbumsRaw?.json) return
|
||||
const artistAlbums = JSON.parse(artistAlbumsRaw.json)
|
||||
|
||||
const albumsRaw = db.findMany(Tables.Album, artistAlbums.hotAlbums)
|
||||
if (albumsRaw.length !== artistAlbums.hotAlbums.length) return
|
||||
const albums = albumsRaw.map(a => JSON.parse(a.json))
|
||||
|
||||
artistAlbums.hotAlbums = artistAlbums.hotAlbums.map((id: number) =>
|
||||
albums.find(a => a.id === id)
|
||||
)
|
||||
return artistAlbums
|
||||
}
|
||||
case CacheAPIs.Lyric: {
|
||||
if (isNaN(Number(params?.id))) return
|
||||
const data = db.find(Tables.Lyrics, params.id)
|
||||
if (data?.json) return JSON.parse(data.json)
|
||||
break
|
||||
}
|
||||
case CacheAPIs.CoverColor: {
|
||||
if (isNaN(Number(params?.id))) return
|
||||
return db.find(Tables.CoverColor, params.id)?.color
|
||||
}
|
||||
case CacheAPIs.Artist: {
|
||||
if (!params.ids?.length) return
|
||||
const artists = db.findMany(Tables.Artist, params.ids)
|
||||
if (artists.length !== params.ids.length) return
|
||||
const result = artists.map(a => JSON.parse(a.json))
|
||||
result.sort((a, b) => {
|
||||
const indexA: number = params.ids.indexOf(a.artist.id)
|
||||
const indexB: number = params.ids.indexOf(b.artist.id)
|
||||
return indexA - indexB
|
||||
})
|
||||
return result
|
||||
}
|
||||
case CacheAPIs.AppleMusicAlbum: {
|
||||
if (isNaN(Number(params?.id))) return
|
||||
const data = db.find(Tables.AppleMusicAlbum, params.id)
|
||||
if (data?.json && data.json !== 'no') return JSON.parse(data.json)
|
||||
break
|
||||
}
|
||||
case CacheAPIs.AppleMusicArtist: {
|
||||
if (isNaN(Number(params?.id))) return
|
||||
const data = db.find(Tables.AppleMusicArtist, params.id)
|
||||
if (data?.json && data.json !== 'no') return JSON.parse(data.json)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getAudio(fileName: string, reply: FastifyReply) {
|
||||
if (!fileName) {
|
||||
return reply.status(400).send({ error: 'No filename provided' })
|
||||
}
|
||||
const id = Number(fileName.split('-')[0])
|
||||
|
||||
try {
|
||||
const path = `${app.getPath('userData')}/audio_cache/${fileName}`
|
||||
const audio = fs.readFileSync(path)
|
||||
if (audio.byteLength === 0) {
|
||||
db.delete(Tables.Audio, id)
|
||||
fs.unlinkSync(path)
|
||||
return reply.status(404).send({ error: 'Audio not found' })
|
||||
}
|
||||
db.update(Tables.Audio, id, { queriedAt: Date.now() })
|
||||
reply
|
||||
.status(206)
|
||||
.header('Accept-Ranges', 'bytes')
|
||||
.header('Connection', 'keep-alive')
|
||||
.header('Content-Range', `bytes 0-${audio.byteLength - 1}/${audio.byteLength}`)
|
||||
.send(audio)
|
||||
} catch (error) {
|
||||
reply.status(500).send({ error })
|
||||
}
|
||||
}
|
||||
|
||||
async setAudio(buffer: Buffer, { id, url }: { id: number; url: string }) {
|
||||
const path = `${app.getPath('userData')}/audio_cache`
|
||||
|
||||
try {
|
||||
fs.statSync(path)
|
||||
} catch (e) {
|
||||
fs.mkdirSync(path)
|
||||
}
|
||||
|
||||
const meta = await musicMetadata.parseBuffer(buffer)
|
||||
const bitRate = meta?.format?.codec === 'OPUS' ? 165000 : meta.format.bitrate ?? 0
|
||||
const type =
|
||||
{
|
||||
'MPEG 1 Layer 3': 'mp3',
|
||||
'Ogg Vorbis': 'ogg',
|
||||
AAC: 'm4a',
|
||||
FLAC: 'flac',
|
||||
OPUS: 'opus',
|
||||
}[meta.format.codec ?? ''] ?? 'unknown'
|
||||
|
||||
let source: TablesStructures[Tables.Audio]['source'] = 'unknown'
|
||||
if (url.includes('googlevideo.com')) source = 'youtube'
|
||||
if (url.includes('126.net')) source = 'netease'
|
||||
|
||||
fs.writeFile(`${path}/${id}-${bitRate}.${type}`, buffer, error => {
|
||||
if (error) {
|
||||
return log.error(`[cache] cacheAudio failed: ${error}`)
|
||||
}
|
||||
log.info(`Audio file ${id}-${bitRate}.${type} cached!`)
|
||||
|
||||
db.upsert(Tables.Audio, {
|
||||
id,
|
||||
bitRate,
|
||||
format: type as TablesStructures[Tables.Audio]['format'],
|
||||
source,
|
||||
queriedAt: Date.now(),
|
||||
})
|
||||
|
||||
log.info(`[cache] cacheAudio ${id}-${bitRate}.${type}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default new Cache()
|
||||
245
packages/desktop/main/db.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import path from 'path'
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs'
|
||||
import SQLite3 from 'better-sqlite3'
|
||||
import log from './log'
|
||||
import { createFileIfNotExist, dirname } from './utils'
|
||||
import { isProd } from './env'
|
||||
import pkg from '../../../package.json'
|
||||
import { compare, validate } from 'compare-versions'
|
||||
import os from 'os'
|
||||
|
||||
log.info('[electron] db.ts')
|
||||
|
||||
export const enum Tables {
|
||||
Track = 'Track',
|
||||
Album = 'Album',
|
||||
Artist = 'Artist',
|
||||
Playlist = 'Playlist',
|
||||
ArtistAlbum = 'ArtistAlbum',
|
||||
Lyrics = 'Lyrics',
|
||||
Audio = 'Audio',
|
||||
AccountData = 'AccountData',
|
||||
CoverColor = 'CoverColor',
|
||||
AppData = 'AppData',
|
||||
AppleMusicAlbum = 'AppleMusicAlbum',
|
||||
AppleMusicArtist = 'AppleMusicArtist',
|
||||
}
|
||||
interface CommonTableStructure {
|
||||
id: number
|
||||
json: string
|
||||
updatedAt: number
|
||||
}
|
||||
export interface TablesStructures {
|
||||
[Tables.Track]: CommonTableStructure
|
||||
[Tables.Album]: CommonTableStructure
|
||||
[Tables.Artist]: CommonTableStructure
|
||||
[Tables.Playlist]: CommonTableStructure
|
||||
[Tables.ArtistAlbum]: CommonTableStructure
|
||||
[Tables.Lyrics]: CommonTableStructure
|
||||
[Tables.AccountData]: {
|
||||
id: string
|
||||
json: string
|
||||
updatedAt: number
|
||||
}
|
||||
[Tables.Audio]: {
|
||||
id: number
|
||||
bitRate: number
|
||||
format: 'mp3' | 'flac' | 'ogg' | 'wav' | 'm4a' | 'aac' | 'unknown' | 'opus'
|
||||
source:
|
||||
| 'unknown'
|
||||
| 'netease'
|
||||
| 'migu'
|
||||
| 'kuwo'
|
||||
| 'kugou'
|
||||
| 'youtube'
|
||||
| 'qq'
|
||||
| 'bilibili'
|
||||
| 'joox'
|
||||
queriedAt: number
|
||||
}
|
||||
[Tables.CoverColor]: {
|
||||
id: number
|
||||
color: string
|
||||
queriedAt: number
|
||||
}
|
||||
[Tables.AppData]: {
|
||||
id: 'appVersion' | 'skippedVersion'
|
||||
value: string
|
||||
}
|
||||
[Tables.AppleMusicAlbum]: CommonTableStructure
|
||||
[Tables.AppleMusicArtist]: CommonTableStructure
|
||||
}
|
||||
|
||||
type TableNames = keyof TablesStructures
|
||||
|
||||
const readSqlFile = (filename: string) => {
|
||||
return fs.readFileSync(path.join(dirname, `./migrations/${filename}`), 'utf8')
|
||||
}
|
||||
|
||||
class DB {
|
||||
sqlite: SQLite3.Database
|
||||
dbFilePath: string = path.resolve(app.getPath('userData'), './api_cache/db.sqlite')
|
||||
|
||||
constructor() {
|
||||
log.info('[db] Initializing database...')
|
||||
|
||||
try {
|
||||
createFileIfNotExist(this.dbFilePath)
|
||||
|
||||
this.sqlite = new SQLite3(this.dbFilePath, {
|
||||
nativeBinding: this.getBinPath(),
|
||||
})
|
||||
this.sqlite.pragma('auto_vacuum = FULL')
|
||||
this.initTables()
|
||||
this.migrate()
|
||||
|
||||
log.info('[db] Database initialized.')
|
||||
} catch (e) {
|
||||
log.error('[db] Database initialization failed.')
|
||||
log.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
private getBinPath() {
|
||||
console
|
||||
const devBinPath = path.resolve(
|
||||
app.getPath('userData'),
|
||||
`../../bin/better_sqlite3_${os.platform}_${os.arch}.node`
|
||||
)
|
||||
const prodBinPaths = {
|
||||
darwin: path.resolve(app.getPath('exe'), `../../Resources/bin/better_sqlite3.node`),
|
||||
win32: path.resolve(app.getPath('exe'), `../resources/bin/better_sqlite3.node`),
|
||||
linux: path.resolve(app.getPath('exe'), `../resources/bin/better_sqlite3.node`),
|
||||
}
|
||||
return isProd
|
||||
? prodBinPaths[os.platform as unknown as 'darwin' | 'win32' | 'linux']
|
||||
: devBinPath
|
||||
}
|
||||
|
||||
initTables() {
|
||||
log.info('[db] Initializing database tables...')
|
||||
const init = readSqlFile('init.sql')
|
||||
this.sqlite.exec(init)
|
||||
this.sqlite.pragma('journal_mode=WAL')
|
||||
log.info('[db] Database tables initialized.')
|
||||
}
|
||||
|
||||
migrate() {
|
||||
log.info('[db] Migrating database..')
|
||||
|
||||
const key = 'appVersion'
|
||||
const appVersion = this.find(Tables.AppData, key)
|
||||
const updateAppVersionInDB = () => {
|
||||
this.upsert(Tables.AppData, {
|
||||
id: key,
|
||||
value: pkg.version,
|
||||
})
|
||||
}
|
||||
|
||||
if (!appVersion?.value) {
|
||||
updateAppVersionInDB()
|
||||
return
|
||||
}
|
||||
|
||||
const sqlFiles = fs.readdirSync(path.join(dirname, './migrations'))
|
||||
sqlFiles.forEach((sqlFile: string) => {
|
||||
const version = sqlFile.split('.').shift() || ''
|
||||
if (!validate(version)) return
|
||||
if (compare(version, pkg.version, '>')) {
|
||||
const file = readSqlFile(sqlFile)
|
||||
this.sqlite.exec(file)
|
||||
}
|
||||
})
|
||||
|
||||
updateAppVersionInDB()
|
||||
|
||||
log.info('[db] Database migrated.')
|
||||
}
|
||||
|
||||
find<T extends TableNames>(
|
||||
table: T,
|
||||
key: TablesStructures[T]['id']
|
||||
): TablesStructures[T] | undefined {
|
||||
return this.sqlite.prepare(`SELECT * FROM ${table} WHERE id = ? LIMIT 1`).get(key)
|
||||
}
|
||||
|
||||
findMany<T extends TableNames>(
|
||||
table: T,
|
||||
keys: TablesStructures[T]['id'][]
|
||||
): TablesStructures[T][] {
|
||||
const idsQuery = keys.map(key => `id = ${key}`).join(' OR ')
|
||||
return this.sqlite.prepare(`SELECT * FROM ${table} WHERE ${idsQuery}`).all()
|
||||
}
|
||||
|
||||
findAll<T extends TableNames>(table: T): TablesStructures[T][] {
|
||||
return this.sqlite.prepare(`SELECT * FROM ${table}`).all()
|
||||
}
|
||||
|
||||
create<T extends TableNames>(table: T, data: TablesStructures[T], skipWhenExist: boolean = true) {
|
||||
if (skipWhenExist && db.find(table, data.id)) return
|
||||
return this.sqlite.prepare(`INSERT INTO ${table} VALUES (?)`).run(data)
|
||||
}
|
||||
|
||||
createMany<T extends TableNames>(
|
||||
table: T,
|
||||
data: TablesStructures[T][],
|
||||
skipWhenExist: boolean = true
|
||||
) {
|
||||
const valuesQuery = Object.keys(data[0])
|
||||
.map(key => `:${key}`)
|
||||
.join(', ')
|
||||
const insert = this.sqlite.prepare(
|
||||
`INSERT ${skipWhenExist ? 'OR IGNORE' : ''} INTO ${table} VALUES (${valuesQuery})`
|
||||
)
|
||||
const insertMany = this.sqlite.transaction((rows: any[]) => {
|
||||
rows.forEach((row: any) => insert.run(row))
|
||||
})
|
||||
insertMany(data)
|
||||
}
|
||||
|
||||
update<T extends TableNames>(
|
||||
table: T,
|
||||
key: TablesStructures[T]['id'],
|
||||
data: Partial<TablesStructures[T]>
|
||||
) {
|
||||
// TODO:
|
||||
}
|
||||
|
||||
upsert<T extends TableNames>(table: T, data: TablesStructures[T]) {
|
||||
const valuesQuery = Object.keys(data)
|
||||
.map(key => `:${key}`)
|
||||
.join(', ')
|
||||
return this.sqlite.prepare(`INSERT OR REPLACE INTO ${table} VALUES (${valuesQuery})`).run(data)
|
||||
}
|
||||
|
||||
upsertMany<T extends TableNames>(table: T, data: TablesStructures[T][]) {
|
||||
const valuesQuery = Object.keys(data[0])
|
||||
.map(key => `:${key}`)
|
||||
.join(', ')
|
||||
const upsert = this.sqlite.prepare(`INSERT OR REPLACE INTO ${table} VALUES (${valuesQuery})`)
|
||||
const upsertMany = this.sqlite.transaction((rows: any[]) => {
|
||||
rows.forEach((row: any) => upsert.run(row))
|
||||
})
|
||||
upsertMany(data)
|
||||
}
|
||||
|
||||
delete<T extends TableNames>(table: T, key: TablesStructures[T]['id']) {
|
||||
return this.sqlite.prepare(`DELETE FROM ${table} WHERE id = ?`).run(key)
|
||||
}
|
||||
|
||||
deleteMany<T extends TableNames>(table: T, keys: TablesStructures[T]['id'][]) {
|
||||
const idsQuery = keys.map(key => `id = ${key}`).join(' OR ')
|
||||
return this.sqlite.prepare(`DELETE FROM ${table} WHERE ${idsQuery}`).run()
|
||||
}
|
||||
|
||||
truncate<T extends TableNames>(table: T) {
|
||||
return this.sqlite.prepare(`DELETE FROM ${table}`).run()
|
||||
}
|
||||
|
||||
vacuum() {
|
||||
return this.sqlite.prepare('VACUUM').run()
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new DB()
|
||||
6
packages/desktop/main/env.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export const isDev = process.env.NODE_ENV === 'development'
|
||||
export const isProd = process.env.NODE_ENV === 'production'
|
||||
export const isWindows = process.platform === 'win32'
|
||||
export const isMac = process.platform === 'darwin'
|
||||
export const isLinux = process.platform === 'linux'
|
||||
export const appName = 'R3PLAY'
|
||||
215
packages/desktop/main/index.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import './preload' // must be first
|
||||
import './sentry'
|
||||
import { BrowserWindow, BrowserWindowConstructorOptions, app, shell } from 'electron'
|
||||
import { release } from 'os'
|
||||
import { join } from 'path'
|
||||
import log from './log'
|
||||
import { initIpcMain } from './ipcMain'
|
||||
import { createTray, YPMTray } from './tray'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { createTaskbar, Thumbar } from './windowsTaskbar'
|
||||
import { createMenu } from './menu'
|
||||
import { isDev, isWindows, isLinux, isMac, appName } from './env'
|
||||
import store from './store'
|
||||
import initAppServer from './appServer/appServer'
|
||||
|
||||
log.info('[electron] index.ts')
|
||||
|
||||
class Main {
|
||||
win: BrowserWindow | null = null
|
||||
tray: YPMTray | null = null
|
||||
thumbar: Thumbar | null = null
|
||||
|
||||
constructor() {
|
||||
log.info('[index] Main process start')
|
||||
// Disable GPU Acceleration for Windows 7
|
||||
if (release().startsWith('6.1')) app.disableHardwareAcceleration()
|
||||
|
||||
// Set application name for Windows 10+ notifications
|
||||
if (process.platform === 'win32') app.setAppUserModelId(app.getName())
|
||||
|
||||
// Make sure the app only run on one instance
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
log.info('[index] App ready')
|
||||
await initAppServer()
|
||||
this.createWindow()
|
||||
this.handleAppEvents()
|
||||
this.handleWindowEvents()
|
||||
this.createTray()
|
||||
createMenu(this.win!)
|
||||
this.createThumbar()
|
||||
initIpcMain(this.win, this.tray, this.thumbar, store)
|
||||
this.initDevTools()
|
||||
})
|
||||
}
|
||||
|
||||
initDevTools() {
|
||||
if (!isDev || !this.win) return
|
||||
|
||||
// Install devtool extension
|
||||
const {
|
||||
default: installExtension,
|
||||
REACT_DEVELOPER_TOOLS,
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
} = require('electron-devtools-installer')
|
||||
installExtension(REACT_DEVELOPER_TOOLS.id).catch((err: unknown) =>
|
||||
log.info('An error occurred: ', err)
|
||||
)
|
||||
|
||||
this.win.webContents.openDevTools()
|
||||
}
|
||||
|
||||
createTray() {
|
||||
if (isWindows || isLinux || isDev) {
|
||||
this.tray = createTray(this.win!)
|
||||
}
|
||||
}
|
||||
|
||||
createThumbar() {
|
||||
if (isWindows) this.thumbar = createTaskbar(this.win!)
|
||||
}
|
||||
|
||||
createWindow() {
|
||||
const options: BrowserWindowConstructorOptions = {
|
||||
title: appName,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'rendererPreload.js'),
|
||||
},
|
||||
width: store.get('window.width'),
|
||||
height: store.get('window.height'),
|
||||
minWidth: 1240,
|
||||
minHeight: 800,
|
||||
titleBarStyle: isMac ? 'customButtonsOnHover' : 'hidden',
|
||||
trafficLightPosition: { x: 24, y: 24 },
|
||||
frame: false,
|
||||
fullscreenable: true,
|
||||
resizable: true,
|
||||
transparent: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0)',
|
||||
show: false,
|
||||
}
|
||||
if (store.get('window')) {
|
||||
options.x = store.get('window.x')
|
||||
options.y = store.get('window.y')
|
||||
}
|
||||
this.win = new BrowserWindow(options)
|
||||
|
||||
// Web server
|
||||
const url = `http://localhost:${process.env.ELECTRON_WEB_SERVER_PORT}`
|
||||
this.win.loadURL(url)
|
||||
|
||||
// Make all links open with the browser, not with the application
|
||||
this.win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith('https://')) shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// 减少显示空白窗口的时间
|
||||
this.win.once('ready-to-show', () => {
|
||||
this.win && this.win.show()
|
||||
})
|
||||
|
||||
this.disableCORS()
|
||||
}
|
||||
|
||||
disableCORS() {
|
||||
if (!this.win) return
|
||||
|
||||
const addCORSHeaders = (headers: Record<string, string | string[]>) => {
|
||||
if (
|
||||
headers['Access-Control-Allow-Origin']?.[0] !== '*' &&
|
||||
headers['access-control-allow-origin']?.[0] !== '*'
|
||||
) {
|
||||
headers['Access-Control-Allow-Origin'] = ['*']
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
this.win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
const { requestHeaders, url } = details
|
||||
addCORSHeaders(requestHeaders)
|
||||
|
||||
// 不加这几个 header 的话,使用 axios 加载 YouTube 音频会很慢
|
||||
if (url.includes('googlevideo.com')) {
|
||||
requestHeaders['Sec-Fetch-Mode'] = 'no-cors'
|
||||
requestHeaders['Sec-Fetch-Dest'] = 'audio'
|
||||
requestHeaders['Range'] = 'bytes=0-'
|
||||
}
|
||||
|
||||
callback({ requestHeaders })
|
||||
})
|
||||
|
||||
this.win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
||||
const { responseHeaders, url } = details
|
||||
if (url.includes('sentry.io')) {
|
||||
callback({ responseHeaders })
|
||||
return
|
||||
}
|
||||
if (responseHeaders) {
|
||||
addCORSHeaders(responseHeaders)
|
||||
}
|
||||
callback({ responseHeaders })
|
||||
})
|
||||
}
|
||||
|
||||
handleWindowEvents() {
|
||||
if (!this.win) return
|
||||
|
||||
// Window maximize and minimize
|
||||
this.win.on('maximize', () => {
|
||||
this.win && this.win.webContents.send(IpcChannels.IsMaximized, true)
|
||||
})
|
||||
|
||||
this.win.on('unmaximize', () => {
|
||||
this.win && this.win.webContents.send(IpcChannels.IsMaximized, false)
|
||||
})
|
||||
|
||||
this.win.on('enter-full-screen', () => {
|
||||
this.win && this.win.webContents.send(IpcChannels.FullscreenStateChange, true)
|
||||
})
|
||||
|
||||
this.win.on('leave-full-screen', () => {
|
||||
this.win && this.win.webContents.send(IpcChannels.FullscreenStateChange, false)
|
||||
})
|
||||
|
||||
// Save window position
|
||||
const saveBounds = () => {
|
||||
const bounds = this.win?.getBounds()
|
||||
if (bounds) {
|
||||
store.set('window', bounds)
|
||||
}
|
||||
}
|
||||
this.win.on('resized', saveBounds)
|
||||
this.win.on('moved', saveBounds)
|
||||
}
|
||||
|
||||
handleAppEvents() {
|
||||
app.on('window-all-closed', () => {
|
||||
this.win = null
|
||||
if (!isMac) app.quit()
|
||||
})
|
||||
|
||||
app.on('second-instance', () => {
|
||||
if (!this.win) return
|
||||
// Focus on the main window if the user tried to open another
|
||||
if (this.win.isMinimized()) this.win.restore()
|
||||
this.win.focus()
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
const allWindows = BrowserWindow.getAllWindows()
|
||||
if (allWindows.length) {
|
||||
allWindows[0].focus()
|
||||
} else {
|
||||
this.createWindow()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
new Main()
|
||||
264
packages/desktop/main/ipcMain.ts
Executable file
|
|
@ -0,0 +1,264 @@
|
|||
import { BrowserWindow, ipcMain, app } from 'electron'
|
||||
// import { db, Tables } from './db'
|
||||
import { IpcChannels, IpcChannelsParams } from '@/shared/IpcChannels'
|
||||
import cache from './cache'
|
||||
import log from './log'
|
||||
import fs from 'fs'
|
||||
import Store from 'electron-store'
|
||||
import { TypedElectronStore } from './store'
|
||||
import { CacheAPIs } from '@/shared/CacheAPIs'
|
||||
import { YPMTray } from './tray'
|
||||
import { Thumbar } from './windowsTaskbar'
|
||||
import fastFolderSize from 'fast-folder-size'
|
||||
import path from 'path'
|
||||
import prettyBytes from 'pretty-bytes'
|
||||
import { db, Tables } from './db'
|
||||
|
||||
log.info('[electron] ipcMain.ts')
|
||||
|
||||
const on = <T extends keyof IpcChannelsParams>(
|
||||
channel: T,
|
||||
listener: (event: Electron.IpcMainEvent, params: IpcChannelsParams[T]) => void
|
||||
) => {
|
||||
ipcMain.on(channel, listener)
|
||||
}
|
||||
|
||||
const handle = <T extends keyof IpcChannelsParams>(
|
||||
channel: T,
|
||||
listener: (event: Electron.IpcMainInvokeEvent, params: IpcChannelsParams[T]) => void
|
||||
) => {
|
||||
return ipcMain.handle(channel, listener)
|
||||
}
|
||||
|
||||
export function initIpcMain(
|
||||
win: BrowserWindow | null,
|
||||
tray: YPMTray | null,
|
||||
thumbar: Thumbar | null,
|
||||
store: Store<TypedElectronStore>
|
||||
) {
|
||||
initWindowIpcMain(win)
|
||||
initTrayIpcMain(tray)
|
||||
initTaskbarIpcMain(thumbar)
|
||||
initStoreIpcMain(store)
|
||||
initOtherIpcMain()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理需要win对象的事件
|
||||
* @param {BrowserWindow} win
|
||||
*/
|
||||
function initWindowIpcMain(win: BrowserWindow | null) {
|
||||
on(IpcChannels.Minimize, () => {
|
||||
win?.minimize()
|
||||
})
|
||||
|
||||
let isMaximized = false
|
||||
let unMaximizeSize: { width: number; height: number } | null = null
|
||||
let windowPosition: { x: number; y: number } | null = null
|
||||
on(IpcChannels.MaximizeOrUnmaximize, () => {
|
||||
if (!win) return false
|
||||
|
||||
if (isMaximized) {
|
||||
if (unMaximizeSize) {
|
||||
win.setSize(unMaximizeSize.width, unMaximizeSize.width, true)
|
||||
}
|
||||
if (windowPosition) {
|
||||
win.setPosition(windowPosition.x, windowPosition.y, true)
|
||||
}
|
||||
win.unmaximize()
|
||||
} else {
|
||||
const size = win.getSize()
|
||||
unMaximizeSize = { width: size[1], height: size[0] }
|
||||
const position = win.getPosition()
|
||||
windowPosition = { x: position[0], y: position[1] }
|
||||
win.maximize()
|
||||
}
|
||||
|
||||
isMaximized = !isMaximized
|
||||
win.webContents.send(IpcChannels.IsMaximized, isMaximized)
|
||||
})
|
||||
|
||||
on(IpcChannels.Close, () => {
|
||||
app.exit()
|
||||
})
|
||||
|
||||
on(IpcChannels.ResetWindowSize, () => {
|
||||
if (!win) return
|
||||
win?.setSize(1440, 1024, true)
|
||||
})
|
||||
|
||||
handle(IpcChannels.IsMaximized, () => {
|
||||
if (!win) return
|
||||
return isMaximized
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理需要tray对象的事件
|
||||
* @param {YPMTray} tray
|
||||
*/
|
||||
function initTrayIpcMain(tray: YPMTray | null) {
|
||||
on(IpcChannels.SetTrayTooltip, (e, { text }) => tray?.setTooltip(text))
|
||||
|
||||
on(IpcChannels.Like, (e, { isLiked }) => tray?.setLikeState(isLiked))
|
||||
|
||||
on(IpcChannels.Play, () => tray?.setPlayState(true))
|
||||
on(IpcChannels.Pause, () => tray?.setPlayState(false))
|
||||
|
||||
on(IpcChannels.Repeat, (e, { mode }) => tray?.setRepeatMode(mode))
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理需要thumbar对象的事件
|
||||
* @param {Thumbar} thumbar
|
||||
*/
|
||||
function initTaskbarIpcMain(thumbar: Thumbar | null) {
|
||||
on(IpcChannels.Play, () => thumbar?.setPlayState(true))
|
||||
on(IpcChannels.Pause, () => thumbar?.setPlayState(false))
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理需要electron-store的事件
|
||||
* @param {Store<TypedElectronStore>} store
|
||||
*/
|
||||
function initStoreIpcMain(store: Store<TypedElectronStore>) {
|
||||
/**
|
||||
* 同步设置到Main
|
||||
*/
|
||||
on(IpcChannels.SyncSettings, (event, settings) => {
|
||||
store.set('settings', settings)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理其他事件
|
||||
*/
|
||||
function initOtherIpcMain() {
|
||||
/**
|
||||
* 清除API缓存
|
||||
*/
|
||||
on(IpcChannels.ClearAPICache, () => {
|
||||
// db.truncate(Tables.Track)
|
||||
// db.truncate(Tables.Album)
|
||||
// db.truncate(Tables.Artist)
|
||||
// db.truncate(Tables.Playlist)
|
||||
// db.truncate(Tables.ArtistAlbum)
|
||||
// db.truncate(Tables.AccountData)
|
||||
// db.truncate(Tables.Audio)
|
||||
// db.vacuum()
|
||||
})
|
||||
|
||||
/**
|
||||
* Get API cache
|
||||
*/
|
||||
// on(IpcChannels.GetApiCache, (event, args) => {
|
||||
// const { api, query } = args
|
||||
// const data = cache.get(api, query)
|
||||
// event.returnValue = data
|
||||
// })
|
||||
|
||||
handle(IpcChannels.GetApiCache, async (event, args) => {
|
||||
const { api, query } = args
|
||||
if (api !== 'user/account') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const data = await cache.get(api, query)
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 缓存封面颜色
|
||||
*/
|
||||
on(IpcChannels.CacheCoverColor, (event, args) => {
|
||||
const { id, color } = args
|
||||
cache.set(CacheAPIs.CoverColor, { id, color })
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取音频缓存文件夹大小
|
||||
*/
|
||||
on(IpcChannels.GetAudioCacheSize, event => {
|
||||
fastFolderSize(path.join(app.getPath('userData'), './audio_cache'), (error, bytes) => {
|
||||
if (error) throw error
|
||||
|
||||
event.returnValue = prettyBytes(bytes ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 从Apple Music获取专辑信息
|
||||
*/
|
||||
// handle(
|
||||
// IpcChannels.GetAlbumFromAppleMusic,
|
||||
// async (event, { id, name, artist }) => {
|
||||
// const fromCache = cache.get(APIs.AppleMusicAlbum, { id })
|
||||
// if (fromCache) {
|
||||
// return fromCache === 'no' ? undefined : fromCache
|
||||
// }
|
||||
|
||||
// const fromApple = await getAlbum({ name, artist })
|
||||
// cache.set(APIs.AppleMusicAlbum, { id, album: fromApple })
|
||||
// return fromApple
|
||||
// }
|
||||
// )
|
||||
|
||||
// /**
|
||||
// * 从Apple Music获取歌手信息
|
||||
// **/
|
||||
// handle(IpcChannels.GetArtistFromAppleMusic, async (event, { id, name }) => {
|
||||
// const fromApple = await getArtist(name)
|
||||
// cache.set(APIs.AppleMusicArtist, { id, artist: fromApple })
|
||||
// return fromApple
|
||||
// })
|
||||
|
||||
// /**
|
||||
// * 从缓存读取Apple Music歌手信息
|
||||
// */
|
||||
// on(IpcChannels.GetArtistFromAppleMusic, (event, { id }) => {
|
||||
// const artist = cache.get(APIs.AppleMusicArtist, id)
|
||||
// event.returnValue = artist === 'no' ? undefined : artist
|
||||
// })
|
||||
|
||||
/**
|
||||
* 退出登陆
|
||||
*/
|
||||
handle(IpcChannels.Logout, async () => {
|
||||
await db.truncate(Tables.AccountData)
|
||||
return true
|
||||
})
|
||||
|
||||
/**
|
||||
* 导出tables到json文件,方便查看table大小(dev环境)
|
||||
*/
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// on(IpcChannels.DevDbExportJson, () => {
|
||||
// const tables = [
|
||||
// Tables.ArtistAlbum,
|
||||
// Tables.Playlist,
|
||||
// Tables.Album,
|
||||
// Tables.Track,
|
||||
// Tables.Artist,
|
||||
// Tables.Audio,
|
||||
// Tables.AccountData,
|
||||
// Tables.Lyric,
|
||||
// ]
|
||||
// tables.forEach(table => {
|
||||
// const data = db.findAll(table)
|
||||
// fs.writeFile(
|
||||
// `./tmp/${table}.json`,
|
||||
// JSON.stringify(data),
|
||||
// function (err) {
|
||||
// if (err) {
|
||||
// return console.log(err)
|
||||
// }
|
||||
// console.log('The file was saved!')
|
||||
// }
|
||||
// )
|
||||
// })
|
||||
// })
|
||||
}
|
||||
}
|
||||
29
packages/desktop/main/log.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/** By default, it writes logs to the following locations:
|
||||
* on Linux: ~/.config/R3PLAY/logs/main.log
|
||||
* on macOS: ~/Library/Logs/r3play/main.log
|
||||
* on Windows: %USERPROFILE%\AppData\Roaming\r3play\logs\main.log
|
||||
* @see https://www.npmjs.com/package/electron-log
|
||||
*/
|
||||
|
||||
import log from 'electron-log'
|
||||
import pc from 'picocolors'
|
||||
import { isDev } from './env'
|
||||
|
||||
Object.assign(console, log.functions)
|
||||
log.variables.process = 'main'
|
||||
if (log.transports.ipc) log.transports.ipc.level = false
|
||||
log.transports.console.format = `${isDev ? '' : pc.dim('{h}:{i}:{s}{scope} ')}{level} › {text}`
|
||||
log.transports.file.level = 'info'
|
||||
|
||||
log.info(
|
||||
`\n\n██╗ ██╗███████╗███████╗██████╗ ██╗ █████╗ ██╗ ██╗███╗ ███╗██╗ ██╗███████╗██╗ ██████╗
|
||||
╚██╗ ██╔╝██╔════╝██╔════╝██╔══██╗██║ ██╔══██╗╚██╗ ██╔╝████╗ ████║██║ ██║██╔════╝██║██╔════╝
|
||||
╚████╔╝ █████╗ ███████╗██████╔╝██║ ███████║ ╚████╔╝ ██╔████╔██║██║ ██║███████╗██║██║
|
||||
╚██╔╝ ██╔══╝ ╚════██║██╔═══╝ ██║ ██╔══██║ ╚██╔╝ ██║╚██╔╝██║██║ ██║╚════██║██║██║
|
||||
██║ ███████╗███████║██║ ███████╗██║ ██║ ██║ ██║ ╚═╝ ██║╚██████╔╝███████║██║╚██████╗
|
||||
╚═╝ ╚══════╝╚══════╝╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═════╝\n`
|
||||
)
|
||||
|
||||
export default log
|
||||
|
||||
log.info(`[logger] logger initialized`)
|
||||
78
packages/desktop/main/menu.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { app, BrowserWindow, Menu, MenuItem, MenuItemConstructorOptions, shell } from 'electron'
|
||||
import { isMac } from './env'
|
||||
import { logsPath } from './utils'
|
||||
import { exec } from 'child_process'
|
||||
import log from './log'
|
||||
|
||||
log.info('[electron] menu.ts')
|
||||
|
||||
export const createMenu = (win: BrowserWindow) => {
|
||||
const template: Array<MenuItemConstructorOptions | MenuItem> = [
|
||||
{ role: 'appMenu' },
|
||||
{ role: 'editMenu' },
|
||||
{ role: 'viewMenu' },
|
||||
{ role: 'windowMenu' },
|
||||
{
|
||||
label: '帮助',
|
||||
submenu: [
|
||||
{
|
||||
label: '打开日志文件目录',
|
||||
click: async () => {
|
||||
if (isMac) {
|
||||
exec(`open ${logsPath}`)
|
||||
} else {
|
||||
// TODO: 测试Windows和Linux是否能正确打开日志目录
|
||||
shell.openPath(logsPath)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '打开应用数据目录',
|
||||
click: async () => {
|
||||
const path = app.getPath('userData')
|
||||
if (isMac) {
|
||||
exec(`open ${path}`)
|
||||
} else {
|
||||
// TODO: 测试Windows和Linux是否能正确打开日志目录
|
||||
shell.openPath(path)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '打开开发者工具',
|
||||
click: async () => {
|
||||
win.webContents.openDevTools()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '反馈问题',
|
||||
click: async () => {
|
||||
await shell.openExternal('https://github.com/qier222/YesPlayMusic/issues/new')
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '访问 GitHub 仓库',
|
||||
click: async () => {
|
||||
await shell.openExternal('https://github.com/qier222/YesPlayMusic')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '访问论坛',
|
||||
click: async () => {
|
||||
await shell.openExternal('https://github.com/qier222/YesPlayMusic/discussions')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '加入交流群',
|
||||
click: async () => {
|
||||
await shell.openExternal('https://github.com/qier222/YesPlayMusic/discussions')
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const menu = Menu.buildFromTemplate(template)
|
||||
Menu.setApplicationMenu(menu)
|
||||
}
|
||||
17
packages/desktop/main/preload.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import log from './log'
|
||||
import { app } from 'electron'
|
||||
import { isDev } from './env'
|
||||
import { createDirIfNotExist, portableUserDataPath, devUserDataPath, dirname } from './utils'
|
||||
|
||||
if (isDev) {
|
||||
createDirIfNotExist(devUserDataPath)
|
||||
app.setPath('appData', devUserDataPath)
|
||||
}
|
||||
if (process.env.PORTABLE_EXECUTABLE_DIR) {
|
||||
createDirIfNotExist(portableUserDataPath)
|
||||
app.setPath('appData', portableUserDataPath)
|
||||
}
|
||||
|
||||
log.info('[preload] dirname', dirname)
|
||||
|
||||
log.info(`[preload] userData path: ${app.getPath('userData')}`)
|
||||
34
packages/desktop/main/rendererPreload.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { isLinux, isMac, isProd, isWindows } from './env'
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
// if (isProd) {
|
||||
// const log = require('electron-log')
|
||||
// log.transports.file.level = 'info'
|
||||
// log.transports.ipc.level = false
|
||||
// log.variables.process = 'renderer'
|
||||
// contextBridge.exposeInMainWorld('log', log)
|
||||
// }
|
||||
|
||||
contextBridge.exposeInMainWorld('ipcRenderer', {
|
||||
invoke: ipcRenderer.invoke,
|
||||
send: ipcRenderer.send,
|
||||
on: (
|
||||
channel: IpcChannels,
|
||||
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
|
||||
) => {
|
||||
ipcRenderer.on(channel, listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener(channel, listener)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('env', {
|
||||
isElectron: true,
|
||||
isEnableTitlebar: process.platform === 'win32' || process.platform === 'linux',
|
||||
isLinux,
|
||||
isMac,
|
||||
isWindows,
|
||||
})
|
||||
19
packages/desktop/main/sentry.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import * as Sentry from '@sentry/electron'
|
||||
import pkg from '../../../package.json'
|
||||
import { appName } from './env'
|
||||
import log from './log'
|
||||
|
||||
log.info(`[sentry] sentry initializing`)
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://2aaaa67f1c3d4d6baefafa5d58fcf340@o436528.ingest.sentry.io/6274637',
|
||||
release: `${appName}@${pkg.version}`,
|
||||
environment: process.env.NODE_ENV,
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: 1.0,
|
||||
})
|
||||
|
||||
log.info(`[sentry] sentry initialized`)
|
||||
26
packages/desktop/main/store.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import Store from 'electron-store'
|
||||
import log from './log'
|
||||
|
||||
log.info('[electron] store.ts')
|
||||
|
||||
export interface TypedElectronStore {
|
||||
window: {
|
||||
width: number
|
||||
height: number
|
||||
x?: number
|
||||
y?: number
|
||||
}
|
||||
// settings: State['settings']
|
||||
}
|
||||
|
||||
const store = new Store<TypedElectronStore>({
|
||||
defaults: {
|
||||
window: {
|
||||
width: 1440,
|
||||
height: 1024,
|
||||
},
|
||||
// settings: initialState.settings,
|
||||
},
|
||||
})
|
||||
|
||||
export default store
|
||||
170
packages/desktop/main/tray.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import path from 'path'
|
||||
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray } from 'electron'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { RepeatMode } from '@/shared/playerDataTypes'
|
||||
import { appName } from './env'
|
||||
import log from './log'
|
||||
|
||||
log.info('[electron] tray.ts')
|
||||
|
||||
const iconDirRoot =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? path.join(process.cwd(), './src/main/assets/icons/tray')
|
||||
: path.join(__dirname, './assets/icons/tray')
|
||||
|
||||
enum MenuItemIDs {
|
||||
Play = 'play',
|
||||
Pause = 'pause',
|
||||
Like = 'like',
|
||||
Unlike = 'unlike',
|
||||
}
|
||||
|
||||
export interface YPMTray {
|
||||
setTooltip(text: string): void
|
||||
setLikeState(isLiked: boolean): void
|
||||
setPlayState(isPlaying: boolean): void
|
||||
setRepeatMode(mode: RepeatMode): void
|
||||
}
|
||||
|
||||
function createNativeImage(filename: string) {
|
||||
return nativeImage.createFromPath(path.join(iconDirRoot, filename))
|
||||
}
|
||||
|
||||
function createMenuTemplate(win: BrowserWindow): MenuItemConstructorOptions[] {
|
||||
const template: MenuItemConstructorOptions[] =
|
||||
process.platform === 'linux'
|
||||
? [
|
||||
{
|
||||
label: '显示主面板',
|
||||
click: () => win.show(),
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
return template.concat([
|
||||
{
|
||||
label: '播放',
|
||||
click: () => win.webContents.send(IpcChannels.Play),
|
||||
icon: createNativeImage('play.png'),
|
||||
id: MenuItemIDs.Play,
|
||||
},
|
||||
{
|
||||
label: '暂停',
|
||||
click: () => win.webContents.send(IpcChannels.Pause),
|
||||
icon: createNativeImage('pause.png'),
|
||||
id: MenuItemIDs.Pause,
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
label: '上一首',
|
||||
click: () => win.webContents.send(IpcChannels.Previous),
|
||||
icon: createNativeImage('left.png'),
|
||||
},
|
||||
{
|
||||
label: '下一首',
|
||||
click: () => win.webContents.send(IpcChannels.Next),
|
||||
icon: createNativeImage('right.png'),
|
||||
},
|
||||
{
|
||||
label: '循环模式',
|
||||
icon: createNativeImage('repeat.png'),
|
||||
submenu: [
|
||||
{
|
||||
label: '关闭循环',
|
||||
click: () => win.webContents.send(IpcChannels.Repeat, RepeatMode.Off),
|
||||
id: RepeatMode.Off,
|
||||
checked: true,
|
||||
type: 'radio',
|
||||
},
|
||||
{
|
||||
label: '列表循环',
|
||||
click: () => win.webContents.send(IpcChannels.Repeat, RepeatMode.On),
|
||||
id: RepeatMode.On,
|
||||
type: 'radio',
|
||||
},
|
||||
{
|
||||
label: '单曲循环',
|
||||
click: () => win.webContents.send(IpcChannels.Repeat, RepeatMode.One),
|
||||
id: RepeatMode.One,
|
||||
type: 'radio',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '加入喜欢',
|
||||
click: () => win.webContents.send(IpcChannels.Like),
|
||||
icon: createNativeImage('like.png'),
|
||||
id: MenuItemIDs.Like,
|
||||
},
|
||||
{
|
||||
label: '取消喜欢',
|
||||
click: () => win.webContents.send(IpcChannels.Like),
|
||||
icon: createNativeImage('unlike.png'),
|
||||
id: MenuItemIDs.Unlike,
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
label: '退出',
|
||||
click: () => app.exit(),
|
||||
icon: createNativeImage('exit.png'),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
class YPMTrayImpl implements YPMTray {
|
||||
private _win: BrowserWindow
|
||||
private _tray: Tray
|
||||
private _template: MenuItemConstructorOptions[]
|
||||
private _contextMenu: Menu
|
||||
|
||||
constructor(win: BrowserWindow) {
|
||||
this._win = win
|
||||
const icon = createNativeImage('menu@88.png').resize({
|
||||
height: 20,
|
||||
width: 20,
|
||||
})
|
||||
this._tray = new Tray(icon)
|
||||
this._template = createMenuTemplate(this._win)
|
||||
this._contextMenu = Menu.buildFromTemplate(this._template)
|
||||
|
||||
this._updateContextMenu()
|
||||
this.setTooltip(appName)
|
||||
|
||||
this._tray.on('click', () => win.show())
|
||||
}
|
||||
|
||||
private _updateContextMenu() {
|
||||
this._tray.setContextMenu(this._contextMenu)
|
||||
}
|
||||
|
||||
setTooltip(text: string) {
|
||||
this._tray.setToolTip(text)
|
||||
}
|
||||
|
||||
setLikeState(isLiked: boolean) {
|
||||
this._contextMenu.getMenuItemById(MenuItemIDs.Like)!.visible = !isLiked
|
||||
this._contextMenu.getMenuItemById(MenuItemIDs.Unlike)!.visible = isLiked
|
||||
this._updateContextMenu()
|
||||
}
|
||||
|
||||
setPlayState(isPlaying: boolean) {
|
||||
this._contextMenu.getMenuItemById(MenuItemIDs.Play)!.visible = !isPlaying
|
||||
this._contextMenu.getMenuItemById(MenuItemIDs.Pause)!.visible = isPlaying
|
||||
this._updateContextMenu()
|
||||
}
|
||||
|
||||
setRepeatMode(mode: RepeatMode) {
|
||||
const item = this._contextMenu.getMenuItemById(mode)
|
||||
if (item) {
|
||||
item.checked = true
|
||||
this._updateContextMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createTray(win: BrowserWindow): YPMTray {
|
||||
return new YPMTrayImpl(win)
|
||||
}
|
||||
43
packages/desktop/main/utils.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import pkg from '../../../package.json'
|
||||
import { appName, isDev } from './env'
|
||||
import log from './log'
|
||||
|
||||
log.info('[electron] utils.ts')
|
||||
|
||||
export const dirname = isDev ? process.cwd() : __dirname
|
||||
export const devUserDataPath = path.resolve(process.cwd(), '../../tmp/userData')
|
||||
export const portableUserDataPath = path.resolve(
|
||||
process.env.PORTABLE_EXECUTABLE_DIR || '',
|
||||
`./${appName.toLowerCase()}-UserData`
|
||||
)
|
||||
export const logsPath = {
|
||||
linux: `~/.config/${pkg.productName}/logs`,
|
||||
darwin: `~/Library/Logs/${pkg.productName}/`,
|
||||
win32: `%USERPROFILE%\\AppData\\Roaming\\${pkg.productName}\\logs`,
|
||||
}[process.platform as 'darwin' | 'win32' | 'linux']
|
||||
|
||||
export const isFileExist = (file: string) => {
|
||||
return fs.existsSync(file)
|
||||
}
|
||||
|
||||
export const createDirIfNotExist = (dir: string) => {
|
||||
if (!isFileExist(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
export const createFileIfNotExist = (file: string) => {
|
||||
createDirIfNotExist(path.dirname(file))
|
||||
if (!isFileExist(file)) {
|
||||
fs.writeFileSync(file, '')
|
||||
}
|
||||
}
|
||||
|
||||
export const getNetworkInfo = () => {
|
||||
return os.networkInterfaces().en0?.find(n => n.family === 'IPv4')
|
||||
}
|
||||
|
||||
export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||
83
packages/desktop/main/windowsTaskbar.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { BrowserWindow, nativeImage, ThumbarButton } from 'electron'
|
||||
import path from 'path'
|
||||
import log from './log'
|
||||
|
||||
log.info('[electron] windowsTaskbar.ts')
|
||||
|
||||
enum ItemKeys {
|
||||
Play = 'play',
|
||||
Pause = 'pause',
|
||||
Previous = 'previous',
|
||||
Next = 'next',
|
||||
}
|
||||
|
||||
type ThumbarButtonMap = Map<ItemKeys, ThumbarButton>
|
||||
|
||||
const iconDirRoot =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? path.join(process.cwd(), './src/main/assets/icons/taskbar')
|
||||
: path.join(__dirname, './assets/icons/taskbar')
|
||||
|
||||
function createNativeImage(filename: string) {
|
||||
return nativeImage.createFromPath(path.join(iconDirRoot, filename))
|
||||
}
|
||||
|
||||
function createThumbarButtons(win: BrowserWindow): ThumbarButtonMap {
|
||||
return new Map<ItemKeys, ThumbarButton>()
|
||||
.set(ItemKeys.Play, {
|
||||
click: () => win.webContents.send(IpcChannels.Play),
|
||||
icon: createNativeImage('play.png'),
|
||||
tooltip: '播放',
|
||||
})
|
||||
.set(ItemKeys.Pause, {
|
||||
click: () => win.webContents.send(IpcChannels.Pause),
|
||||
icon: createNativeImage('pause.png'),
|
||||
tooltip: '暂停',
|
||||
})
|
||||
.set(ItemKeys.Previous, {
|
||||
click: () => win.webContents.send(IpcChannels.Previous),
|
||||
icon: createNativeImage('previous.png'),
|
||||
tooltip: '上一首',
|
||||
})
|
||||
.set(ItemKeys.Next, {
|
||||
click: () => win.webContents.send(IpcChannels.Next),
|
||||
icon: createNativeImage('next.png'),
|
||||
tooltip: '下一首',
|
||||
})
|
||||
}
|
||||
|
||||
export interface Thumbar {
|
||||
setPlayState(isPlaying: boolean): void
|
||||
}
|
||||
|
||||
class ThumbarImpl implements Thumbar {
|
||||
private _win: BrowserWindow
|
||||
private _buttons: ThumbarButtonMap
|
||||
|
||||
private _playOrPause: ThumbarButton
|
||||
private _previous: ThumbarButton
|
||||
private _next: ThumbarButton
|
||||
|
||||
constructor(win: BrowserWindow) {
|
||||
this._win = win
|
||||
this._buttons = createThumbarButtons(win)
|
||||
|
||||
this._playOrPause = this._buttons.get(ItemKeys.Play)!
|
||||
this._previous = this._buttons.get(ItemKeys.Previous)!
|
||||
this._next = this._buttons.get(ItemKeys.Next)!
|
||||
}
|
||||
|
||||
private _updateThumbarButtons(clear: boolean) {
|
||||
this._win.setThumbarButtons(clear ? [] : [this._previous, this._playOrPause, this._next])
|
||||
}
|
||||
|
||||
setPlayState(isPlaying: boolean) {
|
||||
this._playOrPause = this._buttons.get(isPlaying ? ItemKeys.Pause : ItemKeys.Play)!
|
||||
this._updateThumbarButtons(false)
|
||||
}
|
||||
}
|
||||
|
||||
export function createTaskbar(win: BrowserWindow): Thumbar {
|
||||
return new ThumbarImpl(win)
|
||||
}
|
||||
220
packages/desktop/main/youtube.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import log from './log'
|
||||
import ytdl from 'ytdl-core'
|
||||
import axios, { AxiosProxyConfig } from 'axios'
|
||||
import store from './store'
|
||||
import httpProxyAgent from 'http-proxy-agent'
|
||||
|
||||
class YoutubeDownloader {
|
||||
constructor() {
|
||||
//
|
||||
}
|
||||
|
||||
async search(keyword: string): Promise<
|
||||
{
|
||||
duration: number
|
||||
id: string
|
||||
title: string
|
||||
}[]
|
||||
> {
|
||||
let proxy: AxiosProxyConfig | false = false
|
||||
if (store.get('settings.httpProxyForYouTube')) {
|
||||
const host = store.get('settings.httpProxyForYouTube.host') as string | undefined
|
||||
const port = store.get('settings.httpProxyForYouTube.port') as number | undefined
|
||||
const auth = store.get('settings.httpProxyForYouTube.auth') as any | undefined
|
||||
const protocol = store.get('settings.httpProxyForYouTube.protocol') as string | undefined
|
||||
if (host && port) {
|
||||
proxy = { host, port, auth, protocol }
|
||||
}
|
||||
}
|
||||
// proxy = { host: '127.0.0.1', port: 8888, protocol: 'http' }
|
||||
const webPage = await axios.get(`https://www.youtube.com/results`, {
|
||||
params: {
|
||||
search_query: keyword,
|
||||
sp: 'EgIQAQ==',
|
||||
},
|
||||
headers: { 'Accept-Language': 'en-US' },
|
||||
timeout: 5000,
|
||||
proxy,
|
||||
})
|
||||
|
||||
if (webPage.status !== 200) {
|
||||
return []
|
||||
}
|
||||
|
||||
// @credit https://www.npmjs.com/package/@yimura/scraper
|
||||
function _parseData(data) {
|
||||
const results = {
|
||||
channels: [],
|
||||
playlists: [],
|
||||
streams: [],
|
||||
videos: [],
|
||||
}
|
||||
|
||||
const isVideo = item => item.videoRenderer && item.videoRenderer.lengthText
|
||||
const getVideoData = item => {
|
||||
const vRender = item.videoRenderer
|
||||
const compress = key => {
|
||||
return (key && key['runs'] ? key['runs'].map(v => v.text) : []).join('')
|
||||
}
|
||||
const parseDuration = vRender => {
|
||||
if (!vRender.lengthText?.simpleText) return 0
|
||||
|
||||
const nums = vRender.lengthText.simpleText.split(':')
|
||||
let time = nums.reduce((a, t) => 60 * a + +t) * 1e3
|
||||
|
||||
return time
|
||||
}
|
||||
|
||||
return {
|
||||
duration: parseDuration(vRender),
|
||||
id: vRender.videoId,
|
||||
title: compress(vRender.title),
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of data) {
|
||||
if (isVideo(item)) results.videos.push(getVideoData(item))
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
function _extractData(json) {
|
||||
json = json.contents.twoColumnSearchResultsRenderer.primaryContents
|
||||
|
||||
let contents = []
|
||||
|
||||
if (json.sectionListRenderer) {
|
||||
contents = json.sectionListRenderer.contents
|
||||
.filter(item =>
|
||||
item?.itemSectionRenderer?.contents.filter(
|
||||
x => x.videoRenderer || x.playlistRenderer || x.channelRenderer
|
||||
)
|
||||
)
|
||||
.shift().itemSectionRenderer.contents
|
||||
}
|
||||
|
||||
if (json.richGridRenderer) {
|
||||
contents = json.richGridRenderer.contents
|
||||
.filter(item => item.richItemRenderer && item.richItemRenderer.content)
|
||||
.map(item => item.richItemRenderer.content)
|
||||
}
|
||||
|
||||
return contents
|
||||
}
|
||||
|
||||
function _getSearchData(webPage: string) {
|
||||
const startString = 'var ytInitialData = '
|
||||
const start = webPage.indexOf(startString)
|
||||
const end = webPage.indexOf(';</script>', start)
|
||||
|
||||
const data = webPage.substring(start + startString.length, end)
|
||||
|
||||
try {
|
||||
return JSON.parse(data)
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
'Failed to parse YouTube search data. YouTube might have updated their site or no results returned.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const parsedJson = _getSearchData(webPage.data)
|
||||
|
||||
const extracted = _extractData(parsedJson)
|
||||
const parsed = _parseData(extracted)
|
||||
|
||||
return parsed?.videos
|
||||
}
|
||||
|
||||
async matchTrack(
|
||||
artist: string,
|
||||
trackName: string
|
||||
): Promise<{
|
||||
url: string
|
||||
bitRate: number
|
||||
title: string
|
||||
videoId: string
|
||||
duration: string
|
||||
channel: string
|
||||
}> {
|
||||
const match = async () => {
|
||||
console.time('[youtube] search')
|
||||
const videos = await this.search(`${artist} ${trackName} audio`)
|
||||
console.timeEnd('[youtube] search')
|
||||
let video: {
|
||||
duration: number
|
||||
id: string
|
||||
title: string
|
||||
} | null = null
|
||||
|
||||
// 找官方频道最匹配的
|
||||
// videos.forEach(v => {
|
||||
// if (video) return
|
||||
// const channelName = v.channel.name.toLowerCase()
|
||||
// if (channelName !== artist.toLowerCase()) return
|
||||
// const title = v.title.toLowerCase()
|
||||
// if (!title.includes(trackName.toLowerCase())) return
|
||||
// if (!title.includes('audio') && !title.includes('lyric')) return
|
||||
// video = v
|
||||
// })
|
||||
|
||||
// TODO:找时长误差不超过2秒的
|
||||
|
||||
// 最后方案选搜索的第一个
|
||||
if (!video) {
|
||||
video = videos[0]
|
||||
}
|
||||
if (!video) return null
|
||||
|
||||
console.time('[youtube] getInfo')
|
||||
const proxy = 'http://127.0.0.1:8888'
|
||||
const agent = httpProxyAgent(proxy)
|
||||
const info = await ytdl.getInfo(video.id, {
|
||||
// requestOptions: { agent },
|
||||
})
|
||||
console.timeEnd('[youtube] getInfo')
|
||||
if (!info) return null
|
||||
let url = ''
|
||||
let bitRate = 0
|
||||
info.formats.forEach(video => {
|
||||
if (
|
||||
video.mimeType === `audio/webm; codecs="opus"` &&
|
||||
video.bitrate &&
|
||||
video.bitrate > bitRate
|
||||
) {
|
||||
url = video.url
|
||||
bitRate = video.bitrate
|
||||
}
|
||||
})
|
||||
const data = {
|
||||
url,
|
||||
bitRate,
|
||||
title: info.videoDetails.title,
|
||||
videoId: info.videoDetails.videoId,
|
||||
duration: info.videoDetails.lengthSeconds,
|
||||
channel: info.videoDetails.ownerChannelName,
|
||||
}
|
||||
log.info(`[youtube] matched `, data)
|
||||
return data
|
||||
}
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
setTimeout(() => reject('youtube match timeout'), 10000)
|
||||
try {
|
||||
const result = await match()
|
||||
if (result) resolve(result)
|
||||
} catch (e) {
|
||||
log.error(`[youtube] matchTrack error`, e)
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
return axios.get('https://www.youtube.com', { timeout: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
const youtubeDownloader = new YoutubeDownloader()
|
||||
export default youtubeDownloader
|
||||
77
packages/desktop/migrations/init.sql
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
CREATE TABLE IF NOT EXISTS "AccountData" (
|
||||
"id" TEXT NOT NULL,
|
||||
"json" TEXT NOT NULL,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "AppData" (
|
||||
"id" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Track" (
|
||||
"id" INTEGER NOT NULL,
|
||||
"json" TEXT NOT NULL,
|
||||
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Album" (
|
||||
"id" INTEGER NOT NULL,
|
||||
"json" TEXT NOT NULL,
|
||||
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Artist" (
|
||||
"id" INTEGER NOT NULL,
|
||||
"json" TEXT NOT NULL,
|
||||
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "ArtistAlbum" (
|
||||
"id" INTEGER NOT NULL,
|
||||
"hotAlbums" TEXT NOT NULL,
|
||||
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Playlist" (
|
||||
"id" INTEGER NOT NULL,
|
||||
"json" TEXT NOT NULL,
|
||||
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Audio" (
|
||||
"id" INTEGER NOT NULL,
|
||||
"bitRate" INTEGER NOT NULL,
|
||||
"format" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"queriedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Lyrics" (
|
||||
"id" INTEGER NOT NULL,
|
||||
"json" TEXT NOT NULL,
|
||||
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "AppleMusicAlbum" (
|
||||
"id" INTEGER NOT NULL,
|
||||
"json" TEXT NOT NULL,
|
||||
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "AppleMusicArtist" (
|
||||
"id" INTEGER NOT NULL,
|
||||
"json" TEXT NOT NULL,
|
||||
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
63
packages/desktop/package.json
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
{
|
||||
"name": "desktop",
|
||||
"productName": "R3PLAY",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"main": "./main/index.js",
|
||||
"author": "qier222 <qier222@outlook.com>",
|
||||
"homepage": "https://github.com/qier222/YesPlayMusic",
|
||||
"scripts": {
|
||||
"postinstall": "tsx scripts/build.sqlite3.ts",
|
||||
"dev": "tsx scripts/build.main.ts --watch",
|
||||
"build": "tsx scripts/build.main.ts",
|
||||
"pack": "electron-builder build -c .electron-builder.config.js",
|
||||
"pack:test": "electron-builder build -c .electron-builder.config.js --publish never --mac --dir --arm64",
|
||||
"test:types": "tsc --noEmit --project ./tsconfig.json",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^8.3.0",
|
||||
"@fastify/http-proxy": "^8.4.0",
|
||||
"@fastify/multipart": "^7.4.0",
|
||||
"@fastify/static": "^6.6.1",
|
||||
"@sentry/electron": "^3.0.7",
|
||||
"NeteaseCloudMusicApi": "^4.8.9",
|
||||
"better-sqlite3": "8.1.0",
|
||||
"change-case": "^4.1.2",
|
||||
"compare-versions": "^4.1.3",
|
||||
"electron-log": "^4.4.8",
|
||||
"electron-store": "^8.1.0",
|
||||
"fast-folder-size": "^1.7.1",
|
||||
"fastify": "^4.5.3",
|
||||
"http-proxy-agent": "^5.0.0",
|
||||
"pretty-bytes": "^6.0.0",
|
||||
"ytdl-core": "^4.11.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.3",
|
||||
"@vitest/ui": "^0.20.3",
|
||||
"axios": "^1.3.4",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^16.0.3",
|
||||
"electron": "^23.1.4",
|
||||
"electron-builder": "23.6.0",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"esbuild": "^0.16.10",
|
||||
"minimist": "^1.2.8",
|
||||
"music-metadata": "^8.1.3",
|
||||
"open-cli": "^7.1.0",
|
||||
"ora": "^6.1.2",
|
||||
"picocolors": "^1.0.0",
|
||||
"prettier": "*",
|
||||
"tsx": "*",
|
||||
"typescript": "*",
|
||||
"vitest": "^0.20.3",
|
||||
"wait-on": "^7.0.1"
|
||||
}
|
||||
}
|
||||
104
packages/desktop/scripts/build.main.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { build } from 'esbuild'
|
||||
import ora from 'ora'
|
||||
import { builtinModules } from 'module'
|
||||
import electron from 'electron'
|
||||
import { spawn } from 'child_process'
|
||||
import path from 'path'
|
||||
import waitOn from 'wait-on'
|
||||
import dotenv from 'dotenv'
|
||||
import pc from 'picocolors'
|
||||
import minimist from 'minimist'
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
const env = dotenv.config({
|
||||
path: path.resolve(process.cwd(), '../../.env'),
|
||||
})
|
||||
const envForEsbuild = {}
|
||||
Object.entries(env.parsed || {}).forEach(([key, value]) => {
|
||||
envForEsbuild[`process.env.${key}`] = `"${value}"`
|
||||
})
|
||||
console.log(envForEsbuild)
|
||||
|
||||
const argv = minimist(process.argv.slice(2))
|
||||
const TAG = '[script/build.main.ts]'
|
||||
const spinner = ora(`${TAG} Main Process Building...`)
|
||||
|
||||
const options = {
|
||||
entryPoints: ['./main/index.ts', './main/rendererPreload.ts'],
|
||||
outdir: './dist',
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
bundle: true,
|
||||
define: envForEsbuild,
|
||||
minify: true,
|
||||
external: [
|
||||
...builtinModules.filter(x => !/^_|^(internal|v8|node-inspect)\/|\//.test(x)),
|
||||
'electron',
|
||||
'NeteaseCloudMusicApi',
|
||||
'better-sqlite3',
|
||||
],
|
||||
}
|
||||
|
||||
const runApp = () => {
|
||||
return spawn(electron, [path.resolve(process.cwd(), './dist/index.js')], {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (argv.watch) {
|
||||
waitOn(
|
||||
{
|
||||
resources: [`http://127.0.0.1:${process.env.ELECTRON_WEB_SERVER_PORT}/index.html`],
|
||||
timeout: 5000,
|
||||
},
|
||||
err => {
|
||||
if (err) {
|
||||
console.log(err)
|
||||
process.exit(1)
|
||||
} else {
|
||||
let child
|
||||
build({
|
||||
...options,
|
||||
watch: {
|
||||
onRebuild(error) {
|
||||
if (error) {
|
||||
console.error(pc.red('Rebuild Failed:'), error)
|
||||
} else {
|
||||
console.log(pc.green('Rebuild Succeeded'))
|
||||
if (child) child.kill()
|
||||
child = runApp()
|
||||
}
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
}).then(() => {
|
||||
console.log(pc.yellow(`⚡ Run App`))
|
||||
if (child) child.kill()
|
||||
child = runApp()
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
spinner.start()
|
||||
build({
|
||||
...options,
|
||||
define: {
|
||||
...options.define,
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
console.log(TAG, pc.green('Main Process Build Succeeded.'))
|
||||
})
|
||||
.catch(error => {
|
||||
console.log(`\n${TAG} ${pc.red('Main Process Build Failed')}\n`, error, '\n')
|
||||
})
|
||||
.finally(() => {
|
||||
spinner.stop()
|
||||
})
|
||||
}
|
||||
163
packages/desktop/scripts/build.sqlite3.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const { rebuild } = require('electron-rebuild')
|
||||
const fs = require('fs')
|
||||
const minimist = require('minimist')
|
||||
const pc = require('picocolors')
|
||||
const pkg = require(`${process.cwd()}/package.json`)
|
||||
const axios = require('axios')
|
||||
const { execSync } = require('child_process')
|
||||
const { resolve } = require('path')
|
||||
const { promisify } = require('util')
|
||||
const stream = require('stream')
|
||||
|
||||
type Arch = typeof process.arch
|
||||
|
||||
const isWindows = process.platform === 'win32'
|
||||
const isMac = process.platform === 'darwin'
|
||||
const isLinux = process.platform === 'linux'
|
||||
|
||||
const argv = minimist(process.argv.slice(2))
|
||||
const electronVersion = pkg.devDependencies.electron.replaceAll('^', '')
|
||||
const betterSqlite3Version = pkg.dependencies['better-sqlite3'].replaceAll('^', '')
|
||||
|
||||
const projectDir = resolve(process.cwd(), '../../')
|
||||
const tmpDir = resolve(projectDir, `./tmp/better-sqlite3`)
|
||||
const binDir = resolve(projectDir, `./tmp/bin`)
|
||||
console.log(pc.cyan(`projectDir=${projectDir}`))
|
||||
console.log(pc.cyan(`binDir=${binDir}`))
|
||||
|
||||
const finished = promisify(stream.finished)
|
||||
|
||||
if (!fs.existsSync(binDir)) {
|
||||
console.log(pc.cyan(`Creating dist/binary directory: ${binDir}`))
|
||||
fs.mkdirSync(binDir, {
|
||||
recursive: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Get Electron Module Version
|
||||
let electronModuleVersion = ''
|
||||
async function getElectronModuleVersion() {
|
||||
const releases = await axios.get('https://releases.electronjs.org/releases.json')
|
||||
if (!releases.data) {
|
||||
console.error(pc.red('Can not get electron releases'))
|
||||
process.exit(1)
|
||||
}
|
||||
electronModuleVersion = releases.data.find(r => r.version.includes(electronVersion))?.modules
|
||||
if (!electronModuleVersion) {
|
||||
console.error(pc.red('Can not find electron module version in electron-releases'))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(pc.cyan(`electronModuleVersion=${electronModuleVersion}`))
|
||||
}
|
||||
|
||||
// Download better-sqlite library from GitHub Release
|
||||
async function download(arch: Arch) {
|
||||
console.log(pc.cyan(`Downloading ${arch} binary...`))
|
||||
if (!electronModuleVersion) {
|
||||
console.log(pc.red('No electron module version found! Skip download.'))
|
||||
return false
|
||||
}
|
||||
const fileName = `better-sqlite3-v${betterSqlite3Version}-electron-v${electronModuleVersion}-${process.platform}-${arch}`
|
||||
const zipFileName = `${fileName}.tar.gz`
|
||||
const url = `https://github.com/JoshuaWise/better-sqlite3/releases/download/v${betterSqlite3Version}/${zipFileName}`
|
||||
if (!fs.existsSync(tmpDir)) {
|
||||
fs.mkdirSync(tmpDir, {
|
||||
recursive: true,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await axios({
|
||||
method: 'get',
|
||||
url,
|
||||
responseType: 'stream',
|
||||
}).then(response => {
|
||||
const writer = fs.createWriteStream(resolve(tmpDir, `./${zipFileName}`))
|
||||
response.data.pipe(writer)
|
||||
return finished(writer)
|
||||
})
|
||||
} catch (e) {
|
||||
console.log(pc.red('Download failed! Skip download.', e))
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`tar -xvzf ${tmpDir}/${zipFileName} -C ${tmpDir}`)
|
||||
} catch (e) {
|
||||
console.log(pc.red('Extract failed! Skip extract.', e))
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
fs.copyFileSync(
|
||||
resolve(tmpDir, './build/Release/better_sqlite3.node'),
|
||||
resolve(binDir, `./better_sqlite3_${process.platform}_${arch}.node`)
|
||||
)
|
||||
} catch (e) {
|
||||
console.log(pc.red('Copy failed! Skip copy.', e))
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmSync(resolve(tmpDir, `./build`), { recursive: true, force: true })
|
||||
} catch (e) {
|
||||
console.log(pc.red('Delete failed! Skip delete.'))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Build better-sqlite library on this device
|
||||
async function build(arch: Arch) {
|
||||
const downloaded = await download(arch)
|
||||
if (downloaded) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log(pc.cyan(`Building for ${arch}...`))
|
||||
await rebuild({
|
||||
projectRootPath: projectDir,
|
||||
buildPath: process.cwd(),
|
||||
electronVersion,
|
||||
arch,
|
||||
onlyModules: ['better-sqlite3'],
|
||||
force: true,
|
||||
})
|
||||
.then(() => {
|
||||
console.info('Build succeeded')
|
||||
|
||||
const from = resolve(
|
||||
projectDir,
|
||||
`./node_modules/better-sqlite3/build/Release/better_sqlite3.node`
|
||||
)
|
||||
const to = resolve(binDir, `./better_sqlite3_${process.platform}_${arch}.node`)
|
||||
console.info(`copy ${from} to ${to}`)
|
||||
fs.copyFileSync(from, to)
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(pc.red('Build failed!'))
|
||||
console.error(pc.red(e))
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await getElectronModuleVersion()
|
||||
if (argv.x64 || argv.arm64 || argv.arm) {
|
||||
if (argv.x64) await build('x64')
|
||||
if (argv.arm64) await build('arm64')
|
||||
} else {
|
||||
if (isWindows) {
|
||||
await build('x64')
|
||||
} else if (isMac) {
|
||||
await build('x64')
|
||||
await build('arm64')
|
||||
} else if (isLinux) {
|
||||
await build('x64')
|
||||
await build('arm64')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
64
packages/desktop/scripts/copySQLite3.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const path = require('path')
|
||||
const pc = require('picocolors')
|
||||
const fs = require('fs')
|
||||
|
||||
const archs = ['ia32', 'x64', 'armv7l', 'arm64', 'universal']
|
||||
|
||||
const projectDir = path.resolve(process.cwd(), '../../')
|
||||
const binDir = `${projectDir}/tmp/bin`
|
||||
console.log(pc.cyan(`projectDir=${projectDir}`))
|
||||
console.log(pc.cyan(`binDir=${binDir}`))
|
||||
|
||||
exports.default = async function (context) {
|
||||
// console.log(context)
|
||||
const platform = context.electronPlatformName
|
||||
const arch = archs?.[context.arch]
|
||||
|
||||
// Mac
|
||||
if (platform === 'darwin') {
|
||||
if (arch === 'universal') return // Skip universal we already copy binary for x64 and arm64
|
||||
if (arch !== 'x64' && arch !== 'arm64') return // Skip other archs
|
||||
|
||||
const from = `${binDir}/better_sqlite3_darwin_${arch}.node`
|
||||
const to = `${context.appOutDir}/${context.packager.appInfo.productFilename}.app/Contents/Resources/bin/better_sqlite3.node`
|
||||
console.info(`copy ${from} to ${to}`)
|
||||
|
||||
const toFolder = to.replace('/better_sqlite3.node', '')
|
||||
if (!fs.existsSync(toFolder)) {
|
||||
fs.mkdirSync(toFolder, {
|
||||
recursive: true,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
fs.copyFileSync(from, to)
|
||||
} catch (e) {
|
||||
console.log(pc.red('Copy failed! Process stopped.'))
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// Windows and Linux
|
||||
if (platform === 'win32' || platform === 'linux') {
|
||||
if (platform === 'win32' && arch !== 'x64') return // Skip windows arm
|
||||
|
||||
const from = `${binDir}/better_sqlite3_${platform}_${arch}.node`
|
||||
const to = `${context.appOutDir}/resources/bin/better_sqlite3.node`
|
||||
console.info(`copy ${from} to ${to}`)
|
||||
|
||||
const toFolder = to.replace('/better_sqlite3.node', '')
|
||||
if (!fs.existsSync(toFolder)) {
|
||||
fs.mkdirSync(toFolder, {
|
||||
recursive: true,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
fs.copyFileSync(from, to)
|
||||
} catch (e) {
|
||||
console.log(pc.red('Copy failed! Process stopped.'))
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
19
packages/desktop/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": "../",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["./**/*.ts", "../shared/**/*.ts"]
|
||||
}
|
||||
5
packages/server/.env.example
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
APPLE_MUSIC_TOKEN = Bearer xxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# If you want to use a different database provider like SQLite/PostgreSQL,
|
||||
# you will need to change the 'provider' in /prisma/schema.prisma
|
||||
DATABASE_URL='mysql://USER:PASSWORD@HOST:PORT/DATABASE'
|
||||
65
packages/server/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules
|
||||
jspm_packages
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# 0x
|
||||
profile-*
|
||||
|
||||
# mac files
|
||||
.DS_Store
|
||||
|
||||
# vim swap files
|
||||
*.swp
|
||||
|
||||
# webstorm
|
||||
.idea
|
||||
|
||||
# vscode
|
||||
.vscode
|
||||
*code-workspace
|
||||
|
||||
# clinic
|
||||
profile*
|
||||
*clinic*
|
||||
*flamegraph*
|
||||
|
||||
# generated code
|
||||
examples/typescript-server.js
|
||||
test/types/index.js
|
||||
|
||||
# compiled app
|
||||
dist
|
||||
24
packages/server/README.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Getting Started with [Fastify-CLI](https://www.npmjs.com/package/fastify-cli)
|
||||
|
||||
This project was bootstrapped with Fastify-CLI.
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm run dev`
|
||||
|
||||
To start the app in dev mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
### `npm start`
|
||||
|
||||
For production mode
|
||||
|
||||
### `npm run test`
|
||||
|
||||
Run the test cases.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn Fastify, check out the [Fastify documentation](https://www.fastify.io/docs/latest/).
|
||||
5685
packages/server/package-lock.json
generated
Normal file
36
packages/server/package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"description": "This project was bootstrapped with Fastify-CLI.",
|
||||
"main": "app.ts",
|
||||
"scripts": {
|
||||
"postinstall": "prisma generate",
|
||||
"start": "fastify start --port 35530 --address 0.0.0.0 -l info dist/app.js",
|
||||
"build": "tsc",
|
||||
"watch": "tsc -w",
|
||||
"dev": "npm run build && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"npm:watch\" \"npm:dev:start\"",
|
||||
"dev:start": "fastify start --ignore-watch=.ts$ -w --port 35530 --address 0.0.0.0 -l info -P dist/app.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fastify/autoload": "^5.0.0",
|
||||
"@fastify/sensible": "^4.1.0",
|
||||
"@fastify/static": "^6.6.1",
|
||||
"@prisma/client": "^4.8.1",
|
||||
"NeteaseCloudMusicApi": "^4.8.7",
|
||||
"axios": "^0.27.2",
|
||||
"fastify": "^4.5.3",
|
||||
"fastify-cli": "^4.4.0",
|
||||
"fastify-plugin": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.0.0",
|
||||
"concurrently": "^7.0.0",
|
||||
"fastify-tsconfig": "^1.0.1",
|
||||
"prisma": "^4.8.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^4.9.4"
|
||||
}
|
||||
}
|
||||
45
packages/server/prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
relationMode = "prisma"
|
||||
}
|
||||
|
||||
model Album {
|
||||
id Int @id @unique
|
||||
neteaseId Int @unique
|
||||
name String @db.Text
|
||||
neteaseName String @db.Text
|
||||
artistName String @db.Text
|
||||
neteaseArtistName String @db.Text
|
||||
copyright String? @db.Text
|
||||
editorialVideo String? @db.Text
|
||||
artwork String? @db.Text
|
||||
editorialNote AlbumEditorialNote?
|
||||
}
|
||||
|
||||
model AlbumEditorialNote {
|
||||
id Int @id @unique
|
||||
album Album @relation(fields: [id], references: [id])
|
||||
en_US String? @map("en-US") @db.Text
|
||||
zh_CN String? @map("zh-CN") @db.Text
|
||||
}
|
||||
|
||||
model Artist {
|
||||
id Int @id @unique
|
||||
neteaseId Int @unique
|
||||
name String @db.Text
|
||||
artwork String? @db.Text
|
||||
editorialVideo String? @db.Text
|
||||
artistBio ArtistBio?
|
||||
}
|
||||
|
||||
model ArtistBio {
|
||||
id Int @id @unique
|
||||
artist Artist @relation(fields: [id], references: [id])
|
||||
en_US String? @map("en-US") @db.Text
|
||||
zh_CN String? @map("zh-CN") @db.Text
|
||||
}
|
||||