Compare commits
149 commits
v2.0.0-alp
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 71817baad0 | |||
| f6735c9c26 | |||
| 708a1a8eb8 | |||
|
|
70ab357799 | ||
|
|
c7e69158d2 | ||
|
|
022009b140 | ||
|
|
6849632abe | ||
|
|
c929beaf6c | ||
|
|
dfb09b3ccd | ||
|
|
9809a758b4 | ||
|
|
c9739b2d0e | ||
|
|
25e274a4f8 | ||
|
|
fc1c8d8512 | ||
|
|
e27879aa94 | ||
|
|
d219e48541 | ||
|
|
107f5765b0 | ||
|
|
adafffd86b | ||
|
|
9d807d1d63 | ||
|
|
481ba6bce3 | ||
|
|
df82c7cd22 | ||
|
|
bd5af9c721 | ||
|
|
7cb063d511 | ||
|
|
904e61bee6 | ||
|
|
24477694f8 | ||
|
|
87ef48b826 | ||
|
|
da8afc12cf | ||
|
|
84613dcf8a | ||
|
|
dd8aa175d1 | ||
|
|
e3caa24ca4 | ||
|
|
ae352f27e1 | ||
|
|
552a1d4b44 | ||
|
|
b0ed85689b | ||
|
|
a18e093d4a | ||
|
|
79a7c6d991 | ||
|
|
1a2c3e2843 | ||
|
|
741fdc973c | ||
|
|
1400636201 | ||
|
|
6e737b50ee | ||
|
|
c409e3b6ed | ||
|
|
e738d1e46d | ||
|
|
380c55a653 | ||
|
|
9241b3a26a | ||
|
|
3093b6f386 | ||
|
|
42366f4a32 | ||
|
|
6d6fd9a88c | ||
|
|
dc1e0aaf90 | ||
|
|
a5cb1f729d | ||
|
|
e997cd9907 | ||
|
|
c5c7ccc89e | ||
|
|
61d0b5953f | ||
|
|
a5bf5c7dfd | ||
|
|
fd40a29180 | ||
|
|
486b04b70b | ||
|
|
6ad756b215 | ||
|
|
ed1daab1f6 | ||
|
|
f2f4e2ce58 | ||
|
|
845bc8a921 | ||
|
|
f2efc4e682 | ||
|
|
f4d3d67132 | ||
|
|
e14e6d73c6 | ||
|
|
4ec550dc46 | ||
|
|
dd6d4bf1c6 | ||
|
|
a6e433bdc5 | ||
|
|
59898c7883 | ||
|
|
1b7e33c222 | ||
|
|
221ca63d3d | ||
|
|
b7f7ac8d31 | ||
|
|
65f5df8a60 | ||
|
|
8a50337854 | ||
|
|
7b97ac0139 | ||
|
|
1cb3e4b29f | ||
|
|
c89ebbdd22 | ||
|
|
ce738f6b40 | ||
|
|
2a0af8f975 | ||
|
|
2f452dbe74 | ||
|
|
622f95439d | ||
|
|
210e65dd9a | ||
|
|
241de709da | ||
|
|
c6804decfc | ||
|
|
75d3e28ef8 | ||
|
|
99371def54 | ||
|
|
70d2713643 | ||
|
|
41b72563ff | ||
|
|
345f3588bd | ||
|
|
022f740c3f | ||
|
|
ce778afff6 | ||
|
|
9fcb6da960 | ||
|
|
b589f82b6c | ||
|
|
f9e6164245 | ||
|
|
43c5bda806 | ||
|
|
6d3508c62a | ||
|
|
9e64222bdf | ||
|
|
7b911c1658 | ||
|
|
a31d552788 | ||
|
|
000cfda922 | ||
|
|
0abd616ca1 | ||
|
|
2a2ac5a37d | ||
|
|
1496a8a0d0 | ||
|
|
6b690baef6 | ||
|
|
b9cdade832 | ||
|
|
fbc1e9903e | ||
|
|
439f368fd6 | ||
|
|
bbbd729fdf | ||
|
|
c1efcb895c | ||
|
|
cb59eb94a1 | ||
|
|
f355d5da50 | ||
|
|
6e1d58964e | ||
|
|
c3aea5ee8d | ||
|
|
f064859a27 | ||
|
|
9e787bab03 | ||
|
|
97ac4117db | ||
|
|
8cd8ae4255 | ||
|
|
35edd84c22 | ||
|
|
4613feff18 | ||
|
|
fab099c6fb | ||
|
|
107bf53a39 | ||
|
|
e0f2d3fd57 | ||
|
|
93c7ba2fd8 | ||
|
|
5dd00bec87 | ||
|
|
21c7b5ae44 | ||
|
|
a9b05d66a6 | ||
|
|
c85af59b21 | ||
|
|
93ae57adbe | ||
|
|
e1f7618cbd | ||
|
|
3c798a5606 | ||
|
|
d87c4bad21 | ||
|
|
177a8c8eff | ||
|
|
aeda63faf7 | ||
|
|
0a1c847b4b | ||
|
|
ab85c51831 | ||
|
|
f88addc95d | ||
|
|
ea4b20755d | ||
|
|
16b525915e | ||
|
|
af0a997609 | ||
|
|
3fca7d16bb | ||
|
|
7d64dea29b | ||
|
|
999bf6fdb4 | ||
|
|
871217713a | ||
|
|
e5d1af49bf | ||
|
|
fe660cf1a9 | ||
|
|
5ff8868d3e | ||
|
|
1c25f11821 | ||
|
|
626786a008 | ||
|
|
b1c5873bd6 | ||
|
|
748db54f52 | ||
|
|
fbf695eb16 | ||
|
|
98068f55cb | ||
|
|
ef7f51ecf0 | ||
|
|
be35a8ded4 |
16
.dockerignore
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
.dockerignore
|
||||
.git
|
||||
.github
|
||||
.gitignore
|
||||
README.md
|
||||
LICENSE
|
||||
.vscode
|
||||
dist
|
||||
dist_electron
|
||||
build
|
||||
images
|
||||
script
|
||||
8
.editorconfig
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = false
|
||||
10
.env.example
|
|
@ -1,3 +1,7 @@
|
|||
ELECTRON_WEB_SERVER_PORT = 42710
|
||||
ELECTRON_DEV_NETEASE_API_PORT = 30001
|
||||
VITE_APP_NETEASE_API_URL = /netease
|
||||
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
|
||||
|
||||
|
|
|
|||
4
.envrc
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
source_url "https://raw.githubusercontent.com/cachix/devenv/82c0147677e510b247d8b9165c54f73d32dfd899/direnvrc" "sha256-7u4iDd1nZpxL4tCzmPG0dQgC5V+/44Ba+tHkPob1v2k="
|
||||
|
||||
export NIXPKGS_ALLOW_INSECURE=1
|
||||
use devenv
|
||||
27
.eslintrc.js
|
|
@ -1,27 +0,0 @@
|
|||
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
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
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 可打开控制台)
|
||||
114
.github/workflows/build.yaml
vendored
|
|
@ -1,9 +1,15 @@
|
|||
name: Build/Release
|
||||
name: Release
|
||||
|
||||
env:
|
||||
YARN_INSTALL_NOPT: yarn add --ignore-platform --ignore-optional
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- react
|
||||
- master
|
||||
tags:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
|
|
@ -11,80 +17,100 @@ jobs:
|
|||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest, ubuntu-18.04]
|
||||
os: [macos-latest, windows-latest, ubuntu-22.04]
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
submodules: "recursive"
|
||||
|
||||
- uses: pnpm/action-setup@v2.0.1
|
||||
with:
|
||||
version: 6.29.0
|
||||
|
||||
- name: Install Node.js 16
|
||||
uses: actions/setup-node@v2
|
||||
- name: Install Node.js, NPM and Yarn
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
cache: 'pnpm'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile false
|
||||
|
||||
- name: Build sqlite3 binaries
|
||||
run: node ./scripts/build.sqlite3.js
|
||||
|
||||
- name: Install RPM & Pacman (Linux)
|
||||
- name: Install RPM & Pacman (on Ubuntu)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update &&
|
||||
sudo apt-get install --no-install-recommends -y rpm &&
|
||||
sudo apt-get install --no-install-recommends -y bsdtar &&
|
||||
sudo apt-get install --no-install-recommends -y libarchive-tools &&
|
||||
sudo apt-get install --no-install-recommends -y libopenjp2-tools
|
||||
|
||||
- name: Install Snapcraft (Linux)
|
||||
- name: Install Snapcraft (on Ubuntu)
|
||||
uses: samuelmeuli/action-snapcraft@v1
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
# with:
|
||||
# Disable since the Snapcraft token is currently not working
|
||||
# snapcraft_token: ${{ secrets.snapcraft_token }}
|
||||
with:
|
||||
snapcraft_token: ${{ secrets.snapcraft_token }}
|
||||
|
||||
- name: Build/Release Electron app
|
||||
uses: njzydark/action-electron-builder-pnpm@v1.1.0-pnpm
|
||||
- id: get_unm_version
|
||||
name: Get the installed UNM version
|
||||
run: |
|
||||
yarn --ignore-optional
|
||||
unm_version=$(node -e "console.log(require('./node_modules/@unblockneteasemusic/rust-napi/package.json').version)")
|
||||
echo "::set-output name=unmver::${unm_version}"
|
||||
shell: bash
|
||||
|
||||
- name: Install UNM dependencies for Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
${{ env.YARN_INSTALL_NOPT }} \
|
||||
@unblockneteasemusic/rust-napi-win32-x64-msvc@${{steps.get_unm_version.outputs.unmver}}
|
||||
shell: bash
|
||||
|
||||
- name: Install UNM dependencies for macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
${{ env.YARN_INSTALL_NOPT }} \
|
||||
@unblockneteasemusic/rust-napi-darwin-x64@${{steps.get_unm_version.outputs.unmver}} \
|
||||
@unblockneteasemusic/rust-napi-darwin-arm64@${{steps.get_unm_version.outputs.unmver}} \
|
||||
dmg-license
|
||||
shell: bash
|
||||
|
||||
- name: Install UNM dependencies for Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
${{ env.YARN_INSTALL_NOPT }} \
|
||||
@unblockneteasemusic/rust-napi-linux-x64-gnu@${{steps.get_unm_version.outputs.unmver}} \
|
||||
@unblockneteasemusic/rust-napi-linux-arm64-gnu@${{steps.get_unm_version.outputs.unmver}} \
|
||||
@unblockneteasemusic/rust-napi-linux-arm-gnueabihf@${{steps.get_unm_version.outputs.unmver}}
|
||||
shell: bash
|
||||
|
||||
- name: Build/release Electron app
|
||||
uses: samuelmeuli/action-electron-builder@v1.6.0
|
||||
env:
|
||||
ELECTRON_WEB_SERVER_PORT: 42710
|
||||
VITE_APP_NETEASE_API_URL: /netease
|
||||
VITE_APP_LASTFM_API_KEY: 09c55292403d961aa517ff7f5e8a3d9c
|
||||
VITE_APP_LASTFM_API_SHARED_SECRET: 307c9fda32b3904e53654baff215cb67
|
||||
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
|
||||
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
|
||||
|
||||
- name: Upload Artifact (macOS)
|
||||
uses: actions/upload-artifact@v3
|
||||
use_vue_cli: true
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: R3PLAY-mac
|
||||
path: release/*-universal.dmg
|
||||
name: YesPlayMusic-mac
|
||||
path: dist_electron/*-universal.dmg
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload Artifact (Windows)
|
||||
uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: R3PLAY-win
|
||||
path: release/*x64-Setup.exe
|
||||
name: YesPlayMusic-win
|
||||
path: dist_electron/*Setup*.exe
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload Artifact (Linux)
|
||||
uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: R3PLAY-linux
|
||||
path: release/*.AppImage
|
||||
name: YesPlayMusic-linux
|
||||
path: dist_electron/*.AppImage
|
||||
if-no-files-found: ignore
|
||||
|
|
|
|||
48
.github/workflows/sync.yml
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
name: Upstream Sync
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
actions: write
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 * * * *' # every hour
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync_latest_from_upstream:
|
||||
name: Sync latest commits from upstream repo
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.repository.fork }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Clean issue notice
|
||||
uses: actions-cool/issues-helper@v3
|
||||
with:
|
||||
actions: 'close-issues'
|
||||
labels: '🚨 Sync Fail'
|
||||
|
||||
- name: Sync upstream changes
|
||||
id: sync
|
||||
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
|
||||
with:
|
||||
upstream_sync_repo: qier222/YesPlayMusic
|
||||
upstream_sync_branch: master
|
||||
target_sync_branch: master
|
||||
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
|
||||
test_mode: false
|
||||
|
||||
- name: Sync check
|
||||
if: failure()
|
||||
uses: actions-cool/issues-helper@v3
|
||||
with:
|
||||
actions: 'create-issue'
|
||||
title: '🚨 同步失败 | Sync Fail'
|
||||
labels: '🚨 Sync Fail'
|
||||
body: |
|
||||
Due to a change in the workflow file of the upstream repository, GitHub has automatically suspended the scheduled automatic update. You need to manually sync your fork.
|
||||
|
||||
由于上游仓库的 workflow 文件变更,导致 GitHub 自动暂停了本次自动更新,你需要手动 Sync Fork 一次。
|
||||
91
.gitignore
vendored
|
|
@ -1,60 +1,43 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# 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/prisma/client
|
||||
|
||||
#Electron-builder output
|
||||
/dist_electron
|
||||
NeteaseCloudMusicApi-master
|
||||
NeteaseCloudMusicApi-master.zip
|
||||
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
vercel.json
|
||||
# Devenv
|
||||
.devenv*
|
||||
devenv.local.nix
|
||||
|
||||
# direnv
|
||||
.direnv
|
||||
|
||||
# pre-commit
|
||||
.pre-commit-config.yaml
|
||||
|
|
|
|||
4
.npmrc
|
|
@ -1,4 +0,0 @@
|
|||
node-linker=hoisted
|
||||
public-hoist-pattern=*
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
14
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
tmp
|
||||
node_modules
|
||||
release
|
||||
build
|
||||
coverage
|
||||
dist
|
||||
|
|
|
|||
12
.prettierrc
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf",
|
||||
"bracketSpacing": true,
|
||||
"htmlWhitespaceSensitivity": "strict"
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
/**/*/node_modules
|
||||
tmp
|
||||
/**/*/dist
|
||||
/packages/desktop/release
|
||||
25
.vscode/i18n-ally-custom-framework.yml
vendored
|
|
@ -1,25 +0,0 @@
|
|||
# .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
|
||||
25
Dockerfile
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
FROM node:16.13.1-alpine as build
|
||||
ENV VUE_APP_NETEASE_API_URL=/api
|
||||
WORKDIR /app
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories &&\
|
||||
apk add --no-cache python3 make g++ git
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install
|
||||
COPY . .
|
||||
RUN yarn config set electron_mirror https://npmmirror.com/mirrors/electron/ && \
|
||||
yarn build
|
||||
|
||||
FROM nginx:1.20.2-alpine as app
|
||||
|
||||
COPY --from=build /app/package.json /usr/local/lib/
|
||||
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories &&\
|
||||
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 $(awk -F \" '{if($2=="NeteaseCloudMusicApi") print $2"@"$4}' /usr/local/lib/package.json) \
|
||||
&& rm -f /usr/local/lib/package.json
|
||||
|
||||
COPY --from=build /app/docker/nginx.conf.example /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
CMD nginx ; exec npx NeteaseCloudMusicApi
|
||||
2
LICENSE
|
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2022 qier222
|
||||
Copyright (c) 2020-2023 qier222
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
|
|||
270
README.md
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
<br />
|
||||
<p align="center">
|
||||
<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>
|
||||
|
||||
<p align="center">
|
||||
高颜值的第三方网易云播放器
|
||||
<br />
|
||||
<a href="https://music.ineko.cc" 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 />
|
||||
<br />
|
||||
</p>
|
||||
</p>
|
||||
|
||||
[![Library][library-screenshot]](https://music.ineko.cc)
|
||||
|
||||
|
||||
## 全新版本
|
||||
全新2.0 Alpha测试版已发布,欢迎前往 [Releases](https://github.com/qier222/YesPlayMusic/releases) 页面下载。
|
||||
当前版本将会进入维护模式,除重大bug修复外,不会再更新新功能。
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- ✅ 使用 Vue.js 全家桶开发
|
||||
- 🔴 网易云账号登录(扫码/手机/邮箱登录)
|
||||
- 📺 支持 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 用户可以通过 Homebrew 来安装:`brew install --cask yesplaymusic`
|
||||
|
||||
- Windows 用户可以通过 Scoop 来安装:`scoop install extras/yesplaymusic`
|
||||
|
||||
## ⚙️ 部署至 Vercel
|
||||
|
||||
除了下载安装包使用,你还可以将本项目部署到 Vercel 或你的服务器上。下面是部署到 Vercel 的方法。
|
||||
|
||||
本项目的 Demo (https://music.qier222.com) 就是部署在 Vercel 上的网站。
|
||||
|
||||
[](https://vercel.com/?utm_source=ohmusic&utm_campaign=oss)
|
||||
|
||||
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. 安装宝塔面板,前往[宝塔面板官网](https://www.bt.cn/new/download.html) ,选择正式版的脚本下载安装。
|
||||
|
||||
2. 安装后登录宝塔面板,在左侧导航栏中点击 Docker,首次进入会提示安装Docker服务,点击立即安装,按提示完成安装
|
||||
|
||||
3. 安装完成后在应用商店中找到YesPlayMusic,点击安装,配置域名、端口等基本信息即可完成安装。
|
||||
|
||||
4. 安装后在浏览器输入上一步骤设置的域名即可访问。
|
||||
|
||||
## ⚙️ 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`
|
||||
|
||||
## ⚙️ 部署至 Replit
|
||||
|
||||
1. 新建 Repl,选择 Bash 模板
|
||||
|
||||
2. 在 Replit shell 中运行以下命令
|
||||
|
||||
```sh
|
||||
bash <(curl -s -L https://raw.githubusercontent.com/qier222/YesPlayMusic/main/install-replit.sh)
|
||||
```
|
||||
|
||||
3. 首次运行成功后,只需点击绿色按钮 `Run` 即可再次运行
|
||||
|
||||
4. 由于 replit 个人版限制内存为 1G(教育版为 3G),构建过程中可能会失败,请再次运行上述命令或运行以下命令:
|
||||
|
||||
```sh
|
||||
cd /home/runner/${REPL_SLUG}/music && yarn install && yarn run build
|
||||
```
|
||||
|
||||
## 👷♂️ 打包客户端
|
||||
|
||||
如果在 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。
|
||||
|
||||
## 📜 开源许可
|
||||
|
||||
本项目仅供个人学习研究使用,禁止用于商业及非法用途。
|
||||
|
||||
基于 [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)
|
||||
|
||||
## 🖼️ 截图
|
||||
|
||||
![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]
|
||||
|
||||
<!-- MARKDOWN LINKS & IMAGES -->
|
||||
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
|
||||
|
||||
[album-screenshot]: images/album.png
|
||||
[artist-screenshot]: images/artist.png
|
||||
[explore-screenshot]: images/explore.png
|
||||
[home-screenshot]: images/home.png
|
||||
[home-2-screenshot]: images/home-2.png
|
||||
[lyrics-screenshot]: images/lyrics.png
|
||||
[library-screenshot]: images/library.png
|
||||
[library-dark-screenshot]: images/library-dark.png
|
||||
[search-screenshot]: images/search.png
|
||||
11
babel.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
[
|
||||
'@vue/cli-plugin-babel/preset',
|
||||
{
|
||||
useBuiltIns: 'usage',
|
||||
shippedProposals: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
Before Width: | Height: | Size: 523 KiB After Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 474 B After Width: | Height: | Size: 474 B |
|
Before Width: | Height: | Size: 750 B After Width: | Height: | Size: 750 B |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 965 B After Width: | Height: | Size: 965 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
132
devenv.lock
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
{
|
||||
"nodes": {
|
||||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1730412360,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "45847cb1f14a6d8cfa86ea943703c54a8798ae7e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"dir": "src/modules",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1696426674,
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"pre-commit-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1730272153,
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "2d2a9ddbe3f2c00747398f3dc9b05f7f2ebb0f53",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-stable": {
|
||||
"locked": {
|
||||
"lastModified": 1730327045,
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "080166c15633801df010977d9d7474b4a6c549d7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-24.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nodejs16": {
|
||||
"locked": {
|
||||
"lastModified": 1700230496,
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a71323f68d4377d12c04a5410e214495ec598d4c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a71323f68d4377d12c04a5410e214495ec598d4c",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pre-commit-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"nixpkgs-stable": "nixpkgs-stable"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1730302582,
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"rev": "af8a16fe5c264f5e9e18bcee2859b40a656876cf",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"nodejs16": "nodejs16",
|
||||
"pre-commit-hooks": "pre-commit-hooks"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
53
devenv.nix
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
{ pkgs, lib, config, inputs, ... }:
|
||||
|
||||
let
|
||||
nodejs16 = import inputs.nodejs16 { system = pkgs.stdenv.system; };
|
||||
in
|
||||
{
|
||||
# https://devenv.sh/basics/
|
||||
env.GREET = "devenv";
|
||||
|
||||
# https://devenv.sh/packages/
|
||||
packages = [ pkgs.git ] ++ lib.optionals pkgs.stdenv.isDarwin (with pkgs.darwin.apple_sdk; [
|
||||
frameworks.AppKit
|
||||
]);
|
||||
|
||||
# https://devenv.sh/languages/
|
||||
languages.javascript.enable = true;
|
||||
languages.javascript.package = nodejs16.pkgs.nodejs_16;
|
||||
languages.javascript.corepack.enable = true;
|
||||
# languages.rust.enable = true;
|
||||
|
||||
# https://devenv.sh/processes/
|
||||
# processes.cargo-watch.exec = "cargo-watch";
|
||||
|
||||
# https://devenv.sh/services/
|
||||
# services.postgres.enable = true;
|
||||
|
||||
# https://devenv.sh/scripts/
|
||||
scripts.hello.exec = ''
|
||||
echo hello from $GREET
|
||||
'';
|
||||
|
||||
enterShell = ''
|
||||
hello
|
||||
git --version
|
||||
'';
|
||||
|
||||
# https://devenv.sh/tasks/
|
||||
# tasks = {
|
||||
# "myproj:setup".exec = "mytool build";
|
||||
# "devenv:enterShell".after = [ "myproj:setup" ];
|
||||
# };
|
||||
|
||||
# https://devenv.sh/tests/
|
||||
enterTest = ''
|
||||
echo "Running tests"
|
||||
git --version | grep --color=auto "${pkgs.git.version}"
|
||||
'';
|
||||
|
||||
# https://devenv.sh/pre-commit-hooks/
|
||||
# pre-commit.hooks.shellcheck.enable = true;
|
||||
|
||||
# See full reference at https://devenv.sh/reference/options/
|
||||
}
|
||||
19
devenv.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
|
||||
inputs:
|
||||
nixpkgs:
|
||||
url: github:nixos/nixpkgs/nixpkgs-unstable
|
||||
nodejs16:
|
||||
url: github:nixos/nixpkgs/a71323f68d4377d12c04a5410e214495ec598d4c
|
||||
|
||||
# https://github.com/cachix/devenv/issues/792#issuecomment-2043166453
|
||||
impure: true
|
||||
# If you're using non-OSS software, you can set allowUnfree to true.
|
||||
# allowUnfree: true
|
||||
|
||||
# If you're willing to use a package that's vulnerable
|
||||
# permittedInsecurePackages:
|
||||
# - "openssl-1.1.1w"
|
||||
|
||||
# If you have more than one devenv you can merge them
|
||||
#imports:
|
||||
# - ./backend
|
||||
39
docker-compose.yml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
services:
|
||||
YesPlayMusic:
|
||||
build:
|
||||
context: .
|
||||
image: yesplaymusic
|
||||
container_name: YesPlayMusic
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- ./docker/nginx.conf.example:/etc/nginx/conf.d/default.conf:ro
|
||||
ports:
|
||||
- 80:80
|
||||
restart: always
|
||||
depends_on:
|
||||
- UnblockNeteaseMusic
|
||||
environment:
|
||||
- NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
networks:
|
||||
my_network:
|
||||
|
||||
UnblockNeteaseMusic:
|
||||
image: pan93412/unblock-netease-music-enhanced
|
||||
command: -o kugou kuwo migu bilibili pyncmd -p 80:443 -f 45.127.129.53 -e -
|
||||
# environment:
|
||||
# JSON_LOG: true
|
||||
# LOG_LEVEL: debug
|
||||
networks:
|
||||
my_network:
|
||||
aliases:
|
||||
- music.163.com
|
||||
- interface.music.163.com
|
||||
- interface3.music.163.com
|
||||
- interface.music.163.com.163jiasu.com
|
||||
- interface3.music.163.com.163jiasu.com
|
||||
restart: always
|
||||
|
||||
networks:
|
||||
my_network:
|
||||
driver: bridge
|
||||
28
docker/nginx.conf.example
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
server {
|
||||
gzip on;
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location @rewrites {
|
||||
rewrite ^(.*)$ /index.html last;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_buffers 16 32k;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_busy_buffers_size 128k;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Host $remote_addr;
|
||||
proxy_set_header X-NginX-Proxy true;
|
||||
proxy_pass http://localhost:3000/;
|
||||
}
|
||||
}
|
||||
BIN
images/album.png
Normal file
|
After Width: | Height: | Size: 253 KiB |
BIN
images/artist.png
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
images/explore.png
Normal file
|
After Width: | Height: | Size: 354 KiB |
BIN
images/home-2.png
Normal file
|
After Width: | Height: | Size: 312 KiB |
BIN
images/home.png
Normal file
|
After Width: | Height: | Size: 389 KiB |
BIN
images/library-dark.png
Normal file
|
After Width: | Height: | Size: 335 KiB |
BIN
images/library.png
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
images/logo.png
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
images/lyrics.png
Normal file
|
After Width: | Height: | Size: 339 KiB |
BIN
images/search.png
Normal file
|
After Width: | Height: | Size: 276 KiB |
28
install-replit.sh
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/bash
|
||||
|
||||
# 初始化 .replit 和 replit.nix
|
||||
if [[ $1 == i ]];then
|
||||
echo -e 'run = ["bash", "main.sh"]\n\nentrypoint = "main.sh"' >.replit
|
||||
echo -e "{ pkgs }: {\n\t\tdeps = [\n\t\t\tpkgs.nodejs-16_x\n\t\t\tpkgs.yarn\n\t\t\tpkgs.bashInteractive\n\t\t];\n}" > replit.nix
|
||||
exit
|
||||
fi
|
||||
|
||||
# 安装
|
||||
if [[ ! -d api ]];then
|
||||
mkdir api
|
||||
git clone https://github.com/Binaryify/NeteaseCloudMusicApi ./api && \
|
||||
cd api && npm install && cd ..
|
||||
fi
|
||||
|
||||
if [[ ! -d music ]];then
|
||||
mkdir music
|
||||
git clone https://github.com/qier222/YesPlayMusic ./music && \
|
||||
cd music && cp .env.example .env && npm install --force && npm run build && cd ..
|
||||
fi
|
||||
|
||||
# 启动
|
||||
PID=`ps -ef | grep npm | awk '{print $2}' | sed '$d'`
|
||||
|
||||
if [[ ! -z ${PID} ]];then echo $PID | xargs kill;fi
|
||||
nohup bash -c 'cd api && PORT=35216 node app.js' > api.log 2>&1
|
||||
nohup bash -c 'npx serve music/dist/' > music.log 2>&1
|
||||
15
jsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
// 支持 @ 的别名解析
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"target": "ES6",
|
||||
"module": "commonjs",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
156
package.json
|
|
@ -1,35 +1,135 @@
|
|||
{
|
||||
"name": "r3play",
|
||||
"productName": "R3PLAY",
|
||||
"name": "yesplaymusic",
|
||||
"version": "0.4.9",
|
||||
"private": true,
|
||||
"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"
|
||||
},
|
||||
"packageManager": "pnpm@7.20.0",
|
||||
"description": "A third party music player for Netease Music",
|
||||
"author": "qier222<qier222@outlook.com>",
|
||||
"scripts": {
|
||||
"postinstall": "turbo run post-install --parallel --no-cache",
|
||||
"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}\""
|
||||
"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"
|
||||
},
|
||||
"main": "background.js",
|
||||
"engines": {
|
||||
"node": "14 || 16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@unblockneteasemusic/rust-napi": "^0.4.0",
|
||||
"NeteaseCloudMusicApi": "^4.23.3",
|
||||
"axios": "^0.26.1",
|
||||
"change-case": "^4.1.2",
|
||||
"cli-color": "^2.0.0",
|
||||
"color": "^4.2.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": "^3.1.2",
|
||||
"electron-debug": "^3.1.0",
|
||||
"electron-devtools-installer": "^3.2",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-is-dev": "^2.0.0",
|
||||
"electron-log": "^4.3.0",
|
||||
"electron-store": "^8.0.1",
|
||||
"electron-updater": "^5.0.1",
|
||||
"esbuild": "^0.20.1",
|
||||
"esbuild-loader": "^4.0.3",
|
||||
"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",
|
||||
"qrcode": "^1.4.4",
|
||||
"register-service-worker": "^1.7.1",
|
||||
"svg-sprite-loader": "^6.0.11",
|
||||
"tunnel": "^0.0.6",
|
||||
"vscode-codicons": "^0.0.17",
|
||||
"vue": "^2.6.11",
|
||||
"vue-clipboard2": "^0.3.1",
|
||||
"vue-gtag": "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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.31.0",
|
||||
"prettier": "^2.8.1",
|
||||
"turbo": "^1.6.3",
|
||||
"typescript": "^4.9.5",
|
||||
"tsx": "^3.12.1",
|
||||
"prettier-plugin-tailwindcss": "^0.2.1"
|
||||
"@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",
|
||||
"prettier": "2.5.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
/**
|
||||
* @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,
|
||||
copyright: 'Copyright © 2022 qier222',
|
||||
asar: true,
|
||||
directories: {
|
||||
output: 'release',
|
||||
buildResources: 'build',
|
||||
},
|
||||
npmRebuild: false,
|
||||
buildDependenciesFromSource: false,
|
||||
electronVersion,
|
||||
forceCodeSigning: false,
|
||||
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',
|
||||
'!**/node_modules/better-sqlite3/{bin,build,deps}/**',
|
||||
'!**/node_modules/*/{*.MD,*.md,README,readme}',
|
||||
'!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}',
|
||||
'!**/node_modules/*.d.ts',
|
||||
'!**/node_modules/.bin',
|
||||
'!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}',
|
||||
'!.editorconfig',
|
||||
'!**/._*',
|
||||
'!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}',
|
||||
'!**/{__pycache__,thumbs.db,.flowconfig,.idea,.vs,.nyc_output}',
|
||||
'!**/{appveyor.yml,.travis.yml,circle.yml}',
|
||||
'!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json,pnpm-lock.yaml}',
|
||||
'!**/*.{map,debug.min.js}',
|
||||
|
||||
// copy prisma
|
||||
{
|
||||
from: './prisma',
|
||||
to: 'main/prisma',
|
||||
},
|
||||
{
|
||||
from: './prisma',
|
||||
to: 'main',
|
||||
filter: '*.prisma' // only copy prisma schema
|
||||
},
|
||||
|
||||
{
|
||||
from: './dist',
|
||||
to: './main',
|
||||
},
|
||||
{
|
||||
from: '../web/dist',
|
||||
to: './web',
|
||||
},
|
||||
{
|
||||
from: './migrations',
|
||||
to: 'main/migrations',
|
||||
},
|
||||
{
|
||||
from: './assets',
|
||||
to: 'main/assets',
|
||||
},
|
||||
'./main',
|
||||
],
|
||||
}
|
||||
|
Before Width: | Height: | Size: 936 B |
|
Before Width: | Height: | Size: 612 B |
|
Before Width: | Height: | Size: 844 B |
|
Before Width: | Height: | Size: 890 B |
|
|
@ -1,40 +0,0 @@
|
|||
import fastifyCookie from '@fastify/cookie'
|
||||
import fastifyMultipart from '@fastify/multipart'
|
||||
import fastifyStatic from '@fastify/static'
|
||||
import fastify from 'fastify'
|
||||
import path from 'path'
|
||||
import { isProd } from '../env'
|
||||
import log from '../log'
|
||||
import netease from './routes/netease/netease'
|
||||
import appleMusic from './routes/r3play/appleMusic'
|
||||
import audio from './routes/r3play/audio'
|
||||
|
||||
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
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
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'
|
||||
import cache from '../../../cache'
|
||||
|
||||
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
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { FastifyInstance } from 'fastify'
|
||||
import proxy from '@fastify/http-proxy'
|
||||
import { isDev } from '@/desktop/main/env'
|
||||
|
||||
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
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import NeteaseCloudMusicApi, { SoundQualityType } from 'NeteaseCloudMusicApi'
|
||||
import prisma from '@/desktop/main/prisma'
|
||||
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'
|
||||
|
||||
const getAudioFromCache = async (id: number) => {
|
||||
// get from cache
|
||||
const cache = await prisma.audio.findUnique({ where: { 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
|
||||
|
|
@ -1,311 +0,0 @@
|
|||
import prisma from './prisma'
|
||||
import { app } from 'electron'
|
||||
import log from './log'
|
||||
import fs from 'fs'
|
||||
import * as musicMetadata from 'music-metadata'
|
||||
import { CacheAPIs, CacheAPIsParams, CacheAPIsResponse } from '@/shared/CacheAPIs'
|
||||
import { FastifyReply } from 'fastify'
|
||||
|
||||
class Cache {
|
||||
constructor() {
|
||||
//
|
||||
}
|
||||
|
||||
async set<T extends CacheAPIs>(
|
||||
api: T,
|
||||
data: CacheAPIsResponse[T],
|
||||
query: { [key: string]: string } = {}
|
||||
) {
|
||||
if (!data) return
|
||||
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: {
|
||||
const id = api
|
||||
const row = { id, json: JSON.stringify(data) }
|
||||
await prisma.accountData.upsert({ where: { id }, create: row, update: row })
|
||||
break
|
||||
}
|
||||
case CacheAPIs.Track: {
|
||||
const res = data as CacheAPIsResponse[CacheAPIs.Track]
|
||||
if (!res.songs) return
|
||||
await Promise.all(
|
||||
res.songs.map(t => {
|
||||
const id = t.id
|
||||
const row = { id, json: JSON.stringify(t) }
|
||||
return prisma.track.upsert({ where: { id }, create: row, update: row })
|
||||
})
|
||||
)
|
||||
break
|
||||
}
|
||||
case CacheAPIs.Album: {
|
||||
const res = data as CacheAPIsResponse[CacheAPIs.Album]
|
||||
if (!res.album) return
|
||||
res.album.songs = data.songs
|
||||
const id = data.album.id
|
||||
const row = { id, json: JSON.stringify(data.album) }
|
||||
await prisma.album.upsert({ where: { id }, update: row, create: row })
|
||||
break
|
||||
}
|
||||
case CacheAPIs.Playlist: {
|
||||
if (!data.playlist) return
|
||||
const id = data.playlist.id
|
||||
const row = { id, json: JSON.stringify(data) }
|
||||
await prisma.playlist.upsert({ where: { id }, update: row, create: row })
|
||||
break
|
||||
}
|
||||
case CacheAPIs.Artist: {
|
||||
if (!data.artist) return
|
||||
const id = data.artist.id
|
||||
const row = { id, json: JSON.stringify(data) }
|
||||
await prisma.artist.upsert({ where: { id }, update: row, create: row })
|
||||
break
|
||||
}
|
||||
case CacheAPIs.ArtistAlbum: {
|
||||
const res = data as CacheAPIsResponse[CacheAPIs.ArtistAlbum]
|
||||
if (!res.hotAlbums) return
|
||||
|
||||
const id = data.artist.id
|
||||
const row = { id, hotAlbums: res.hotAlbums.map(a => a.id).join(',') }
|
||||
await prisma.artistAlbum.upsert({ where: { id }, update: row, create: row })
|
||||
await Promise.all(
|
||||
res.hotAlbums.map(async album => {
|
||||
const id = album.id
|
||||
const existAlbum = await prisma.album.findUnique({ where: { id } })
|
||||
if (!existAlbum) {
|
||||
await prisma.album.create({ data: { id, json: JSON.stringify(album) } })
|
||||
}
|
||||
})
|
||||
)
|
||||
break
|
||||
}
|
||||
case CacheAPIs.Lyric: {
|
||||
if (!data.lrc) return
|
||||
const id = Number(query.id)
|
||||
const row = { id, json: JSON.stringify(data) }
|
||||
await prisma.lyrics.upsert({ where: { id }, update: row, create: row })
|
||||
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
|
||||
const id = data.id
|
||||
const row = { id, json: JSON.stringify(data) }
|
||||
await prisma.appleMusicAlbum.upsert({ where: { id }, update: row, create: row })
|
||||
break
|
||||
}
|
||||
case CacheAPIs.AppleMusicArtist: {
|
||||
if (!data) return
|
||||
const id = data.id
|
||||
const row = { id, json: JSON.stringify(data) }
|
||||
await prisma.artist.upsert({ where: { id }, update: row, create: row })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async get<T extends CacheAPIs>(
|
||||
api: T,
|
||||
query: CacheAPIsParams[T]
|
||||
): Promise<CacheAPIsResponse[T] | undefined> {
|
||||
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 = await prisma.accountData.findUnique({ where: { id: api } })
|
||||
if (data?.json) return JSON.parse(data.json)
|
||||
break
|
||||
}
|
||||
case CacheAPIs.Track: {
|
||||
const typedQuery = query as CacheAPIsParams[CacheAPIs.Track]
|
||||
const ids: number[] = typedQuery?.ids.split(',').map((id: string) => Number(id))
|
||||
if (ids.length === 0) return
|
||||
|
||||
if (ids.includes(NaN)) return
|
||||
|
||||
const tracksRaw = await prisma.track.findMany({ where: { id: { in: 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: {
|
||||
const typedQuery = query as CacheAPIsParams[CacheAPIs.Album]
|
||||
const id = Number(typedQuery?.id)
|
||||
if (isNaN(id)) return
|
||||
const data = await prisma.album.findUnique({ where: { id } })
|
||||
if (data?.json)
|
||||
return {
|
||||
resourceState: true,
|
||||
songs: [],
|
||||
code: 200,
|
||||
album: JSON.parse(data.json),
|
||||
}
|
||||
break
|
||||
}
|
||||
case CacheAPIs.Playlist: {
|
||||
const typedQuery = query as CacheAPIsParams[CacheAPIs.Playlist]
|
||||
const id = Number(typedQuery?.id)
|
||||
if (isNaN(id)) return
|
||||
const data = await prisma.playlist.findUnique({ where: { id } })
|
||||
if (data?.json) return JSON.parse(data.json)
|
||||
break
|
||||
}
|
||||
case CacheAPIs.Artist: {
|
||||
const typedQuery = query as CacheAPIsParams[CacheAPIs.Artist]
|
||||
const id = Number(typedQuery?.id)
|
||||
if (isNaN(id)) return
|
||||
const data = await prisma.artist.findUnique({ where: { id } })
|
||||
if (data?.json) return JSON.parse(data.json)
|
||||
break
|
||||
}
|
||||
case CacheAPIs.ArtistAlbum: {
|
||||
const typedQuery = query as CacheAPIsParams[CacheAPIs.ArtistAlbum]
|
||||
const id = Number(typedQuery?.id)
|
||||
if (isNaN(id)) return
|
||||
|
||||
const artistAlbums = await prisma.artistAlbum.findUnique({ where: { id } })
|
||||
if (!artistAlbums?.hotAlbums) return
|
||||
const ids = artistAlbums.hotAlbums.split(',').map(Number)
|
||||
|
||||
const albumsRaw = await prisma.album.findMany({
|
||||
where: { id: { in: ids } },
|
||||
})
|
||||
if (albumsRaw.length !== ids.length) return
|
||||
const albums = albumsRaw.map(a => JSON.parse(a.json))
|
||||
return {
|
||||
hotAlbums: ids.map((id: number) => albums.find(a => a.id === id)),
|
||||
}
|
||||
}
|
||||
case CacheAPIs.Lyric: {
|
||||
const typedQuery = query as CacheAPIsParams[CacheAPIs.Lyric]
|
||||
const id = Number(typedQuery?.id)
|
||||
if (isNaN(id)) return
|
||||
const data = await prisma.lyrics.findUnique({ where: { 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.AppleMusicAlbum: {
|
||||
const typedQuery = query as CacheAPIsParams[CacheAPIs.AppleMusicAlbum]
|
||||
const id = Number(typedQuery?.id)
|
||||
if (isNaN(id)) return
|
||||
const data = await prisma.appleMusicAlbum.findUnique({ where: { id } })
|
||||
if (data?.json) return JSON.parse(data.json)
|
||||
break
|
||||
}
|
||||
case CacheAPIs.AppleMusicArtist: {
|
||||
const typedQuery = query as CacheAPIsParams[CacheAPIs.AppleMusicArtist]
|
||||
const id = Number(typedQuery?.id)
|
||||
if (isNaN(id)) return
|
||||
const data = await prisma.appleMusicArtist.findUnique({ where: { id } })
|
||||
if (data?.json) return JSON.parse(data.json)
|
||||
break
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
async 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) {
|
||||
prisma.audio.delete({ where: { id } })
|
||||
fs.unlinkSync(path)
|
||||
return reply.status(404).send({ error: 'Audio not found' })
|
||||
}
|
||||
await prisma.audio.update({ where: { id }, data: { updatedAt: new Date() } })
|
||||
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, bitrate }: { id: number; url: string; bitrate: number }
|
||||
) {
|
||||
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.bitrate || bitrate || 0) / 1000)
|
||||
const format =
|
||||
{
|
||||
'MPEG 1 Layer 3': 'mp3',
|
||||
'Ogg Vorbis': 'ogg',
|
||||
AAC: 'm4a',
|
||||
FLAC: 'flac',
|
||||
OPUS: 'opus',
|
||||
}[meta.format.codec ?? ''] ?? 'unknown'
|
||||
|
||||
let source = 'unknown'
|
||||
if (url.includes('googlevideo.com')) source = 'youtube'
|
||||
if (url.includes('126.net')) source = 'netease'
|
||||
|
||||
fs.writeFile(`${path}/${id}-${bitRate}.${format}`, buffer, async error => {
|
||||
if (error) {
|
||||
return log.error(`[cache] cacheAudio failed: ${error}`)
|
||||
}
|
||||
|
||||
const row = { id, bitRate, format, source }
|
||||
await prisma.audio.upsert({
|
||||
where: { id },
|
||||
create: row,
|
||||
update: row,
|
||||
})
|
||||
|
||||
log.info(`Audio file ${id}-${bitRate}.${format} cached!`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default new Cache()
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
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'
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
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'
|
||||
import { initDatabase } from './prisma'
|
||||
|
||||
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 initDatabase()
|
||||
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()
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
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 prisma from './prisma'
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
on(IpcChannels.MaximizeOrUnmaximize, () => {
|
||||
if (!win) return
|
||||
win.isMaximized() ? win.unmaximize() : win.maximize()
|
||||
})
|
||||
|
||||
on(IpcChannels.Close, () => {
|
||||
app.exit()
|
||||
})
|
||||
|
||||
on(IpcChannels.ResetWindowSize, () => {
|
||||
if (!win) return
|
||||
win?.setSize(1440, 1024, true)
|
||||
})
|
||||
|
||||
handle(IpcChannels.IsMaximized, () => {
|
||||
if (!win) return
|
||||
return win.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 prisma.accountData.deleteMany({})
|
||||
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!')
|
||||
// }
|
||||
// )
|
||||
// })
|
||||
// })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
/** 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`)
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuItemConstructorOptions,
|
||||
shell,
|
||||
} from 'electron'
|
||||
import { isMac } from './env'
|
||||
import { logsPath } from './utils'
|
||||
import { exec } from 'child_process'
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
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')}`)
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
import { app } from 'electron'
|
||||
import path from 'path'
|
||||
import { PrismaClient } from '../prisma/client'
|
||||
import { isDev, isWindows } from './env'
|
||||
import log from './log'
|
||||
import { createFileIfNotExist, dirname, isFileExist } from './utils'
|
||||
import fs from 'fs'
|
||||
import { dialog } from 'electron'
|
||||
|
||||
export const dbPath = path.join(app.getPath('userData'), 'r3play.db')
|
||||
export const dbUrl = 'file:' + (isWindows ? '' : '//') + dbPath
|
||||
log.info('[prisma] dbUrl', dbUrl)
|
||||
|
||||
const extraResourcesPath = app.getAppPath().replace('app.asar', '') // impacted by extraResources setting in electron-builder.yml
|
||||
function getPlatformName(): string {
|
||||
const isDarwin = process.platform === 'darwin'
|
||||
if (isDarwin && process.arch === 'arm64') {
|
||||
return process.platform + 'Arm64'
|
||||
}
|
||||
|
||||
return process.platform
|
||||
}
|
||||
const platformName = getPlatformName()
|
||||
export const platformToExecutables: any = {
|
||||
win32: {
|
||||
migrationEngine: 'node_modules/@prisma/engines/migration-engine-windows.exe',
|
||||
queryEngine: 'node_modules/@prisma/engines/query_engine-windows.dll.node',
|
||||
},
|
||||
linux: {
|
||||
migrationEngine: 'node_modules/@prisma/engines/migration-engine-debian-openssl-1.1.x',
|
||||
queryEngine: 'node_modules/@prisma/engines/libquery_engine-debian-openssl-1.1.x.so.node',
|
||||
},
|
||||
darwin: {
|
||||
migrationEngine: 'node_modules/@prisma/engines/migration-engine-darwin',
|
||||
queryEngine: 'node_modules/@prisma/engines/libquery_engine-darwin.dylib.node',
|
||||
},
|
||||
darwinArm64: {
|
||||
migrationEngine: 'node_modules/@prisma/engines/migration-engine-darwin-arm64',
|
||||
queryEngine: 'node_modules/@prisma/engines/libquery_engine-darwin-arm64.dylib.node',
|
||||
},
|
||||
}
|
||||
export const queryEnginePath = path.join(
|
||||
extraResourcesPath,
|
||||
platformToExecutables[platformName].queryEngine
|
||||
)
|
||||
|
||||
log.info('[prisma] dbUrl', dbUrl)
|
||||
|
||||
// Hacky, but putting this here because otherwise at query time the Prisma client
|
||||
// gives an error "Environment variable not found: DATABASE_URL" despite us passing
|
||||
// the dbUrl into the prisma client constructor in datasources.db.url
|
||||
process.env.DATABASE_URL = dbUrl
|
||||
|
||||
createFileIfNotExist(dbPath)
|
||||
|
||||
// @ts-expect-error
|
||||
let prisma: PrismaClient = null
|
||||
try {
|
||||
prisma = new PrismaClient({
|
||||
log: isDev ? ['info', 'warn', 'error'] : ['error'],
|
||||
datasources: {
|
||||
db: {
|
||||
url: dbUrl,
|
||||
},
|
||||
},
|
||||
// see https://github.com/prisma/prisma/discussions/5200
|
||||
// @ts-expect-error internal prop
|
||||
// __internal: {
|
||||
// engine: {
|
||||
// binaryPath: queryEnginePath,
|
||||
// },
|
||||
// },
|
||||
})
|
||||
log.info('[prisma] prisma initialized')
|
||||
} catch (e) {
|
||||
log.error('[prisma] failed to init prisma', e)
|
||||
dialog.showErrorBox('Failed to init prisma', String(e))
|
||||
app.exit()
|
||||
}
|
||||
|
||||
export const initDatabase = async () => {
|
||||
try {
|
||||
const initSQLFile = fs
|
||||
.readFileSync(path.join(dirname, 'migrations/init.sql'), 'utf-8')
|
||||
.toString()
|
||||
const tables = initSQLFile.split(';')
|
||||
await Promise.all(
|
||||
tables.map(sql => {
|
||||
if (!sql.trim()) return
|
||||
return prisma.$executeRawUnsafe(sql.trim()).catch(() => {
|
||||
log.error('[prisma] failed to execute init sql >>> ', sql.trim())
|
||||
})
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
dialog.showErrorBox('Failed to init prisma database', String(e))
|
||||
app.exit()
|
||||
}
|
||||
log.info('[prisma] database initialized')
|
||||
}
|
||||
|
||||
export default prisma
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
/* 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,
|
||||
})
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
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`)
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import Store from 'electron-store'
|
||||
|
||||
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
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
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'
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import pkg from '../../../package.json'
|
||||
import { appName, isDev } from './env'
|
||||
|
||||
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))
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { BrowserWindow, nativeImage, ThumbarButton } from 'electron'
|
||||
import path from 'path'
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
CREATE TABLE IF NOT EXISTS "AccountData" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"json" TEXT NOT NULL,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "AppData" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"value" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Track" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"json" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Album" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"json" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Artist" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"json" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "ArtistAlbum" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"hotAlbums" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Playlist" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"json" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Audio" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"bitRate" INTEGER NOT NULL,
|
||||
"format" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Lyrics" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"json" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "AppleMusicAlbum" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"json" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "AppleMusicArtist" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"json" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
{
|
||||
"name": "desktop",
|
||||
"productName": "R3PLAY",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"main": "./main/index.js",
|
||||
"author": "*",
|
||||
"scripts": {
|
||||
"postinstall": "prisma generate",
|
||||
"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",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:db-push": "prisma db push"
|
||||
},
|
||||
"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",
|
||||
"@prisma/client": "^4.8.1",
|
||||
"@prisma/engines": "^4.9.0",
|
||||
"@sentry/electron": "^3.0.7",
|
||||
"@yimura/scraper": "^1.2.4",
|
||||
"NeteaseCloudMusicApi": "^4.8.9",
|
||||
"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",
|
||||
"prisma": "^4.8.1",
|
||||
"ytdl-core": "^4.11.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/ui": "^0.20.3",
|
||||
"axios": "^1.2.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^16.0.3",
|
||||
"electron": "^22.1.0",
|
||||
"electron-builder": "23.6.0",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"electron-releases": "^3.1171.0",
|
||||
"esbuild": "^0.16.10",
|
||||
"minimist": "^1.2.7",
|
||||
"music-metadata": "^8.1.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "./client"
|
||||
binaryTargets = ["native", "darwin", "darwin-arm64"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model AccountData {
|
||||
id String @id @unique
|
||||
json String
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model AppData {
|
||||
id String @id @unique
|
||||
value String
|
||||
}
|
||||
|
||||
model Track {
|
||||
id Int @id @unique
|
||||
json String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Album {
|
||||
id Int @id @unique
|
||||
json String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Artist {
|
||||
id Int @id @unique
|
||||
json String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model ArtistAlbum {
|
||||
id Int @id @unique
|
||||
hotAlbums String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Playlist {
|
||||
id Int @id @unique
|
||||
json String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Audio {
|
||||
id Int @id @unique
|
||||
bitRate Int
|
||||
format String
|
||||
source String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Lyrics {
|
||||
id Int @id @unique
|
||||
json String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model AppleMusicAlbum {
|
||||
id Int @id @unique
|
||||
json String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model AppleMusicArtist {
|
||||
id Int @id @unique
|
||||
json String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
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',
|
||||
],
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"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"]
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
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
|
|
@ -1,65 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
# 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
|
|
@ -1,36 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { join } from 'path'
|
||||
import AutoLoad, { AutoloadPluginOptions } from '@fastify/autoload'
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
|
||||
const app: FastifyPluginAsync<AutoloadPluginOptions> = async (fastify, opts) => {
|
||||
fastify.register(AutoLoad, {
|
||||
dir: join(__dirname, 'plugins'),
|
||||
options: opts,
|
||||
})
|
||||
|
||||
fastify.register(AutoLoad, {
|
||||
dir: join(__dirname, 'routes'),
|
||||
options: opts,
|
||||
})
|
||||
}
|
||||
|
||||
export default app
|
||||
export { app }
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
# Plugins Folder
|
||||
|
||||
Plugins define behavior that is common to all the routes in your
|
||||
application. Authentication, caching, templates, and all the other cross
|
||||
cutting concerns should be handled by plugins placed in this folder.
|
||||
|
||||
Files in this folder are typically defined through the
|
||||
[`fastify-plugin`](https://github.com/fastify/fastify-plugin) module,
|
||||
making them non-encapsulated. They can define decorators and set hooks
|
||||
that will then be used in the rest of your application.
|
||||
|
||||
Check out:
|
||||
|
||||
- [The hitchhiker's guide to plugins](https://www.fastify.io/docs/latest/Guides/Plugins-Guide/)
|
||||
- [Fastify decorators](https://www.fastify.io/docs/latest/Reference/Decorators/).
|
||||
- [Fastify lifecycle](https://www.fastify.io/docs/latest/Reference/Lifecycle/).
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import fp from 'fastify-plugin'
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
// Use TypeScript module augmentation to declare the type of server.prisma to be PrismaClient
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
prisma: PrismaClient
|
||||
}
|
||||
}
|
||||
|
||||
const prismaPlugin: FastifyPluginAsync = fp(async (server, options) => {
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
await prisma.$connect()
|
||||
|
||||
// Make Prisma Client available through the fastify server instance: server.prisma
|
||||
server.decorate('prisma', prisma)
|
||||
|
||||
server.addHook('onClose', async server => {
|
||||
await server.prisma.$disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
export default prismaPlugin
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import fp from 'fastify-plugin'
|
||||
import sensible, { SensibleOptions } from '@fastify/sensible'
|
||||
|
||||
/**
|
||||
* This plugins adds some utilities to handle http errors
|
||||
*
|
||||
* @see https://github.com/fastify/fastify-sensible
|
||||
*/
|
||||
export default fp<SensibleOptions>(async (fastify, opts) => {
|
||||
fastify.register(sensible, {
|
||||
errorHandler: false,
|
||||
})
|
||||
})
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import fp from 'fastify-plugin'
|
||||
|
||||
export interface SupportPluginOptions {
|
||||
// Specify Support plugin options here
|
||||
}
|
||||
|
||||
// The use of fastify-plugin is required to be able
|
||||
// to export the decorators to the outer scope
|
||||
export default fp<SupportPluginOptions>(async (fastify, opts) => {
|
||||
fastify.decorate('someSupport', function () {
|
||||
return 'hugs'
|
||||
})
|
||||
})
|
||||
|
||||
// When using .decorate you have to specify added properties for Typescript
|
||||
declare module 'fastify' {
|
||||
export interface FastifyInstance {
|
||||
someSupport(): string
|
||||
}
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
import { FastifyPluginAsync } from 'fastify'
|
||||
import appleMusicRequest from '../../utils/appleMusicRequest'
|
||||
import { album as getAlbum } from 'NeteaseCloudMusicApi'
|
||||
|
||||
type ResponseSchema = {
|
||||
id: number
|
||||
neteaseId: number
|
||||
name: string
|
||||
artistName: string
|
||||
editorialVideo: string
|
||||
artwork: string
|
||||
copyright: string
|
||||
neteaseName: string
|
||||
neteaseArtistName: string
|
||||
editorialNote: {
|
||||
en_US: string
|
||||
zh_CN: string
|
||||
}
|
||||
}
|
||||
|
||||
const album: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
|
||||
fastify.get<{
|
||||
Querystring: {
|
||||
neteaseId: string
|
||||
lang?: 'zh-CN' | 'en-US'
|
||||
noCache?: boolean
|
||||
}
|
||||
}>('/album', opts, async function (request, reply): Promise<ResponseSchema | undefined> {
|
||||
const { neteaseId: neteaseIdString, lang = 'en-US', noCache = false } = request.query
|
||||
|
||||
// validate neteaseAlbumID
|
||||
const neteaseId = Number(neteaseIdString)
|
||||
if (isNaN(neteaseId)) {
|
||||
reply.code(400).send('params "neteaseId" is required')
|
||||
return
|
||||
}
|
||||
|
||||
// get from database
|
||||
if (!noCache) {
|
||||
const fromDB = await fastify.prisma.album.findFirst({
|
||||
where: { neteaseId: neteaseId },
|
||||
include: { editorialNote: { select: { en_US: true, zh_CN: true } } },
|
||||
})
|
||||
if (fromDB) {
|
||||
return fromDB as ResponseSchema
|
||||
}
|
||||
}
|
||||
|
||||
// get from netease
|
||||
const { body: neteaseAlbum } = (await getAlbum({ id: neteaseId })) as any
|
||||
const artist = neteaseAlbum?.album?.artist?.name
|
||||
const albumName = neteaseAlbum?.album?.name
|
||||
if (!artist || !albumName) {
|
||||
return
|
||||
}
|
||||
|
||||
// get from apple
|
||||
const fromApple = await appleMusicRequest({
|
||||
method: 'GET',
|
||||
url: '/search',
|
||||
params: {
|
||||
term: `${artist} ${albumName}`,
|
||||
types: 'albums',
|
||||
'fields[albums]': 'artistName,artwork,name,copyright,editorialVideo,editorialNotes',
|
||||
limit: '10',
|
||||
l: lang.toLowerCase(),
|
||||
},
|
||||
})
|
||||
|
||||
const albums = fromApple?.results?.album?.data
|
||||
const album =
|
||||
albums?.find(
|
||||
(a: any) =>
|
||||
a.attributes.name.toLowerCase() === albumName.toLowerCase() &&
|
||||
a.attributes.artistName.toLowerCase() === artist.toLowerCase()
|
||||
) || albums?.[0]
|
||||
if (!album) return
|
||||
|
||||
// get editorialNote
|
||||
const editorialNote = {
|
||||
en_US: lang === 'en-US' ? album.attributes.editorialNotes?.standard : '',
|
||||
zh_CN: lang === 'zh-CN' ? album.attributes.editorialNotes?.standard : '',
|
||||
}
|
||||
const otherLangEditorialNoteResult = await appleMusicRequest({
|
||||
method: 'GET',
|
||||
url: `/albums/${album.id}`,
|
||||
params: {
|
||||
'omit[resource:albums]': 'relationships',
|
||||
l: lang === 'zh-CN' ? 'en-US' : 'zh-CN',
|
||||
},
|
||||
})
|
||||
const otherLangEditorialNote =
|
||||
otherLangEditorialNoteResult?.data?.[0]?.attributes?.editorialNotes?.standard
|
||||
if (lang === 'zh-CN') {
|
||||
editorialNote.en_US = otherLangEditorialNote
|
||||
} else if (lang === 'en-US') {
|
||||
editorialNote.zh_CN = otherLangEditorialNote
|
||||
}
|
||||
|
||||
const data: ResponseSchema = {
|
||||
id: Number(album.id),
|
||||
neteaseId: Number(neteaseId),
|
||||
name: album.attributes.name,
|
||||
artistName: album.attributes.artistName,
|
||||
editorialVideo: album.attributes.editorialVideo?.motionDetailSquare?.video,
|
||||
artwork: album.attributes.artwork?.url,
|
||||
editorialNote,
|
||||
copyright: album.attributes.copyright,
|
||||
neteaseName: albumName,
|
||||
neteaseArtistName: artist,
|
||||
}
|
||||
|
||||
// save to database
|
||||
if (!noCache) {
|
||||
await fastify.prisma.album.create({
|
||||
data: {
|
||||
...data,
|
||||
editorialNote: {
|
||||
connectOrCreate: {
|
||||
where: { id: data.id },
|
||||
create: editorialNote,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
})
|
||||
}
|
||||
|
||||
export default album
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import { FastifyPluginAsync } from 'fastify'
|
||||
import appleMusicRequest from '../../utils/appleMusicRequest'
|
||||
import { artist_detail as getArtistDetail } from 'NeteaseCloudMusicApi'
|
||||
|
||||
type ResponseSchema = {
|
||||
id: number
|
||||
neteaseId: number
|
||||
editorialVideo: string
|
||||
artwork: string
|
||||
name: string
|
||||
artistBio: {
|
||||
en_US: string
|
||||
zh_CN: string
|
||||
}
|
||||
}
|
||||
|
||||
const artist: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
|
||||
fastify.get<{
|
||||
Querystring: {
|
||||
neteaseId: string
|
||||
lang?: 'zh-CN' | 'en-US'
|
||||
noCache?: boolean
|
||||
}
|
||||
}>('/artist', async function (request, reply): Promise<ResponseSchema | undefined> {
|
||||
const { neteaseId: neteaseIdString, lang = 'en-US', noCache = false } = request.query
|
||||
|
||||
// validate neteaseId
|
||||
const neteaseId = Number(neteaseIdString)
|
||||
if (isNaN(neteaseId)) {
|
||||
reply.code(400).send('params "neteaseId" is required')
|
||||
return
|
||||
}
|
||||
|
||||
// get from database
|
||||
if (!noCache) {
|
||||
const fromDB = await fastify.prisma.artist.findFirst({
|
||||
where: { neteaseId: neteaseId },
|
||||
include: { artistBio: { select: { en_US: true, zh_CN: true } } },
|
||||
})
|
||||
if (fromDB) {
|
||||
return fromDB as ResponseSchema
|
||||
}
|
||||
}
|
||||
|
||||
// get from netease
|
||||
const { body: neteaseArtist } = (await getArtistDetail({ id: neteaseId })) as any
|
||||
const artistName = neteaseArtist?.data?.artist?.name
|
||||
if (!artistName) {
|
||||
return
|
||||
}
|
||||
|
||||
const fromApple = await appleMusicRequest({
|
||||
method: 'GET',
|
||||
url: '/search',
|
||||
params: {
|
||||
term: artistName,
|
||||
types: 'artists',
|
||||
'fields[artists]': 'url,name,artwork,editorialVideo,artistBio',
|
||||
'omit[resource:artists]': 'relationships',
|
||||
platform: 'web',
|
||||
limit: '5',
|
||||
l: lang?.toLowerCase(),
|
||||
},
|
||||
})
|
||||
|
||||
const artist = fromApple?.results?.artist?.data?.[0]
|
||||
if (artist?.attributes?.name?.toLowerCase() !== artistName.toLowerCase()) {
|
||||
return
|
||||
}
|
||||
|
||||
// get ArtistBio
|
||||
const artistBio = {
|
||||
en_US: lang === 'en-US' ? artist.attributes.artistBio : '',
|
||||
zh_CN: lang === 'zh-CN' ? artist.attributes.artistBio : '',
|
||||
}
|
||||
const otherLangArtistBioResult = await appleMusicRequest({
|
||||
method: 'GET',
|
||||
url: `/artists/${artist.id}`,
|
||||
params: {
|
||||
'fields[artists]': 'artistBio',
|
||||
'omit[resource:artists]': 'relationships',
|
||||
l: lang === 'zh-CN' ? 'en-US' : 'zh-CN',
|
||||
},
|
||||
})
|
||||
const otherLangArtistBio = otherLangArtistBioResult?.data?.[0]?.attributes?.artistBio
|
||||
if (lang === 'zh-CN') {
|
||||
artistBio.en_US = otherLangArtistBio
|
||||
} else if (lang === 'en-US') {
|
||||
artistBio.zh_CN = otherLangArtistBio
|
||||
}
|
||||
|
||||
const data: ResponseSchema = {
|
||||
id: Number(artist.id),
|
||||
neteaseId: neteaseId,
|
||||
name: artist.attributes.name,
|
||||
artistBio,
|
||||
editorialVideo: artist?.attributes.editorialVideo?.motionArtistSquare1x1?.video,
|
||||
artwork: artist?.attributes?.artwork?.url,
|
||||
}
|
||||
|
||||
// save to database
|
||||
if (!noCache) {
|
||||
await fastify.prisma.artist.create({
|
||||
data: {
|
||||
...data,
|
||||
artistBio: {
|
||||
connectOrCreate: {
|
||||
where: { id: data.id },
|
||||
create: artistBio,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
})
|
||||
}
|
||||
|
||||
export default artist
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { FastifyPluginAsync } from 'fastify'
|
||||
import appleMusicRequest from '../../utils/appleMusicRequest'
|
||||
|
||||
type ResponseSchema = {
|
||||
status: 'OK' | 'Expired'
|
||||
}
|
||||
|
||||
const album: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
|
||||
fastify.get('/check-token', opts, async function (request, reply): Promise<
|
||||
ResponseSchema | undefined
|
||||
> {
|
||||
const result = await appleMusicRequest({
|
||||
method: 'GET',
|
||||
url: '/search',
|
||||
params: {
|
||||
term: `Taylor Swift evermore`,
|
||||
types: 'albums',
|
||||
'fields[albums]': 'artistName,artwork,name,copyright,editorialVideo,editorialNotes',
|
||||
limit: '1',
|
||||
l: 'en-us',
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
status: result?.results?.album ? 'OK' : 'Expired',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default album
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { FastifyPluginAsync } from 'fastify'
|
||||
|
||||
const root: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
|
||||
fastify.get('/', async (request, reply) => {
|
||||
return 'R3PLAY server is running!'
|
||||
})
|
||||
}
|
||||
|
||||
export default root
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
|
||||
export const baseURL = 'https://amp-api.music.apple.com/v1/catalog/us'
|
||||
|
||||
export const headers = {
|
||||
Authority: 'amp-api.music.apple.com',
|
||||
Accept: '*/*',
|
||||
Authorization: process.env.APPLE_MUSIC_TOKEN || '',
|
||||
Referer: 'https://music.apple.com/',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'cross-site',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Cider/1.5.1 Chrome/100.0.4896.160 Electron/18.3.3 Safari/537.36',
|
||||
'Accept-Encoding': 'gzip',
|
||||
Origin: 'https://music.apple.com',
|
||||
}
|
||||
|
||||
export const params = {
|
||||
platform: 'web',
|
||||
with: 'serverBubbles',
|
||||
}
|
||||
|
||||
const service: AxiosInstance = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true,
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
service.interceptors.request.use((config: AxiosRequestConfig) => {
|
||||
config.headers = { ...headers, ...config.headers }
|
||||
config.params = { ...params, ...config.params }
|
||||
return config
|
||||
})
|
||||
|
||||
service.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
const res = response
|
||||
return res
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
const appleMusicRequest = async (config: AxiosRequestConfig) => {
|
||||
const { data } = await service.request(config)
|
||||
return data as any
|
||||
}
|
||||
|
||||
export default appleMusicRequest
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"extends": "fastify-tsconfig",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
export interface AppleMusicAlbum {
|
||||
attributes: {
|
||||
name: string
|
||||
artistName: string
|
||||
editorialVideo: {
|
||||
motionSquareVideo1x1: {
|
||||
video: string
|
||||
}
|
||||
}
|
||||
editorialNotes: {
|
||||
short: string
|
||||
standard: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppleMusicArtist {
|
||||
attributes: {
|
||||
name: string
|
||||
artistBio: string
|
||||
editorialVideo: {
|
||||
motionArtistSquare1x1: {
|
||||
video: string
|
||||
}
|
||||
}
|
||||
artwork: {
|
||||
url: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import {
|
||||
FetchArtistAlbumsResponse,
|
||||
FetchArtistResponse,
|
||||
FetchSimilarArtistsResponse,
|
||||
} from './api/Artist'
|
||||
import { FetchAlbumResponse } from './api/Album'
|
||||
import {
|
||||
FetchListenedRecordsResponse,
|
||||
FetchUserAccountResponse,
|
||||
FetchUserAlbumsResponse,
|
||||
FetchUserArtistsResponse,
|
||||
FetchUserLikedTracksIDsResponse,
|
||||
FetchUserPlaylistsResponse,
|
||||
} from './api/User'
|
||||
import { FetchAudioSourceResponse, FetchLyricResponse, FetchTracksResponse } from './api/Track'
|
||||
import { FetchPlaylistResponse, FetchRecommendedPlaylistsResponse } from './api/Playlists'
|
||||
import { AppleMusicAlbum, AppleMusicArtist } from './AppleMusic'
|
||||
|
||||
export enum CacheAPIs {
|
||||
Album = 'album',
|
||||
Artist = 'artists',
|
||||
ArtistAlbum = 'artist/album',
|
||||
Likelist = 'likelist',
|
||||
Lyric = 'lyric',
|
||||
Personalized = 'personalized',
|
||||
Playlist = 'playlist/detail',
|
||||
RecommendResource = 'recommend/resource',
|
||||
SongUrl = 'song/url/v1',
|
||||
Track = 'song/detail',
|
||||
UserAccount = 'user/account',
|
||||
UserAlbums = 'album/sublist',
|
||||
UserArtists = 'artist/sublist',
|
||||
UserPlaylist = 'user/playlist',
|
||||
SimilarArtist = 'simi/artist',
|
||||
ListenedRecords = 'user/record',
|
||||
|
||||
// not netease api
|
||||
CoverColor = 'cover_color',
|
||||
AppleMusicAlbum = 'apple_music_album',
|
||||
AppleMusicArtist = 'apple_music_artist',
|
||||
}
|
||||
|
||||
export interface CacheAPIsParams {
|
||||
[CacheAPIs.Album]: { id: number }
|
||||
[CacheAPIs.Artist]: { id: number }
|
||||
[CacheAPIs.ArtistAlbum]: { id: number }
|
||||
[CacheAPIs.Likelist]: void
|
||||
[CacheAPIs.Lyric]: { id: number }
|
||||
[CacheAPIs.Personalized]: void
|
||||
[CacheAPIs.Playlist]: { id: number }
|
||||
[CacheAPIs.RecommendResource]: void
|
||||
[CacheAPIs.SongUrl]: { id: string }
|
||||
[CacheAPIs.Track]: { ids: string }
|
||||
[CacheAPIs.UserAccount]: void
|
||||
[CacheAPIs.UserAlbums]: void
|
||||
[CacheAPIs.UserArtists]: void
|
||||
[CacheAPIs.UserPlaylist]: void
|
||||
[CacheAPIs.SimilarArtist]: { id: number }
|
||||
[CacheAPIs.ListenedRecords]: { id: number; type: number }
|
||||
|
||||
[CacheAPIs.CoverColor]: { id: number }
|
||||
[CacheAPIs.AppleMusicAlbum]: { id: number }
|
||||
[CacheAPIs.AppleMusicArtist]: { id: number }
|
||||
}
|
||||
|
||||
export interface CacheAPIsResponse {
|
||||
[CacheAPIs.Album]: FetchAlbumResponse
|
||||
[CacheAPIs.Artist]: FetchArtistResponse
|
||||
[CacheAPIs.ArtistAlbum]: FetchArtistAlbumsResponse
|
||||
[CacheAPIs.Likelist]: FetchUserLikedTracksIDsResponse
|
||||
[CacheAPIs.Lyric]: FetchLyricResponse
|
||||
[CacheAPIs.Personalized]: FetchRecommendedPlaylistsResponse
|
||||
[CacheAPIs.Playlist]: FetchPlaylistResponse
|
||||
[CacheAPIs.RecommendResource]: FetchRecommendedPlaylistsResponse
|
||||
[CacheAPIs.SongUrl]: FetchAudioSourceResponse
|
||||
[CacheAPIs.Track]: FetchTracksResponse
|
||||
[CacheAPIs.UserAccount]: FetchUserAccountResponse
|
||||
[CacheAPIs.UserAlbums]: FetchUserAlbumsResponse
|
||||
[CacheAPIs.UserArtists]: FetchUserArtistsResponse
|
||||
[CacheAPIs.UserPlaylist]: FetchUserPlaylistsResponse
|
||||
[CacheAPIs.SimilarArtist]: FetchSimilarArtistsResponse
|
||||
[CacheAPIs.ListenedRecords]: FetchListenedRecordsResponse
|
||||
|
||||
[CacheAPIs.CoverColor]: string | undefined
|
||||
[CacheAPIs.AppleMusicAlbum]: AppleMusicAlbum | 'no'
|
||||
[CacheAPIs.AppleMusicArtist]: AppleMusicArtist | 'no'
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import { AppleMusicAlbum, AppleMusicArtist } from './AppleMusic'
|
||||
import { CacheAPIs } from './CacheAPIs'
|
||||
import { RepeatMode } from './playerDataTypes'
|
||||
|
||||
export const enum IpcChannels {
|
||||
ClearAPICache = 'ClearAPICache',
|
||||
Minimize = 'Minimize',
|
||||
MaximizeOrUnmaximize = 'MaximizeOrUnmaximize',
|
||||
Close = 'Close',
|
||||
IsMaximized = 'IsMaximized',
|
||||
FullscreenStateChange = 'FullscreenStateChange',
|
||||
GetApiCache = 'GetApiCache',
|
||||
DevDbExportJson = 'DevDbExportJson',
|
||||
CacheCoverColor = 'CacheCoverColor',
|
||||
SetTrayTooltip = 'SetTrayTooltip',
|
||||
// 准备三个播放相关channel, 为 mpris 预留接口
|
||||
Play = 'Play',
|
||||
Pause = 'Pause',
|
||||
PlayOrPause = 'PlayOrPause',
|
||||
Next = 'Next',
|
||||
Previous = 'Previous',
|
||||
Like = 'Like',
|
||||
Repeat = 'Repeat',
|
||||
SyncSettings = 'SyncSettings',
|
||||
GetAudioCacheSize = 'GetAudioCacheSize',
|
||||
ResetWindowSize = 'ResetWindowSize',
|
||||
GetAlbumFromAppleMusic = 'GetAlbumFromAppleMusic',
|
||||
GetArtistFromAppleMusic = 'GetArtistFromAppleMusic',
|
||||
Logout = 'Logout',
|
||||
}
|
||||
|
||||
// ipcMain.on params
|
||||
export interface IpcChannelsParams {
|
||||
[IpcChannels.ClearAPICache]: void
|
||||
[IpcChannels.Minimize]: void
|
||||
[IpcChannels.MaximizeOrUnmaximize]: void
|
||||
[IpcChannels.Close]: void
|
||||
[IpcChannels.IsMaximized]: void
|
||||
[IpcChannels.FullscreenStateChange]: void
|
||||
[IpcChannels.GetApiCache]: {
|
||||
api: CacheAPIs
|
||||
query?: any
|
||||
}
|
||||
[IpcChannels.DevDbExportJson]: void
|
||||
[IpcChannels.CacheCoverColor]: {
|
||||
id: number
|
||||
color: string
|
||||
}
|
||||
[IpcChannels.SetTrayTooltip]: {
|
||||
text: string
|
||||
}
|
||||
[IpcChannels.Play]: void
|
||||
[IpcChannels.Pause]: void
|
||||
[IpcChannels.PlayOrPause]: void
|
||||
[IpcChannels.Next]: void
|
||||
[IpcChannels.Previous]: void
|
||||
[IpcChannels.Like]: {
|
||||
isLiked: boolean
|
||||
}
|
||||
[IpcChannels.Repeat]: {
|
||||
mode: RepeatMode
|
||||
}
|
||||
[IpcChannels.SyncSettings]: any
|
||||
[IpcChannels.GetAudioCacheSize]: void
|
||||
[IpcChannels.ResetWindowSize]: void
|
||||
[IpcChannels.GetAlbumFromAppleMusic]: {
|
||||
id: number
|
||||
name: string
|
||||
artist: string
|
||||
}
|
||||
[IpcChannels.GetArtistFromAppleMusic]: { id: number; name: string }
|
||||
[IpcChannels.Logout]: void
|
||||
}
|
||||
|
||||
// ipcRenderer.on params
|
||||
export interface IpcChannelsReturns {
|
||||
[IpcChannels.ClearAPICache]: void
|
||||
[IpcChannels.Minimize]: void
|
||||
[IpcChannels.MaximizeOrUnmaximize]: void
|
||||
[IpcChannels.Close]: void
|
||||
[IpcChannels.IsMaximized]: boolean
|
||||
[IpcChannels.FullscreenStateChange]: boolean
|
||||
[IpcChannels.GetApiCache]: any
|
||||
[IpcChannels.DevDbExportJson]: void
|
||||
[IpcChannels.CacheCoverColor]: void
|
||||
[IpcChannels.SetTrayTooltip]: void
|
||||
[IpcChannels.Play]: void
|
||||
[IpcChannels.Pause]: void
|
||||
[IpcChannels.PlayOrPause]: void
|
||||
[IpcChannels.Next]: void
|
||||
[IpcChannels.Previous]: void
|
||||
[IpcChannels.Like]: void
|
||||
[IpcChannels.Repeat]: RepeatMode
|
||||
[IpcChannels.GetAudioCacheSize]: void
|
||||
[IpcChannels.GetAlbumFromAppleMusic]: AppleMusicAlbum | undefined
|
||||
[IpcChannels.GetArtistFromAppleMusic]: AppleMusicArtist | undefined
|
||||
[IpcChannels.Logout]: void
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
export enum AlbumApiNames {
|
||||
FetchAlbum = 'fetchAlbum',
|
||||
}
|
||||
|
||||
// 专辑详情
|
||||
export interface FetchAlbumParams {
|
||||
id: number
|
||||
}
|
||||
export interface FetchAlbumResponse {
|
||||
code: number
|
||||
resourceState: boolean
|
||||
album: Album
|
||||
songs: Track[]
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface LikeAAlbumParams {
|
||||
t: 1 | 2
|
||||
id: number
|
||||
}
|
||||
export interface LikeAAlbumResponse {
|
||||
code: number
|
||||
}
|
||||