Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6692751c62 | ||
|
|
65e876a2e9 | ||
|
|
496c88a629 | ||
|
|
2086bd1663 | ||
|
|
d6f8d0e63c | ||
|
|
cc4dd8284f | ||
|
|
6f688cbbb3 | ||
|
|
e0a1e0af39 | ||
|
|
c1d3a61f9f | ||
|
|
d6d806c96e | ||
|
|
089406464b | ||
|
|
c28d5d6ad0 | ||
|
|
471147ac82 | ||
|
|
7558a67df3 | ||
|
|
4a3f0ee124 | ||
|
|
5fe6d93d5e | ||
|
|
30fd2ebb9f | ||
|
|
0f78f117d0 | ||
|
|
c79b6951d6 | ||
|
|
5118874712 | ||
|
|
7e5baba969 | ||
|
|
a7af89e35d | ||
|
|
d511efdfce | ||
|
|
9b6050be7a | ||
|
|
57736e60f3 | ||
|
|
6165a2619e | ||
|
|
c933b6e0b4 | ||
|
|
e0e01cbdca | ||
|
|
9b34ecbed9 | ||
|
|
1dda213013 | ||
|
|
94c2dc740f | ||
|
|
61f062455e | ||
|
|
79827f14f7 | ||
|
|
394bdd573c | ||
|
|
d03d62c8d4 | ||
|
|
be0b0b0390 | ||
|
|
c1d2f3dc8d | ||
|
|
e590c33c66 | ||
|
|
604ac7b553 | ||
|
|
18e233ae10 | ||
|
|
61699c4853 | ||
|
|
6e69920a5d | ||
|
|
a767b008a0 | ||
|
|
41b104e96d | ||
|
|
8562b7c954 | ||
|
|
b61e88b7d9 | ||
|
|
cc1dbcaf3f | ||
|
|
941af10830 | ||
|
|
576f9697d4 | ||
|
|
53d9197196 | ||
|
|
7a349272b2 | ||
|
|
29dfa45791 | ||
|
|
c2fcb25686 | ||
|
|
3bb23e4765 | ||
|
|
5af7df60e4 | ||
|
|
4280cab090 | ||
|
|
7e1111cd33 | ||
|
|
194b88519f | ||
|
|
7d20b23fb0 | ||
|
|
909547ad2e | ||
|
|
9779e38140 | ||
|
|
0021f32a19 | ||
|
|
4784f63ca0 | ||
|
|
fb007eaa9c | ||
|
|
790530b71f | ||
|
|
49bd1c66c0 | ||
|
|
1f6dd81e78 | ||
|
|
faaf904e9f | ||
|
|
faac6273bb | ||
|
|
07a9c515a2 | ||
|
|
179ff34063 | ||
|
|
3aff332758 | ||
|
|
d2b1d69929 | ||
|
|
67430a25a3 | ||
|
|
7f971f0256 | ||
|
|
a7d4d877a9 |
13
.eslintrc.backup.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": [
|
||||
"@electron-toolkit/eslint-config-ts",
|
||||
"@electron-toolkit/eslint-config-prettier"
|
||||
],
|
||||
"rules": {
|
||||
"vue/require-default-prop": "off",
|
||||
"vue/multi-word-component-names": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"no-console": "warn"
|
||||
}
|
||||
}
|
||||
158
.github/workflows/auto-sync-release.yml
vendored
Normal file
@@ -0,0 +1,158 @@
|
||||
name: Auto Sync New Release to WebDAV
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
auto-sync-to-webdav:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl jq
|
||||
|
||||
- name: Get release info
|
||||
id: release-info
|
||||
run: |
|
||||
echo "tag_name=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
|
||||
echo "release_id=${{ github.event.release.id }}" >> $GITHUB_OUTPUT
|
||||
echo "release_name=${{ github.event.release.name }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Sync new release to WebDAV
|
||||
run: |
|
||||
TAG_NAME="${{ steps.release-info.outputs.tag_name }}"
|
||||
RELEASE_ID="${{ steps.release-info.outputs.release_id }}"
|
||||
RELEASE_NAME="${{ steps.release-info.outputs.release_name }}"
|
||||
|
||||
echo "🚀 开始同步新发布的版本到 WebDAV..."
|
||||
echo "版本标签: $TAG_NAME"
|
||||
echo "版本名称: $RELEASE_NAME"
|
||||
echo "Release ID: $RELEASE_ID"
|
||||
echo "WebDAV 根路径: ${{ secrets.WEBDAV_BASE_URL }}/yd/ceru"
|
||||
|
||||
# 获取该release的所有资源文件
|
||||
assets_json=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/releases/$RELEASE_ID/assets")
|
||||
|
||||
assets_count=$(echo "$assets_json" | jq '. | length')
|
||||
echo "找到 $assets_count 个资源文件"
|
||||
|
||||
if [ "$assets_count" -eq 0 ]; then
|
||||
echo "⚠️ 该版本没有资源文件,跳过同步"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 先创建版本目录
|
||||
dir_path="/yd/ceru/$TAG_NAME"
|
||||
dir_url="${{ secrets.WEBDAV_BASE_URL }}$dir_path"
|
||||
|
||||
echo "创建版本目录: $dir_path"
|
||||
if curl -s -f -X MKCOL \
|
||||
-u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
|
||||
"$dir_url"; then
|
||||
echo "✅ 目录创建成功"
|
||||
else
|
||||
echo "ℹ️ 目录可能已存在或创建失败,继续执行"
|
||||
fi
|
||||
|
||||
# 处理每个asset
|
||||
success_count=0
|
||||
failed_count=0
|
||||
|
||||
for i in $(seq 0 $(($assets_count - 1))); do
|
||||
asset=$(echo "$assets_json" | jq -c ".[$i]")
|
||||
asset_name=$(echo "$asset" | jq -r '.name')
|
||||
asset_url=$(echo "$asset" | jq -r '.url')
|
||||
asset_size=$(echo "$asset" | jq -r '.size')
|
||||
|
||||
echo "📦 处理资源: $asset_name (大小: $asset_size bytes)"
|
||||
|
||||
# 下载资源文件
|
||||
safe_filename="./temp_${TAG_NAME}_$(date +%s)_$i"
|
||||
|
||||
if ! curl -sL -o "$safe_filename" \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/octet-stream" \
|
||||
"$asset_url"; then
|
||||
echo "❌ 下载失败: $asset_name"
|
||||
failed_count=$((failed_count + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -f "$safe_filename" ]; then
|
||||
actual_size=$(wc -c < "$safe_filename")
|
||||
if [ "$actual_size" -ne "$asset_size" ]; then
|
||||
echo "❌ 文件大小不匹配: $asset_name (期望: $asset_size, 实际: $actual_size)"
|
||||
rm -f "$safe_filename"
|
||||
failed_count=$((failed_count + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "⬆️ 上传到 WebDAV: $asset_name"
|
||||
|
||||
# 构建远程路径
|
||||
remote_path="/yd/ceru/$TAG_NAME/$asset_name"
|
||||
full_url="${{ secrets.WEBDAV_BASE_URL }}$remote_path"
|
||||
|
||||
# 使用 WebDAV PUT 方法上传文件
|
||||
if curl -s -f -X PUT \
|
||||
-u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
|
||||
-T "$safe_filename" \
|
||||
"$full_url"; then
|
||||
|
||||
echo "✅ 上传成功: $asset_name"
|
||||
success_count=$((success_count + 1))
|
||||
|
||||
# 验证文件是否存在
|
||||
sleep 1
|
||||
if curl -s -f -u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
|
||||
-X PROPFIND \
|
||||
-H "Depth: 0" \
|
||||
"$full_url" > /dev/null 2>&1; then
|
||||
echo "✅ 文件验证成功: $asset_name"
|
||||
else
|
||||
echo "⚠️ 文件验证失败,但上传可能成功: $asset_name"
|
||||
fi
|
||||
|
||||
else
|
||||
echo "❌ 上传失败: $asset_name"
|
||||
failed_count=$((failed_count + 1))
|
||||
fi
|
||||
|
||||
# 清理临时文件
|
||||
rm -f "$safe_filename"
|
||||
echo "----------------------------------------"
|
||||
else
|
||||
echo "❌ 临时文件不存在: $safe_filename"
|
||||
failed_count=$((failed_count + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "========================================"
|
||||
echo "🎉 同步完成!"
|
||||
echo "成功: $success_count 个文件"
|
||||
echo "失败: $failed_count 个文件"
|
||||
echo "总计: $assets_count 个文件"
|
||||
|
||||
if [ "$failed_count" -gt 0 ]; then
|
||||
echo "⚠️ 有文件同步失败,请检查日志"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ 所有文件同步成功!"
|
||||
fi
|
||||
|
||||
- name: Notify completion
|
||||
if: always()
|
||||
run: |
|
||||
if [ "${{ job.status }}" == "success" ]; then
|
||||
echo "✅ 版本 ${{ steps.release-info.outputs.tag_name }} 已成功同步到 alist"
|
||||
else
|
||||
echo "❌ 版本 ${{ steps.release-info.outputs.tag_name }} 同步失败"
|
||||
fi
|
||||
66
.github/workflows/deploydocs.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
# 构建 VitePress 站点并将其部署到 GitHub Pages 的示例工作流程
|
||||
#
|
||||
name: Deploy VitePress site to Pages
|
||||
|
||||
on:
|
||||
# 在针对 `main` 分支的推送上运行。如果你
|
||||
# 使用 `master` 分支作为默认分支,请将其更改为 `master`
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# 允许你从 Actions 选项卡手动运行此工作流程
|
||||
workflow_dispatch:
|
||||
|
||||
# 设置 GITHUB_TOKEN 的权限,以允许部署到 GitHub Pages
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# 只允许同时进行一次部署,跳过正在运行和最新队列之间的运行队列
|
||||
# 但是,不要取消正在进行的运行,因为我们希望允许这些生产部署完成
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# 构建工作
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # 如果未启用 lastUpdated,则不需要
|
||||
- uses: pnpm/action-setup@v3 # 如果使用 pnpm,请取消此区域注释
|
||||
with:
|
||||
version: 9
|
||||
# - uses: oven-sh/setup-bun@v1 # 如果使用 Bun,请取消注释
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm # 或 pnpm / yarn
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
- name: Install dependencies
|
||||
run: pnpm add -D vitepress@next # 或 pnpm install / yarn install / bun install
|
||||
- name: Build with VitePress
|
||||
run: pnpm run docs:build # 或 pnpm docs:build / yarn docs:build / bun run docs:build
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/.vitepress/dist
|
||||
|
||||
# 部署工作
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
183
.github/workflows/main.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false # 如果一个任务失败,其他任务继续运行
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest, ubuntu-latest] # 在Windows、macOS和Ubuntu上运行任务
|
||||
os: [windows-latest, macos-latest, ubuntu-latest] # 在Windows和macOS上运行任务
|
||||
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
@@ -25,58 +25,191 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22 # 安装Node.js 22
|
||||
node-version: 22 # 安装Node.js 22 (这里node环境是能够运行代码的环境)
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
npm i -g yarn
|
||||
yarn install # 安装项目依赖
|
||||
|
||||
- name: Build Electron App for Windows
|
||||
- name: Build Electron App for windows
|
||||
if: matrix.os == 'windows-latest' # 只在Windows上运行
|
||||
run: yarn run build:win # 构建Windows版应用
|
||||
|
||||
- name: Build Electron App for macOS
|
||||
- name: Build Electron App for macos
|
||||
if: matrix.os == 'macos-latest' # 只在macOS上运行
|
||||
run: yarn run build:mac
|
||||
run: |
|
||||
yarn run build:mac
|
||||
|
||||
- name: Build Electron App for Linux
|
||||
- name: Build Electron App for linux
|
||||
if: matrix.os == 'ubuntu-latest' # 只在Linux上运行
|
||||
run: yarn run build:linux
|
||||
run: yarn run build:linux # 构建Linux版应用
|
||||
|
||||
- name: Cleanup Artifacts for Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: |
|
||||
npx del-cli "dist/*" "!dist/*.exe" "!dist/*.zip" "!dist/*.yml" # 清理Windows构建产物,只保留特定文件
|
||||
|
||||
- name: Cleanup Artifacts for macOS
|
||||
- name: Cleanup Artifacts for MacOS
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
npx del-cli "dist/*" "!dist/(*.dmg|*.zip|*.pkg|latest*.yml)" # 清理macOS构建产物
|
||||
npx del-cli "dist/*" "!dist/(*.dmg|*.zip|latest*.yml)" # 清理macOS构建产物,只保留特定文件
|
||||
|
||||
- name: Cleanup Artifacts for Linux
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
npx del-cli "dist/*" "!dist/(*.AppImage|*.deb|*.rpm|*.snap|*.tar.gz|latest*.yml)" # 清理Linux构建产物
|
||||
npx del-cli "dist/*" "!dist/(*.AppImage|*.deb|*.snap|latest*.yml)" # 清理Linux构建产物,只保留特定文件
|
||||
|
||||
- name: Upload artifacts
|
||||
- name: upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.os }}
|
||||
path: dist # 上传构建产物作为工作流artifact
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
- name: release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: 'dist/**' # 将dist目录下所有文件添加到release
|
||||
|
||||
- name: Create GitHub Release with all files
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
artifacts/**/*
|
||||
# 新增:自动同步到 WebDAV
|
||||
sync-to-webdav:
|
||||
name: Sync to WebDAV
|
||||
needs: release # 等待 release 任务完成
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v') # 只在标签推送时执行
|
||||
steps:
|
||||
- name: Wait for release to be ready
|
||||
run: |
|
||||
echo "等待 Release 准备就绪..."
|
||||
sleep 30 # 等待30秒确保 release 完全创建
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl jq
|
||||
|
||||
- name: Get latest release info
|
||||
id: get-release
|
||||
run: |
|
||||
# 获取当前标签对应的 release 信息
|
||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||
echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
# 获取 release 详细信息
|
||||
response=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/releases/tags/$TAG_NAME")
|
||||
|
||||
release_id=$(echo "$response" | jq -r '.id')
|
||||
echo "release_id=$release_id" >> $GITHUB_OUTPUT
|
||||
echo "找到 Release ID: $release_id"
|
||||
|
||||
- name: Sync release to WebDAV
|
||||
run: |
|
||||
TAG_NAME="${{ steps.get-release.outputs.tag_name }}"
|
||||
RELEASE_ID="${{ steps.get-release.outputs.release_id }}"
|
||||
|
||||
echo "🚀 开始同步版本 $TAG_NAME 到 WebDAV..."
|
||||
echo "Release ID: $RELEASE_ID"
|
||||
echo "WebDAV 根路径: ${{ secrets.WEBDAV_BASE_URL }}/yd/ceru"
|
||||
|
||||
# 获取该release的所有资源文件
|
||||
assets_json=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/releases/$RELEASE_ID/assets")
|
||||
|
||||
assets_count=$(echo "$assets_json" | jq '. | length')
|
||||
echo "找到 $assets_count 个资源文件"
|
||||
|
||||
if [ "$assets_count" -eq 0 ]; then
|
||||
echo "⚠️ 该版本没有资源文件,跳过同步"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 先创建版本目录
|
||||
dir_path="/yd/ceru/$TAG_NAME"
|
||||
dir_url="${{ secrets.WEBDAV_BASE_URL }}$dir_path"
|
||||
|
||||
echo "创建版本目录: $dir_path"
|
||||
curl -s -X MKCOL \
|
||||
-u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
|
||||
"$dir_url" || echo "目录可能已存在"
|
||||
|
||||
# 处理每个asset
|
||||
success_count=0
|
||||
failed_count=0
|
||||
|
||||
for i in $(seq 0 $(($assets_count - 1))); do
|
||||
asset=$(echo "$assets_json" | jq -c ".[$i]")
|
||||
asset_name=$(echo "$asset" | jq -r '.name')
|
||||
asset_url=$(echo "$asset" | jq -r '.url')
|
||||
asset_size=$(echo "$asset" | jq -r '.size')
|
||||
|
||||
echo "📦 处理资源: $asset_name (大小: $asset_size bytes)"
|
||||
|
||||
# 下载资源文件
|
||||
safe_filename="./temp_${TAG_NAME}_$(date +%s)_$i"
|
||||
|
||||
if ! curl -sL -o "$safe_filename" \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/octet-stream" \
|
||||
"$asset_url"; then
|
||||
echo "❌ 下载失败: $asset_name"
|
||||
failed_count=$((failed_count + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -f "$safe_filename" ]; then
|
||||
actual_size=$(wc -c < "$safe_filename")
|
||||
if [ "$actual_size" -ne "$asset_size" ]; then
|
||||
echo "❌ 文件大小不匹配: $asset_name (期望: $asset_size, 实际: $actual_size)"
|
||||
rm -f "$safe_filename"
|
||||
failed_count=$((failed_count + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "⬆️ 上传到 WebDAV: $asset_name"
|
||||
|
||||
# 构建远程路径
|
||||
remote_path="/yd/ceru/$TAG_NAME/$asset_name"
|
||||
full_url="${{ secrets.WEBDAV_BASE_URL }}$remote_path"
|
||||
|
||||
# 使用 WebDAV PUT 方法上传文件
|
||||
if curl -s -f -X PUT \
|
||||
-u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
|
||||
-T "$safe_filename" \
|
||||
"$full_url"; then
|
||||
|
||||
echo "✅ 上传成功: $asset_name"
|
||||
success_count=$((success_count + 1))
|
||||
|
||||
else
|
||||
echo "❌ 上传失败: $asset_name"
|
||||
failed_count=$((failed_count + 1))
|
||||
fi
|
||||
|
||||
# 清理临时文件
|
||||
rm -f "$safe_filename"
|
||||
echo "----------------------------------------"
|
||||
else
|
||||
echo "❌ 临时文件不存在: $safe_filename"
|
||||
failed_count=$((failed_count + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "========================================"
|
||||
echo "🎉 同步完成!"
|
||||
echo "成功: $success_count 个文件"
|
||||
echo "失败: $failed_count 个文件"
|
||||
echo "总计: $assets_count 个文件"
|
||||
|
||||
- name: Notify completion
|
||||
if: always()
|
||||
run: |
|
||||
if [ "${{ job.status }}" == "success" ]; then
|
||||
echo "✅ 版本 ${{ steps.get-release.outputs.tag_name }} 已成功同步到 alist"
|
||||
else
|
||||
echo "❌ 版本 ${{ steps.get-release.outputs.tag_name }} 同步失败"
|
||||
fi
|
||||
|
||||
154
.github/workflows/sync-releases-to-webdav.yml
vendored
Normal file
@@ -0,0 +1,154 @@
|
||||
name: Sync Existing Releases to Alist
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: '要同步的特定标签(如 v1.0.0),留空则同步所有版本'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync-releases-to-alist:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl jq
|
||||
|
||||
- name: Get all releases
|
||||
id: get-releases
|
||||
run: |
|
||||
response=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/releases")
|
||||
echo "releases_json=$(echo "$response" | jq -c '.')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Sync releases to Alist(自动登录 & 上传)
|
||||
run: |
|
||||
# ========== 1. 读取输入参数 ==========
|
||||
SPECIFIC_TAG="${{ github.event.inputs.tag_name }}"
|
||||
RELEASES_JSON='${{ steps.get-releases.outputs.releases_json }}'
|
||||
|
||||
# ========== 2. Alist 连接信息 ==========
|
||||
ALIST_URL="https://alist.shiqianjiang.cn" # https://pan.example.com
|
||||
ALIST_USER="${{ secrets.WEBDAV_USERNAME }}" # Alist 登录账号
|
||||
ALIST_PASS="${{ secrets.WEBDAV_PASSWORD }}" # Alist 登录密码
|
||||
ALIST_DIR="/yd/ceru" # 目标根目录
|
||||
|
||||
# ========== 3. 登录拿 token ==========
|
||||
echo "正在登录 Alist ..."
|
||||
login_resp=$(curl -s -X POST "$ALIST_URL/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"username\": \"$ALIST_USER\",
|
||||
\"password\": \"$ALIST_PASS\"
|
||||
}")
|
||||
echo "$login_resp"
|
||||
token=$(echo "$login_resp" | jq -r '.data.token // empty')
|
||||
|
||||
if [ -z "$token" ]; then
|
||||
echo "❌ 登录失败,返回:$login_resp"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ 登录成功,token 已获取"
|
||||
|
||||
# ========== 4. 循环处理 release ==========
|
||||
releases_count=$(echo "$RELEASES_JSON" | jq '. | length')
|
||||
echo "找到 $releases_count 个 releases"
|
||||
for i in $(seq 0 $(($releases_count - 1))); do
|
||||
release=$(echo "$RELEASES_JSON" | jq -c ".[$i]")
|
||||
tag_name=$(echo "$release" | jq -r '.tag_name')
|
||||
release_id=$(echo "$release" | jq -r '.id')
|
||||
|
||||
[ -n "$SPECIFIC_TAG" ] && [ "$tag_name" != "$SPECIFIC_TAG" ] && {
|
||||
echo "跳过 $tag_name,不是指定标签"
|
||||
continue
|
||||
}
|
||||
|
||||
echo "处理版本: $tag_name (ID: $release_id)"
|
||||
assets_json=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/releases/$release_id/assets")
|
||||
assets_count=$(echo "$assets_json" | jq '. | length')
|
||||
echo "找到 $assets_count 个资源文件"
|
||||
|
||||
for j in $(seq 0 $(($assets_count - 1))); do
|
||||
asset=$(echo "$assets_json" | jq -c ".[$j]")
|
||||
asset_name=$(echo "$asset" | jq -r '.name')
|
||||
asset_url=$(echo "$asset" | jq -r '.url')
|
||||
asset_size=$(echo "$asset" | jq -r '.size')
|
||||
|
||||
echo "下载资源: $asset_name (大小: $asset_size bytes)"
|
||||
safe_filename="./temp_download_$(date +%s)_$j"
|
||||
|
||||
# 下载
|
||||
curl -sL -o "$safe_filename" \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/octet-stream" \
|
||||
"$asset_url" || {
|
||||
echo "❌ 下载失败: $asset_name"
|
||||
continue
|
||||
}
|
||||
|
||||
# 大小校验
|
||||
actual_size=$(wc -c < "$safe_filename")
|
||||
[ "$actual_size" -ne "$asset_size" ] && {
|
||||
echo "❌ 文件大小不匹配: $asset_name"
|
||||
rm -f "$safe_filename"
|
||||
continue
|
||||
}
|
||||
|
||||
# 组装远程路径(URL 编码)
|
||||
remote_path="$ALIST_DIR/$tag_name/$asset_name"
|
||||
file_path_encoded=$(printf %s "$remote_path" | jq -sRr @uri)
|
||||
echo "上传到 Alist: $remote_path"
|
||||
|
||||
# 调用 /api/fs/put 上传(带 As-Task 异步)
|
||||
response=$(
|
||||
curl -s -X PUT "$ALIST_URL/api/fs/put" \
|
||||
-H "Authorization: $token" \
|
||||
-H "File-Path: $file_path_encoded" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
-H "Content-Length: $actual_size" \
|
||||
-H "As-Task: true" \
|
||||
--data-binary @"$safe_filename"
|
||||
)
|
||||
echo "==== 上传接口原始返回 ===="
|
||||
echo "$response"
|
||||
code=$(echo "$response" | jq -r '.code // empty')
|
||||
if [ "$code" = "200" ]; then
|
||||
echo "✅ Alist 上传任务创建成功: $asset_name"
|
||||
else
|
||||
echo "❌ Alist 上传失败: $asset_name"
|
||||
fi
|
||||
|
||||
rm -f "$safe_filename"
|
||||
echo "----------------------------------------"
|
||||
done
|
||||
echo "版本 $tag_name 处理完成"
|
||||
echo "========================================"
|
||||
done
|
||||
|
||||
# ========== 5. 退出登录 ==========
|
||||
echo "退出登录 ..."
|
||||
curl -s -X POST "$ALIST_URL/api/auth/logout" \
|
||||
-H "Authorization: $token" > /dev/null || true
|
||||
|
||||
echo "🎉 Alist 同步完成"
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "同步任务已完成!"
|
||||
echo "请检查 Alist 中的文件是否正确上传。"
|
||||
echo "如果遇到问题,请检查以下配置:"
|
||||
echo "1. ALIST_URL - Alist 服务器地址"
|
||||
echo "2. ALIST_USERNAME - Alist 登录账号"
|
||||
echo "3. ALIST_PASSWORD - Alist 登录密码"
|
||||
echo "4. GITHUB_TOKEN - GitHub 访问令牌"
|
||||
160
.github/workflows/uploadpan.yml
vendored
Normal file
@@ -0,0 +1,160 @@
|
||||
name: Sync Existing Releases to WebDAV
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: '要同步的特定标签(如 v1.0.0),留空则同步所有版本'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync-releases-to-webdav:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl
|
||||
|
||||
- name: Get all releases
|
||||
id: get-releases
|
||||
run: |
|
||||
response=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/releases")
|
||||
echo "releases_json=$(echo "$response" | jq -c '.')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Sync releases to WebDAV
|
||||
run: |
|
||||
# 读取输入参数
|
||||
SPECIFIC_TAG="${{ github.event.inputs.tag_name }}"
|
||||
RELEASES_JSON='${{ steps.get-releases.outputs.releases_json }}'
|
||||
|
||||
echo "开始同步 releases..."
|
||||
echo "特定标签: ${SPECIFIC_TAG:-所有版本}"
|
||||
echo "WebDAV 根路径: ${{ secrets.WEBDAV_BASE_URL }}/yd/ceru"
|
||||
|
||||
# 处理每个 release
|
||||
releases_count=$(echo "$RELEASES_JSON" | jq '. | length')
|
||||
echo "找到 $releases_count 个 releases"
|
||||
|
||||
for i in $(seq 0 $(($releases_count - 1))); do
|
||||
release=$(echo "$RELEASES_JSON" | jq -c ".[$i]")
|
||||
tag_name=$(echo "$release" | jq -r '.tag_name')
|
||||
release_id=$(echo "$release" | jq -r '.id')
|
||||
|
||||
if [ -n "$SPECIFIC_TAG" ] && [ "$tag_name" != "$SPECIFIC_TAG" ]; then
|
||||
echo "跳过 $tag_name,不是指定的标签 $SPECIFIC_TAG"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "正在处理版本: $tag_name (ID: $release_id)"
|
||||
|
||||
# 获取该release的所有资源文件
|
||||
assets_json=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/releases/$release_id/assets")
|
||||
|
||||
assets_count=$(echo "$assets_json" | jq '. | length')
|
||||
echo "找到 $assets_count 个资源文件"
|
||||
|
||||
# 处理每个asset
|
||||
for j in $(seq 0 $(($assets_count - 1))); do
|
||||
asset=$(echo "$assets_json" | jq -c ".[$j]")
|
||||
asset_name=$(echo "$asset" | jq -r '.name')
|
||||
asset_url=$(echo "$asset" | jq -r '.url')
|
||||
asset_size=$(echo "$asset" | jq -r '.size')
|
||||
|
||||
echo "下载资源: $asset_name (大小: $asset_size bytes)"
|
||||
|
||||
# 下载资源文件
|
||||
safe_filename="./temp_download"
|
||||
|
||||
if ! curl -sL -o "$safe_filename" \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/octet-stream" \
|
||||
"$asset_url"; then
|
||||
echo "❌ 下载失败: $asset_name"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -f "$safe_filename" ]; then
|
||||
actual_size=$(wc -c < "$safe_filename")
|
||||
if [ "$actual_size" -ne "$asset_size" ]; then
|
||||
echo "❌ 文件大小不匹配: $asset_name (期望: $asset_size, 实际: $actual_size)"
|
||||
rm -f "$safe_filename"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "上传到 WebDAV: $asset_name"
|
||||
|
||||
# 构建远程路径
|
||||
remote_path="/yd/ceru/$tag_name/$asset_name"
|
||||
full_url="${{ secrets.WEBDAV_BASE_URL }}$remote_path"
|
||||
|
||||
echo "完整路径: $full_url"
|
||||
|
||||
# 使用 WebDAV PUT 方法上传文件
|
||||
if curl -s -f -X PUT \
|
||||
-u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
|
||||
-T "$safe_filename" \
|
||||
"$full_url"; then
|
||||
|
||||
echo "✅ WebDAV 上传成功: $asset_name"
|
||||
|
||||
# 验证文件是否存在
|
||||
echo "验证文件是否存在..."
|
||||
sleep 2
|
||||
|
||||
if curl -s -f -u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
|
||||
-X PROPFIND \
|
||||
-H "Depth: 0" \
|
||||
"$full_url" > /dev/null 2>&1; then
|
||||
echo "✅ 文件确认存在: $asset_name"
|
||||
else
|
||||
echo "⚠️ 文件验证失败,但上传可能成功"
|
||||
fi
|
||||
|
||||
else
|
||||
echo "❌ WebDAV 上传失败: $asset_name"
|
||||
echo "尝试创建目录后重新上传..."
|
||||
|
||||
# 尝试先创建目录
|
||||
dir_path="/yd/ceru/$tag_name"
|
||||
dir_url="${{ secrets.WEBDAV_BASE_URL }}$dir_path"
|
||||
|
||||
if curl -s -f -X MKCOL \
|
||||
-u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
|
||||
"$dir_url"; then
|
||||
echo "✅ 目录创建成功: $dir_path"
|
||||
|
||||
# 重新尝试上传文件
|
||||
if curl -s -f -X PUT \
|
||||
-u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
|
||||
-T "$safe_filename" \
|
||||
"$full_url"; then
|
||||
echo "✅ 重新上传成功: $asset_name"
|
||||
else
|
||||
echo "❌ 重新上传失败: $asset_name"
|
||||
fi
|
||||
else
|
||||
echo "❌ 目录创建失败: $dir_path"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 安全删除临时文件
|
||||
rm -f "$safe_filename"
|
||||
echo "----------------------------------------"
|
||||
else
|
||||
echo "❌ 文件不存在: $safe_filename"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
echo "🎉 WebDAV 同步完成"
|
||||
4
.gitignore
vendored
@@ -15,4 +15,6 @@ temp/log.txt
|
||||
/.kiro/
|
||||
/.vscode/
|
||||
/.codebuddy/
|
||||
/.idea/
|
||||
/.idea/
|
||||
docs/.vitepress/dist
|
||||
docs/.vitepress/cache
|
||||
|
||||
31
.vitepress/cache/deps/_metadata.json
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"hash": "23b978c5",
|
||||
"configHash": "c96c5ee9",
|
||||
"lockfileHash": "603038da",
|
||||
"browserHash": "b1457114",
|
||||
"optimized": {
|
||||
"vue": {
|
||||
"src": "../../../node_modules/vue/dist/vue.runtime.esm-bundler.js",
|
||||
"file": "vue.js",
|
||||
"fileHash": "7c4217d1",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vitepress > @vue/devtools-api": {
|
||||
"src": "../../../node_modules/vitepress/node_modules/@vue/devtools-api/dist/index.js",
|
||||
"file": "vitepress___@vue_devtools-api.js",
|
||||
"fileHash": "dc8e5ae9",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vitepress > @vueuse/core": {
|
||||
"src": "../../../node_modules/@vueuse/core/index.mjs",
|
||||
"file": "vitepress___@vueuse_core.js",
|
||||
"fileHash": "74c34320",
|
||||
"needsInterop": false
|
||||
}
|
||||
},
|
||||
"chunks": {
|
||||
"chunk-TH7GRLUQ": {
|
||||
"file": "chunk-TH7GRLUQ.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
12833
.vitepress/cache/deps/chunk-TH7GRLUQ.js
vendored
Normal file
7
.vitepress/cache/deps/chunk-TH7GRLUQ.js.map
vendored
Normal file
3
.vitepress/cache/deps/package.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
4140
.vitepress/cache/deps/vitepress___@vue_devtools-api.js
vendored
Normal file
7
.vitepress/cache/deps/vitepress___@vue_devtools-api.js.map
vendored
Normal file
9923
.vitepress/cache/deps/vitepress___@vueuse_core.js
vendored
Normal file
7
.vitepress/cache/deps/vitepress___@vueuse_core.js.map
vendored
Normal file
342
.vitepress/cache/deps/vue.js
vendored
Normal file
@@ -0,0 +1,342 @@
|
||||
import {
|
||||
BaseTransition,
|
||||
BaseTransitionPropsValidators,
|
||||
Comment,
|
||||
DeprecationTypes,
|
||||
EffectScope,
|
||||
ErrorCodes,
|
||||
ErrorTypeStrings,
|
||||
Fragment,
|
||||
KeepAlive,
|
||||
ReactiveEffect,
|
||||
Static,
|
||||
Suspense,
|
||||
Teleport,
|
||||
Text,
|
||||
TrackOpTypes,
|
||||
Transition,
|
||||
TransitionGroup,
|
||||
TriggerOpTypes,
|
||||
VueElement,
|
||||
assertNumber,
|
||||
callWithAsyncErrorHandling,
|
||||
callWithErrorHandling,
|
||||
camelize,
|
||||
capitalize,
|
||||
cloneVNode,
|
||||
compatUtils,
|
||||
compile,
|
||||
computed,
|
||||
createApp,
|
||||
createBaseVNode,
|
||||
createBlock,
|
||||
createCommentVNode,
|
||||
createElementBlock,
|
||||
createHydrationRenderer,
|
||||
createPropsRestProxy,
|
||||
createRenderer,
|
||||
createSSRApp,
|
||||
createSlots,
|
||||
createStaticVNode,
|
||||
createTextVNode,
|
||||
createVNode,
|
||||
customRef,
|
||||
defineAsyncComponent,
|
||||
defineComponent,
|
||||
defineCustomElement,
|
||||
defineEmits,
|
||||
defineExpose,
|
||||
defineModel,
|
||||
defineOptions,
|
||||
defineProps,
|
||||
defineSSRCustomElement,
|
||||
defineSlots,
|
||||
devtools,
|
||||
effect,
|
||||
effectScope,
|
||||
getCurrentInstance,
|
||||
getCurrentScope,
|
||||
getCurrentWatcher,
|
||||
getTransitionRawChildren,
|
||||
guardReactiveProps,
|
||||
h,
|
||||
handleError,
|
||||
hasInjectionContext,
|
||||
hydrate,
|
||||
hydrateOnIdle,
|
||||
hydrateOnInteraction,
|
||||
hydrateOnMediaQuery,
|
||||
hydrateOnVisible,
|
||||
initCustomFormatter,
|
||||
initDirectivesForSSR,
|
||||
inject,
|
||||
isMemoSame,
|
||||
isProxy,
|
||||
isReactive,
|
||||
isReadonly,
|
||||
isRef,
|
||||
isRuntimeOnly,
|
||||
isShallow,
|
||||
isVNode,
|
||||
markRaw,
|
||||
mergeDefaults,
|
||||
mergeModels,
|
||||
mergeProps,
|
||||
nextTick,
|
||||
normalizeClass,
|
||||
normalizeProps,
|
||||
normalizeStyle,
|
||||
onActivated,
|
||||
onBeforeMount,
|
||||
onBeforeUnmount,
|
||||
onBeforeUpdate,
|
||||
onDeactivated,
|
||||
onErrorCaptured,
|
||||
onMounted,
|
||||
onRenderTracked,
|
||||
onRenderTriggered,
|
||||
onScopeDispose,
|
||||
onServerPrefetch,
|
||||
onUnmounted,
|
||||
onUpdated,
|
||||
onWatcherCleanup,
|
||||
openBlock,
|
||||
popScopeId,
|
||||
provide,
|
||||
proxyRefs,
|
||||
pushScopeId,
|
||||
queuePostFlushCb,
|
||||
reactive,
|
||||
readonly,
|
||||
ref,
|
||||
registerRuntimeCompiler,
|
||||
render,
|
||||
renderList,
|
||||
renderSlot,
|
||||
resolveComponent,
|
||||
resolveDirective,
|
||||
resolveDynamicComponent,
|
||||
resolveFilter,
|
||||
resolveTransitionHooks,
|
||||
setBlockTracking,
|
||||
setDevtoolsHook,
|
||||
setTransitionHooks,
|
||||
shallowReactive,
|
||||
shallowReadonly,
|
||||
shallowRef,
|
||||
ssrContextKey,
|
||||
ssrUtils,
|
||||
stop,
|
||||
toDisplayString,
|
||||
toHandlerKey,
|
||||
toHandlers,
|
||||
toRaw,
|
||||
toRef,
|
||||
toRefs,
|
||||
toValue,
|
||||
transformVNodeArgs,
|
||||
triggerRef,
|
||||
unref,
|
||||
useAttrs,
|
||||
useCssModule,
|
||||
useCssVars,
|
||||
useHost,
|
||||
useId,
|
||||
useModel,
|
||||
useSSRContext,
|
||||
useShadowRoot,
|
||||
useSlots,
|
||||
useTemplateRef,
|
||||
useTransitionState,
|
||||
vModelCheckbox,
|
||||
vModelDynamic,
|
||||
vModelRadio,
|
||||
vModelSelect,
|
||||
vModelText,
|
||||
vShow,
|
||||
version,
|
||||
warn,
|
||||
watch,
|
||||
watchEffect,
|
||||
watchPostEffect,
|
||||
watchSyncEffect,
|
||||
withAsyncContext,
|
||||
withCtx,
|
||||
withDefaults,
|
||||
withDirectives,
|
||||
withKeys,
|
||||
withMemo,
|
||||
withModifiers,
|
||||
withScopeId
|
||||
} from './chunk-TH7GRLUQ.js'
|
||||
export {
|
||||
BaseTransition,
|
||||
BaseTransitionPropsValidators,
|
||||
Comment,
|
||||
DeprecationTypes,
|
||||
EffectScope,
|
||||
ErrorCodes,
|
||||
ErrorTypeStrings,
|
||||
Fragment,
|
||||
KeepAlive,
|
||||
ReactiveEffect,
|
||||
Static,
|
||||
Suspense,
|
||||
Teleport,
|
||||
Text,
|
||||
TrackOpTypes,
|
||||
Transition,
|
||||
TransitionGroup,
|
||||
TriggerOpTypes,
|
||||
VueElement,
|
||||
assertNumber,
|
||||
callWithAsyncErrorHandling,
|
||||
callWithErrorHandling,
|
||||
camelize,
|
||||
capitalize,
|
||||
cloneVNode,
|
||||
compatUtils,
|
||||
compile,
|
||||
computed,
|
||||
createApp,
|
||||
createBlock,
|
||||
createCommentVNode,
|
||||
createElementBlock,
|
||||
createBaseVNode as createElementVNode,
|
||||
createHydrationRenderer,
|
||||
createPropsRestProxy,
|
||||
createRenderer,
|
||||
createSSRApp,
|
||||
createSlots,
|
||||
createStaticVNode,
|
||||
createTextVNode,
|
||||
createVNode,
|
||||
customRef,
|
||||
defineAsyncComponent,
|
||||
defineComponent,
|
||||
defineCustomElement,
|
||||
defineEmits,
|
||||
defineExpose,
|
||||
defineModel,
|
||||
defineOptions,
|
||||
defineProps,
|
||||
defineSSRCustomElement,
|
||||
defineSlots,
|
||||
devtools,
|
||||
effect,
|
||||
effectScope,
|
||||
getCurrentInstance,
|
||||
getCurrentScope,
|
||||
getCurrentWatcher,
|
||||
getTransitionRawChildren,
|
||||
guardReactiveProps,
|
||||
h,
|
||||
handleError,
|
||||
hasInjectionContext,
|
||||
hydrate,
|
||||
hydrateOnIdle,
|
||||
hydrateOnInteraction,
|
||||
hydrateOnMediaQuery,
|
||||
hydrateOnVisible,
|
||||
initCustomFormatter,
|
||||
initDirectivesForSSR,
|
||||
inject,
|
||||
isMemoSame,
|
||||
isProxy,
|
||||
isReactive,
|
||||
isReadonly,
|
||||
isRef,
|
||||
isRuntimeOnly,
|
||||
isShallow,
|
||||
isVNode,
|
||||
markRaw,
|
||||
mergeDefaults,
|
||||
mergeModels,
|
||||
mergeProps,
|
||||
nextTick,
|
||||
normalizeClass,
|
||||
normalizeProps,
|
||||
normalizeStyle,
|
||||
onActivated,
|
||||
onBeforeMount,
|
||||
onBeforeUnmount,
|
||||
onBeforeUpdate,
|
||||
onDeactivated,
|
||||
onErrorCaptured,
|
||||
onMounted,
|
||||
onRenderTracked,
|
||||
onRenderTriggered,
|
||||
onScopeDispose,
|
||||
onServerPrefetch,
|
||||
onUnmounted,
|
||||
onUpdated,
|
||||
onWatcherCleanup,
|
||||
openBlock,
|
||||
popScopeId,
|
||||
provide,
|
||||
proxyRefs,
|
||||
pushScopeId,
|
||||
queuePostFlushCb,
|
||||
reactive,
|
||||
readonly,
|
||||
ref,
|
||||
registerRuntimeCompiler,
|
||||
render,
|
||||
renderList,
|
||||
renderSlot,
|
||||
resolveComponent,
|
||||
resolveDirective,
|
||||
resolveDynamicComponent,
|
||||
resolveFilter,
|
||||
resolveTransitionHooks,
|
||||
setBlockTracking,
|
||||
setDevtoolsHook,
|
||||
setTransitionHooks,
|
||||
shallowReactive,
|
||||
shallowReadonly,
|
||||
shallowRef,
|
||||
ssrContextKey,
|
||||
ssrUtils,
|
||||
stop,
|
||||
toDisplayString,
|
||||
toHandlerKey,
|
||||
toHandlers,
|
||||
toRaw,
|
||||
toRef,
|
||||
toRefs,
|
||||
toValue,
|
||||
transformVNodeArgs,
|
||||
triggerRef,
|
||||
unref,
|
||||
useAttrs,
|
||||
useCssModule,
|
||||
useCssVars,
|
||||
useHost,
|
||||
useId,
|
||||
useModel,
|
||||
useSSRContext,
|
||||
useShadowRoot,
|
||||
useSlots,
|
||||
useTemplateRef,
|
||||
useTransitionState,
|
||||
vModelCheckbox,
|
||||
vModelDynamic,
|
||||
vModelRadio,
|
||||
vModelSelect,
|
||||
vModelText,
|
||||
vShow,
|
||||
version,
|
||||
warn,
|
||||
watch,
|
||||
watchEffect,
|
||||
watchPostEffect,
|
||||
watchSyncEffect,
|
||||
withAsyncContext,
|
||||
withCtx,
|
||||
withDefaults,
|
||||
withDirectives,
|
||||
withKeys,
|
||||
withMemo,
|
||||
withModifiers,
|
||||
withScopeId
|
||||
}
|
||||
7
.vitepress/cache/deps/vue.js.map
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
217
LICENSE
@@ -1,21 +1,204 @@
|
||||
MIT License
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Copyright (c) 2025 时迁酱
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright (c) 2025 时迁酱
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
110
README.md
@@ -1,10 +1,12 @@
|
||||
# Ceru Music
|
||||
# Ceru Music(澜音)
|
||||
|
||||
一个跨平台的音乐播放器应用,支持多来源音乐数据获取与播放。
|
||||
一个跨平台的音乐播放器应用,支持基于合规插件获取公开音乐信息与播放功能。
|
||||
|
||||
## 项目简介
|
||||
|
||||
Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器,支持从多个音乐平台获取歌曲信息并播放。该项目结合了现代前端技术和桌面应用开发,提供了流畅的用户体验和灵活的音乐数据源支持。
|
||||
Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,**仅提供插件运行框架与播放功能**,不直接存储、提供任何音乐源文件。用户需通过自行选择、安装合规插件获取音乐相关数据,项目旨在为开发者提供桌面应用技术实践与学习案例,为用户提供合规的音乐播放工具框架。
|
||||
|
||||
<img src="assets/image-20250827175023917.png" alt="image-20250827175023917" style="zoom: 33%;" /><img src="assets/image-20250827175109430.png" alt="image-20250827175109430" style="zoom:33%;" />
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -13,23 +15,26 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器,
|
||||
- **TypeScript**:增强代码可维护性和类型安全
|
||||
- **Pinia**:状态管理工具
|
||||
- **Vite**:快速的前端构建工具
|
||||
- **Meting API**:作为备用音乐数据源
|
||||
- **CeruPlugins**:音乐插件运行环境(仅提供框架,不包含默认插件)
|
||||
- **AMLL**:音乐生态辅助模块(仅提供功能接口,不关联具体音乐数据源)
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 支持从多个音乐平台搜索和播放歌曲
|
||||
- 获取歌词和专辑信息
|
||||
- 提供插件加载与管理功能,支持通过合规插件获取公开音乐信息
|
||||
- 支持通过插件获取歌词、专辑封面等公开元数据
|
||||
- 支持虚拟滚动列表,优化大量数据渲染性能
|
||||
- 本地数据存储与播放列表管理
|
||||
- 本地播放列表管理(仅存储用户手动创建的列表结构,不包含音乐文件)
|
||||
- **提示**:本地数据仅保存在用户设备本地,未进行云端备份,用户需自行备份以防止数据丢失
|
||||
- 精美的用户界面与动画效果
|
||||
- **插件生态框架**(插件需用户自行获取并确保合规性)
|
||||
|
||||
## 安装与使用
|
||||
|
||||
### 推荐开发环境
|
||||
|
||||
- **IDE**: VS Code 或 WebStorm
|
||||
- **Node.js 版本**: 推荐使用最新稳定版
|
||||
- **包管理器**: yarn
|
||||
- **Node.js 版本**: 22 及以上
|
||||
- **包管理器**: **yarn**
|
||||
|
||||
### 项目设置
|
||||
|
||||
@@ -46,46 +51,111 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器,
|
||||
```
|
||||
|
||||
3. 构建应用:
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
```
|
||||
|
||||
### 平台构建指令
|
||||
|
||||
- **Windows**:
|
||||
- Windows
|
||||
|
||||
```bash
|
||||
yarn build:win
|
||||
```
|
||||
|
||||
- **macOS**:
|
||||
- macOS
|
||||
|
||||
```bash
|
||||
yarn build:mac
|
||||
```
|
||||
|
||||
- **Linux**:
|
||||
- Linux
|
||||
|
||||
```bash
|
||||
yarn build:linux
|
||||
```
|
||||
|
||||
> 提示:构建后的应用仅包含播放器框架,需用户自行配置合规插件方可获取音乐数据。
|
||||
|
||||
## 文档与资源
|
||||
|
||||
- [API 接口文档](docs/api.md):详细说明了支持的音乐平台和请求格式。
|
||||
- [产品设计文档](docs/design.md):涵盖项目架构、核心功能设计和开发规范。
|
||||
- [产品设计文档](https://www.doubao.com/thread/docs/design.md):涵盖项目架构、核心功能设计和开发规范(不含任何音乐数据源信息)。
|
||||
- [插件开发文档](https://www.doubao.com/thread/docs/CeruMusic插件开发文档.md):仅提供插件开发技术规范,**明确要求插件开发者需遵守数据来源平台的用户协议与版权法**,禁止开发、传播获取非公开数据的插件。
|
||||
|
||||
## 开源许可
|
||||
|
||||
本项目遵循 MIT 许可协议。详情请参阅 [LICENSE](LICENSE) 文件。
|
||||
本项目源代码遵循 **Apache License 2.0**,仅授权用户对项目框架进行学习、修改与二次开发,不包含任何音乐数据相关授权。详情请参阅 [LICENSE](./LICENSE) 文件,使用前请务必阅读许可条款。
|
||||
|
||||
## 贡献指南
|
||||
|
||||
欢迎贡献代码和反馈建议!请遵循 [Git 提交规范](docs/design.md#git提交规范) 并确保代码符合项目风格指南。
|
||||
欢迎开发者贡献代码与反馈建议,贡献内容需符合以下要求:
|
||||
|
||||
## 更新日志
|
||||
|
||||
请参阅 [更新日志](docs/api.md#更新日志) 了解最新功能和改进。
|
||||
1. 仅涉及播放器框架功能优化、bug 修复、文档完善,不包含任何音乐数据源相关代码。
|
||||
2. 遵循 [Git 提交规范](https://www.doubao.com/thread/docs/design.md#git提交规范) 并确保代码符合项目风格指南。
|
||||
3. 贡献的代码需无第三方版权纠纷,且不违反开源许可协议。
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题或合作意向,请通过 Gitee 私信联系项目维护者。
|
||||
如有技术问题或合作意向(仅限技术交流),请通过 Gitee 私信联系项目维护者。
|
||||
|
||||
## 项目开发者
|
||||
|
||||
- **时迁酱**:产品总体设计与开发
|
||||
|
||||
<img src="assets/head.jpg" alt="head.jpg (940×940)" style="zoom:15%;" />
|
||||
|
||||
- **无聊的霜霜**:首页设计&Ai助手
|
||||
|
||||
<img src="assets/image-20250827181604432.png" alt="image-20250827181604432" style="zoom:25%;" />
|
||||
|
||||
- **Star**:**插件管理**相关功能&部分接口封装
|
||||
|
||||
<img src="assets/image-20250827181535681.png" alt="image-20250827181535681" style="zoom:25%;" />
|
||||
|
||||
**Tips**: 排名不分先后
|
||||
|
||||
# 法律声明与免责条款
|
||||
|
||||
**重要提示:使用本项目前,请务必仔细阅读本条款,使用本项目即视为你已充分理解并同意本条款全部内容。**
|
||||
|
||||
### 一、定义约定
|
||||
|
||||
- “Apache License 2.0”:指 Ceru Music(澜音)桌面播放器框架及源代码,不包含任何第三方插件或音乐数据。
|
||||
- “**用户**”:指下载、安装、使用本项目的个人或组织。
|
||||
- “**合规插件**”:指符合数据来源平台用户协议、不侵犯第三方版权、不获取非公开数据的插件。
|
||||
- “**版权内容**”:指包括但不限于音乐文件、歌词、专辑封面、艺人信息等受著作权法保护的内容。
|
||||
|
||||
### 二、数据与内容责任
|
||||
|
||||
1. 本项目**不直接获取、存储、传输任何音乐数据或版权内容**,仅提供插件运行框架。用户通过插件获取的所有数据,其合法性、准确性由插件提供者及用户**自行负责**,本项目不承担任何责任。
|
||||
2. 若用户使用的插件存在获取非公开数据、侵犯第三方版权等违规行为,相关法律责任由用户及插件提供者承担,与本项目无关。
|
||||
3. 本项目使用的字体、图片等素材,均来自开源社区或已获得合法授权,若存在侵权请联系项目维护者立即移除,本项目将积极配合处理。
|
||||
|
||||
### 三、版权合规要求
|
||||
|
||||
1. 用户承诺:使用本项目时,仅通过合规插件获取音乐相关信息,且获取、使用版权内容的行为符合**《中华人民共和国著作权法》**及相关法律法规,不侵犯**任何第三方**合法权益。
|
||||
2. 用户需知晓:任何未经授权下载、传播、使用受版权保护的音乐文件的行为,均可能构成侵权,需自行承担法律后果。
|
||||
3. 本项目倡导 “尊重版权、支持正版”,提醒用户通过官方音乐平台获取授权音乐服务。
|
||||
|
||||
### 四、免责声明
|
||||
|
||||
1. 因用户使用非合规插件、违反法律法规或第三方协议导致的任何法律责任(包括但不限于侵权赔偿、行政处罚),均由用户自行承担,本项目不承担任何直接、间接、连带或衍生责任。
|
||||
2. 因本项目框架本身的 **bug** 导致的用户设备故障、数据丢失,本项目仅承担在合理范围内的技术修复责任,不承担由此产生的间接损失(如商誉损失、业务中断损失等)。
|
||||
3. 本项目为开源学习项目,不提供商业服务,对用户使用本项目的效果不做任何明示或暗示的保证。
|
||||
|
||||
### 五、使用限制
|
||||
|
||||
1. 本项目仅允许用于**非商业、纯技术学习目的**,禁止用于任何商业运营、盈利活动,禁止修改后用于侵犯第三方权益的场景。
|
||||
2. 禁止在违反当地法律法规、本声明或第三方协议的前提下使用本项目,若用户所在地区禁止此类工具的使用,应立即停止使用。
|
||||
3. 禁止将本项目源代码或构建后的应用,与违规插件捆绑传播,禁止利用本项目从事任何违法违规活动。
|
||||
|
||||
### 六、其他
|
||||
|
||||
1. 本声明的效力、解释及适用,均适用中华人民共和国法律(不含港澳台地区法律)。
|
||||
2. 若用户与本项目维护者就本声明产生争议,应首先通过友好协商解决;协商不成的,任何一方均有权向本项目维护者所在地有管辖权的人民法院提起诉讼。
|
||||
|
||||
## 赞助
|
||||
|
||||
若您认可本项目的技术价值,欢迎通过以下方式支持开发者(仅用于项目技术维护与迭代):
|
||||
<img src="assets/image-20250827175356006.png" alt="赞助方式1" style="zoom:33%;" /><img src="assets/image-20250827175547444.png" alt="赞助方式2" style="zoom: 33%;" />
|
||||
|
||||
BIN
assets/head.jpg
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
assets/image-20250827175023917.png
Normal file
|
After Width: | Height: | Size: 719 KiB |
BIN
assets/image-20250827175109430.png
Normal file
|
After Width: | Height: | Size: 981 KiB |
BIN
assets/image-20250827175356006.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
assets/image-20250827175547444.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
assets/image-20250827181535681.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
assets/image-20250827181604432.png
Normal file
|
After Width: | Height: | Size: 528 KiB |
BIN
assets/image-20250827181634134.png
Normal file
|
After Width: | Height: | Size: 528 KiB |
57
docs/.vitepress/config.mts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { defineConfig } from 'vitepress'
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
lang: 'zh-CN',
|
||||
title: 'Ceru Music',
|
||||
base: '/',
|
||||
description:
|
||||
'Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,一个跨平台的音乐播放器应用,支持基于合规插件获取公开音乐信息与播放功能。',
|
||||
themeConfig: {
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
logo: '/logo.svg',
|
||||
nav: [
|
||||
{ text: '首页', link: '/' },
|
||||
{ text: '使用文档', link: '/guide/' }
|
||||
],
|
||||
|
||||
sidebar: [
|
||||
{
|
||||
text: 'CeruMusic',
|
||||
items: [
|
||||
{ text: '使用教程', link: '/guide/' },
|
||||
{ text: '软件设计文档', link: '/guide/design' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '澜音&插件',
|
||||
items: [
|
||||
{ text: '插件类使用', link: '/guide/CeruMusicPluginHost' },
|
||||
{ text: '澜音插件开发文档(重点)', link: '/guide/CeruMusicPluginDev' }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
socialLinks: [
|
||||
{ icon: 'github', link: 'https://github.com/timeshiftsauce/CeruMusic' },
|
||||
{ icon: 'gitee', link: 'https://gitee.com/sqjcode/CeruMuisc' },
|
||||
{ icon: 'qq', link: 'https://qm.qq.com/q/IDpQnbGd06' },
|
||||
{ icon: 'beatsbydre', link: 'https://shiqianjiang.cn' },
|
||||
{ icon: 'bilibili', link: 'https://space.bilibili.com/696709986' }
|
||||
],
|
||||
footer: {
|
||||
message: 'Released under the Apache License 2.0 License.',
|
||||
copyright: `Copyright © 2025-${new Date().getFullYear()} 时迁酱`
|
||||
},
|
||||
editLink: {
|
||||
pattern: 'https://github.com/timeshiftsauce/CeruMusic/edit/main/docs/:path'
|
||||
},
|
||||
search: {
|
||||
provider: 'local'
|
||||
}
|
||||
},
|
||||
lastUpdated: true,
|
||||
head: [['link', { rel: 'icon', href: '/logo.svg' }]]
|
||||
})
|
||||
console.log(process.env.BASE_URL_DOCS)
|
||||
// Smooth scrolling functions
|
||||
80
docs/.vitepress/theme/MyLayout.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import { useRouter, useData } from 'vitepress'
|
||||
import { toggleDark } from './dark'
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import { watch, ref } from 'vue'
|
||||
|
||||
const { route } = useRouter()
|
||||
const isTransitioning = ref(false)
|
||||
const { Layout } = DefaultTheme
|
||||
const { isDark } = useData()
|
||||
|
||||
toggleDark(isDark)
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
isTransitioning.value = true
|
||||
// 动画结束后重置状态
|
||||
setTimeout(() => {
|
||||
isTransitioning.value = false
|
||||
}, 500) // 500ms 要和 CSS 动画时间匹配
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout> </Layout>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* .shade {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background-color: rgb(255, 255, 255);
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: transform 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.shade-active {
|
||||
opacity: 0;
|
||||
animation: shadeAnimation 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes shadeAnimation {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(100vh);
|
||||
}
|
||||
} */
|
||||
#VPContent.vp-doc > div {
|
||||
animation:
|
||||
rises 1s,
|
||||
looming 0.6s;
|
||||
}
|
||||
@keyframes rises {
|
||||
0% {
|
||||
transform: translateY(50px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes looming {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
21
docs/.vitepress/theme/dark.css
Normal file
@@ -0,0 +1,21 @@
|
||||
::view-transition-old(*) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
::view-transition-new(*) {
|
||||
animation: globalDark 0.5s ease-in;
|
||||
}
|
||||
|
||||
@keyframes globalDark {
|
||||
from {
|
||||
clip-path: circle(0% at var(--darkX) var(--darkY));
|
||||
}
|
||||
|
||||
to {
|
||||
clip-path: circle(100% at var(--darkX) var(--darkY));
|
||||
}
|
||||
}
|
||||
|
||||
.dark img {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
26
docs/.vitepress/theme/dark.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { nextTick, provide } from 'vue'
|
||||
// 判断是否能使用 startViewTransition
|
||||
const enableTransitions = () => {
|
||||
return (
|
||||
'startViewTransition' in document &&
|
||||
window.matchMedia('(prefers-reduced-motion: no-preference)').matches
|
||||
)
|
||||
}
|
||||
// 切换动画
|
||||
export const toggleDark = (isDark: any) => {
|
||||
provide('toggle-appearance', async ({ clientX: x, clientY: y }: MouseEvent) => {
|
||||
//如果不支持动效直接切换
|
||||
if (!enableTransitions()) {
|
||||
isDark.value = !isDark.value
|
||||
return
|
||||
}
|
||||
document.documentElement.style.setProperty('--darkX', x + 'px')
|
||||
document.documentElement.style.setProperty('--darkY', y + 'px')
|
||||
// 原生的视图转换动画 https://developer.mozilla.org/zh-CN/docs/Web/API/Document/startViewTransition
|
||||
// pnpm add -D @types/dom-view-transitions 解决 document.startViewTransition 类型错误的问题
|
||||
await document.startViewTransition(async () => {
|
||||
isDark.value = !isDark.value
|
||||
await nextTick()
|
||||
}).ready
|
||||
})
|
||||
}
|
||||
13
docs/.vitepress/theme/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import './style.css'
|
||||
import './dark.css'
|
||||
import MyLayout from './MyLayout.vue'
|
||||
// history.scrollRestoration = 'manual'
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
Layout: MyLayout,
|
||||
enhanceApp({ app, router, siteData }) {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
BIN
docs/.vitepress/theme/phycat/Cascadia-Code-Regular.ttf
Normal file
BIN
docs/.vitepress/theme/phycat/HarmonyOS_Sans_SC_Bold.woff
Normal file
BIN
docs/.vitepress/theme/phycat/HarmonyOS_Sans_SC_Regular.woff
Normal file
1176
docs/.vitepress/theme/phycat/phycat.dark.css
Normal file
1213
docs/.vitepress/theme/phycat/phycat.light.css
Normal file
266
docs/.vitepress/theme/style.css
Normal file
@@ -0,0 +1,266 @@
|
||||
@import url(./phycat/phycat.light.css);
|
||||
@import url(./phycat/phycat.dark.css);
|
||||
/**
|
||||
* Customize default theme styling by overriding CSS variables:
|
||||
* https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css
|
||||
*/
|
||||
|
||||
/**
|
||||
* Colors
|
||||
*
|
||||
* Each colors have exact same color scale system with 3 levels of solid
|
||||
* colors with different brightness, and 1 soft color.
|
||||
*
|
||||
* - `XXX-1`: The most solid color used mainly for colored text. It must
|
||||
* satisfy the contrast ratio against when used on top of `XXX-soft`.
|
||||
*
|
||||
* - `XXX-2`: The color used mainly for hover state of the button.
|
||||
*
|
||||
* - `XXX-3`: The color for solid background, such as bg color of the button.
|
||||
* It must satisfy the contrast ratio with pure white (#ffffff) text on
|
||||
* top of it.
|
||||
*
|
||||
* - `XXX-soft`: The color used for subtle background such as custom container
|
||||
* or badges. It must satisfy the contrast ratio when putting `XXX-1` colors
|
||||
* on top of it.
|
||||
*
|
||||
* The soft color must be semi transparent alpha channel. This is crucial
|
||||
* because it allows adding multiple "soft" colors on top of each other
|
||||
* to create an accent, such as when having inline code block inside
|
||||
* custom containers.
|
||||
*
|
||||
* - `default`: The color used purely for subtle indication without any
|
||||
* special meanings attached to it such as bg color for menu hover state.
|
||||
*
|
||||
* - `brand`: Used for primary brand colors, such as link text, button with
|
||||
* brand theme, etc.
|
||||
*
|
||||
* - `tip`: Used to indicate useful information. The default theme uses the
|
||||
* brand color for this by default.
|
||||
*
|
||||
* - `warning`: Used to indicate warning to the users. Used in custom
|
||||
* container, badges, etc.
|
||||
*
|
||||
* - `danger`: Used to show error, or dangerous message to the users. Used
|
||||
* in custom container, badges, etc.
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
html.dark #app {
|
||||
--vp-nav-bg-color: #000000a7 !important;
|
||||
}
|
||||
.VPNavBar:not(.VPNavBar.top) {
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--vp-c-indigo-1: #14a5c2;
|
||||
--vp-c-indigo-2: #21b1ce;
|
||||
--vp-c-indigo-3: #4fcbe4;
|
||||
--vp-nav-bg-color: #ffffffa7;
|
||||
--vp-c-default-1: var(--vp-c-gray-1);
|
||||
--vp-c-default-2: var(--vp-c-gray-2);
|
||||
--vp-c-default-3: var(--vp-c-gray-3);
|
||||
--vp-c-default-soft: var(--vp-c-gray-soft);
|
||||
|
||||
--vp-c-brand-1: var(--vp-c-indigo-1);
|
||||
--vp-c-brand-2: var(--vp-c-indigo-2);
|
||||
--vp-c-brand-3: var(--vp-c-indigo-3);
|
||||
--vp-c-brand-soft: var(--vp-c-indigo-soft);
|
||||
|
||||
--vp-c-tip-1: var(--vp-c-brand-1);
|
||||
--vp-c-tip-2: var(--vp-c-brand-2);
|
||||
--vp-c-tip-3: var(--vp-c-brand-3);
|
||||
--vp-c-tip-soft: var(--vp-c-brand-soft);
|
||||
|
||||
--vp-c-warning-1: var(--vp-c-yellow-1);
|
||||
--vp-c-warning-2: var(--vp-c-yellow-2);
|
||||
--vp-c-warning-3: var(--vp-c-yellow-3);
|
||||
--vp-c-warning-soft: var(--vp-c-yellow-soft);
|
||||
|
||||
--vp-c-danger-1: var(--vp-c-red-1);
|
||||
--vp-c-danger-2: var(--vp-c-red-2);
|
||||
--vp-c-danger-3: var(--vp-c-red-3);
|
||||
--vp-c-danger-soft: var(--vp-c-red-soft);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Button
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--vp-button-brand-border: transparent;
|
||||
--vp-button-brand-text: var(--vp-c-white);
|
||||
--vp-button-brand-bg: var(--vp-c-brand-3);
|
||||
--vp-button-brand-hover-border: transparent;
|
||||
--vp-button-brand-hover-text: var(--vp-c-white);
|
||||
--vp-button-brand-hover-bg: var(--vp-c-brand-2);
|
||||
--vp-button-brand-active-border: transparent;
|
||||
--vp-button-brand-active-text: var(--vp-c-white);
|
||||
--vp-button-brand-active-bg: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Home
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--vp-home-hero-name-color: transparent;
|
||||
--vp-home-hero-name-background: -webkit-linear-gradient(120deg, #5dd6cc 30%, #b8f1cc);
|
||||
|
||||
--vp-home-hero-image-background-image: linear-gradient(-45deg, #b8f1cf 50%, #47caff 50%);
|
||||
--vp-home-hero-image-filter: blur(44px);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
:root {
|
||||
--vp-home-hero-image-filter: blur(56px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
:root {
|
||||
--vp-home-hero-image-filter: blur(68px);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Custom Block
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--vp-custom-block-tip-border: transparent;
|
||||
--vp-custom-block-tip-text: var(--vp-c-text-1);
|
||||
--vp-custom-block-tip-bg: var(--vp-c-brand-soft);
|
||||
--vp-custom-block-tip-code-bg: var(--vp-c-brand-soft);
|
||||
}
|
||||
/**
|
||||
* Component: Algolia
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
.DocSearch {
|
||||
--docsearch-primary-color: var(--vp-c-brand-1) !important;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* 标题后小图标,借鉴自思源笔记主题——Savor */
|
||||
--h1-r-graphic: url("data:image/svg+xml;utf8,<svg fill='rgba(74, 200, 141, 0.5)' height='24' viewBox='0 0 32 32' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M4.8 29.714v0c-1.371 0-2.514-1.143-2.514-2.514v0c0-1.371 1.143-2.514 2.514-2.514v0c1.371 0 2.514 1.143 2.514 2.514v0c0.114 1.371-1.029 2.514-2.514 2.514z'/></svg>")
|
||||
no-repeat center;
|
||||
--h2-r-graphic: url("data:image/svg+xml;utf8,<svg fill='rgba(74, 200, 141, 0.5)' height='24' viewBox='0 0 32 32' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M11.429 25.143c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM4.571 18.286c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286z'/></svg>")
|
||||
no-repeat center;
|
||||
--h3-r-graphic: url("data:image/svg+xml;utf8,<svg fill='rgba(74, 200, 141, 0.5)' height='28' viewBox='0 0 32 32' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M4.571 25.143c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM4.571 18.286c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM11.429 25.143c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286z'/></svg>")
|
||||
no-repeat center;
|
||||
--h4-r-graphic: url("data:image/svg+xml;utf8,<svg fill='rgba(74, 200, 141, 0.5)' height='24' viewBox='0 0 32 32' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M4.571 25.143c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM4.571 18.286c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM11.429 25.143c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM11.429 22.857c1.257 0 2.286-1.029 2.286-2.286s-1.029-2.286-2.286-2.286-2.286 1.029-2.286 2.286 1.029 2.286 2.286 2.286z'/></svg>")
|
||||
no-repeat center;
|
||||
--h5-r-graphic: url("data:image/svg+xml;utf8,<svg fill='rgba(74, 200, 141, 0.5)' height='24' viewBox='0 0 32 32' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M4.571 18.286c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM11.429 22.857c1.257 0 2.286-1.029 2.286-2.286s-1.029-2.286-2.286-2.286-2.286 1.029-2.286 2.286 1.029 2.286 2.286 2.286zM4.571 25.143c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM11.429 25.143c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM4.571 11.429c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286z'/></svg>")
|
||||
no-repeat center;
|
||||
--h6-r-graphic: url("data:image/svg+xml;utf8,<svg fill='rgba(74, 200, 141, 0.5)' height='24' viewBox='0 0 32 32' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M4.571 25.143c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM4.571 18.286c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM4.571 11.429c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM11.429 18.286c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM11.429 25.143c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM11.429 16c1.257 0 2.286-1.029 2.286-2.286s-1.029-2.286-2.286-2.286-2.286 1.029-2.286 2.286 1.029 2.286 2.286 2.286z'/></svg>")
|
||||
no-repeat center;
|
||||
|
||||
/* 是否开启网格背景?1 是;0 否 */
|
||||
--bg-grid: 0;
|
||||
|
||||
/* 已完成的代办事项是否显示删除线?1 是;0 否 */
|
||||
--check-line: 1;
|
||||
|
||||
/* 自动编号格式设置 无需自动编号可全部注释掉或部分注释掉*/
|
||||
/* --autonum-h1: counter(h1) ". ";
|
||||
--autonum-h2: counter(h1) "." counter(h2) ". ";
|
||||
--autonum-h3: counter(h1) "." counter(h2) "." counter(h3) ". ";
|
||||
--autonum-h4: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) ". ";
|
||||
--autonum-h5: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) ". ";
|
||||
--autonum-h6: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) "." counter(h6) ". "; */
|
||||
|
||||
/* 下面是文章内Toc目录自动编号,与上面一样即可 */
|
||||
/* --autonum-h1toc: counter(h1toc) ". ";
|
||||
--autonum-h2toc: counter(h1toc) "." counter(h2toc) ". ";
|
||||
--autonum-h3toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) ". ";
|
||||
--autonum-h4toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) ". ";
|
||||
--autonum-h5toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) ". ";
|
||||
--autonum-h6toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) "." counter(h6toc) ". "; */
|
||||
|
||||
/* 主题颜色 */
|
||||
|
||||
--head-title-color: #3db8bf;
|
||||
/* 标题主色 */
|
||||
--head-title-h2-color: #fff;
|
||||
--head-title-h2-background: linear-gradient(to right, #3db8d3, #80f7c4);
|
||||
/* 二级标题主色,因为二级标题是背景色的,所以单独设置 */
|
||||
|
||||
--element-color: #3db8bf;
|
||||
/* 元素主色 */
|
||||
--element-color-deep: #089ba3;
|
||||
/* 元素深色 */
|
||||
--element-color-shallow: #7aeaf0;
|
||||
/* 元素浅色 */
|
||||
--element-color-so-shallow: #7aeaf077;
|
||||
/* 元素很浅色 */
|
||||
--element-color-soo-shallow: #7aeaf018;
|
||||
/* 元素非常浅色 */
|
||||
|
||||
--element-color-linecode: #089ba3;
|
||||
/* 行内代码文字色 */
|
||||
--element-color-linecode-background: #7aeaf018;
|
||||
/* 行内代码背景色 */
|
||||
|
||||
/* 程序本体UI */
|
||||
--appui-color: #3db8bf;
|
||||
/* 程序UI主题色 */
|
||||
--appui-color-icon: #3db8bf;
|
||||
/* 程序UI图标颜色 */
|
||||
--appui-color-text: #333;
|
||||
/* 程序UI文字色 */
|
||||
--primary-color: #3db8bf;
|
||||
}
|
||||
* {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
/**
|
||||
* 黑暗模式切换动画
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
#VPContent .vp-doc > div {
|
||||
animation:
|
||||
rises 1s,
|
||||
looming 1s;
|
||||
}
|
||||
|
||||
@keyframes rises {
|
||||
0% {
|
||||
transform: translateY(50px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes looming {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
li {
|
||||
position: relative;
|
||||
}
|
||||
.vp-doc li div[class*='language-'] {
|
||||
margin: 12px;
|
||||
}
|
||||
html.dark .vp-doc div[class*='language-'],
|
||||
html.dark .vp-doc div[class*='language-'] pre {
|
||||
background-color: #222222;
|
||||
}
|
||||
html .vp-doc div[class*='language-'],
|
||||
html .vp-doc div[class*='language-'] pre {
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
#app div[class^='language'] {
|
||||
border-radius: 1em;
|
||||
overflow: hidden;
|
||||
padding: 0.4em;
|
||||
}
|
||||
142
docs/alist-config.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Alist 下载配置说明
|
||||
|
||||
## 概述
|
||||
|
||||
项目已从 GitHub 下载方式切换到 Alist API 下载方式,包括:
|
||||
|
||||
- 桌面应用的自动更新功能 (`src/main/autoUpdate.ts`)
|
||||
- 官方网站的下载功能 (`website/script.js`)
|
||||
|
||||
## 配置步骤
|
||||
|
||||
### 1. 修改 Alist 域名
|
||||
|
||||
#### 桌面应用配置
|
||||
|
||||
在 `src/main/autoUpdate.ts` 文件中,Alist 域名已配置为:
|
||||
|
||||
```typescript
|
||||
const ALIST_BASE_URL = 'http://47.96.72.224:5244'
|
||||
```
|
||||
|
||||
#### 网站配置
|
||||
|
||||
在 `website/script.js` 文件中,Alist 域名已配置为:
|
||||
|
||||
```javascript
|
||||
const ALIST_BASE_URL = 'http://47.96.72.224:5244'
|
||||
```
|
||||
|
||||
如需修改域名,请同时更新这两个文件中的 `ALIST_BASE_URL` 配置。
|
||||
|
||||
### 2. 认证信息
|
||||
|
||||
已配置的认证信息:
|
||||
|
||||
- 用户名: `ceruupdata`
|
||||
- 密码: `123456`
|
||||
|
||||
### 3. 文件路径格式
|
||||
|
||||
文件在 Alist 中的路径格式为:`/{version}/{文件名}`
|
||||
|
||||
例如:
|
||||
|
||||
- 版本 `v1.0.0` 的安装包 `app-setup.exe` 路径为:`/v1.0.0/app-setup.exe`
|
||||
|
||||
## 工作原理
|
||||
|
||||
### 桌面应用自动更新
|
||||
|
||||
1. **认证**: 使用配置的用户名和密码向 Alist API 获取认证 token
|
||||
2. **获取文件信息**: 使用 token 调用 `/api/fs/get` 接口获取文件信息和签名
|
||||
3. **下载**: 使用带签名的直接下载链接下载文件
|
||||
4. **备用方案**: 如果 Alist 失败,自动回退到原始 URL 下载
|
||||
|
||||
### 网站下载功能
|
||||
|
||||
1. **获取版本列表**: 调用 `/api/fs/list` 获取根目录下的版本文件夹
|
||||
2. **获取文件列表**: 获取最新版本文件夹中的所有文件
|
||||
3. **平台匹配**: 根据用户平台自动匹配对应的安装包文件
|
||||
4. **生成下载链接**: 获取文件的直接下载链接
|
||||
5. **备用方案**: 如果 Alist 失败,自动回退到 GitHub API
|
||||
|
||||
## API 接口
|
||||
|
||||
### 认证接口
|
||||
|
||||
```
|
||||
POST /api/auth/login
|
||||
{
|
||||
"username": "ceruupdata",
|
||||
"password": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
### 获取文件信息接口
|
||||
|
||||
```
|
||||
POST /api/fs/get
|
||||
Headers: Authorization: {token} # 注意:直接使用 token,不需要 "Bearer " 前缀
|
||||
{
|
||||
"path": "/{version}/{fileName}"
|
||||
}
|
||||
```
|
||||
|
||||
### 获取文件列表接口
|
||||
|
||||
```
|
||||
POST /api/fs/list
|
||||
Headers: Authorization: {token} # 注意:直接使用 token,不需要 "Bearer " 前缀
|
||||
{
|
||||
"path": "/",
|
||||
"password": "",
|
||||
"page": 1,
|
||||
"per_page": 100,
|
||||
"refresh": false
|
||||
}
|
||||
```
|
||||
|
||||
### 下载链接格式
|
||||
|
||||
```
|
||||
{ALIST_BASE_URL}/d/{filePath}?sign={sign}
|
||||
```
|
||||
|
||||
## 测试方法
|
||||
|
||||
项目包含了一个测试脚本来验证 Alist 连接:
|
||||
|
||||
```bash
|
||||
node scripts/test-alist.js
|
||||
```
|
||||
|
||||
该脚本会:
|
||||
|
||||
1. 测试服务器连通性
|
||||
2. 测试用户认证
|
||||
3. 测试文件列表获取
|
||||
4. 测试文件信息获取
|
||||
|
||||
## 备用机制
|
||||
|
||||
两个组件都实现了备用机制:
|
||||
|
||||
### 桌面应用
|
||||
|
||||
- 主要:使用 Alist API 下载
|
||||
- 备用:如果 Alist 失败,使用原始 URL 下载
|
||||
|
||||
### 网站
|
||||
|
||||
- 主要:使用 Alist API 获取版本和文件信息
|
||||
- 备用:如果 Alist 失败,回退到 GitHub API
|
||||
- 最终备用:跳转到 GitHub releases 页面
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 确保 Alist 服务器可以正常访问
|
||||
2. 确保配置的用户名和密码有权限访问相应的文件路径
|
||||
3. 文件必须按照指定的路径格式存放在 Alist 中
|
||||
4. 网站会自动检测用户操作系统并推荐对应的下载版本
|
||||
5. 所有下载都会显示文件大小信息
|
||||
99
docs/alist-migration-summary.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Alist 迁移完成总结
|
||||
|
||||
## 修改概述
|
||||
|
||||
项目已成功从 GitHub 下载方式迁移到 Alist API 下载方式。
|
||||
|
||||
## 修改的文件
|
||||
|
||||
### 1. 桌面应用自动更新 (`src/main/autoUpdate.ts`)
|
||||
|
||||
- ✅ 添加了 Alist API 配置
|
||||
- ✅ 实现了 Alist 认证功能
|
||||
- ✅ 实现了 Alist 文件下载功能
|
||||
- ✅ 添加了备用机制(Alist 失败时回退到原始 URL)
|
||||
- ✅ 修复了 Authorization 头格式(使用直接 token 而非 Bearer 格式)
|
||||
|
||||
### 2. 官方网站下载功能 (`website/script.js`)
|
||||
|
||||
- ✅ 添加了 Alist API 配置
|
||||
- ✅ 实现了 Alist 认证功能
|
||||
- ✅ 实现了版本列表获取功能
|
||||
- ✅ 实现了文件列表获取功能
|
||||
- ✅ 实现了平台文件匹配功能
|
||||
- ✅ 添加了多层备用机制(Alist → GitHub API → GitHub 页面)
|
||||
- ✅ 修复了 Authorization 头格式
|
||||
|
||||
### 3. 配置文档
|
||||
|
||||
- ✅ 创建了详细的配置说明 (`docs/alist-config.md`)
|
||||
- ✅ 创建了迁移总结文档 (`docs/alist-migration-summary.md`)
|
||||
|
||||
### 4. 测试脚本
|
||||
|
||||
- ✅ 创建了 Alist 连接测试脚本 (`scripts/test-alist.js`)
|
||||
- ✅ 创建了认证格式测试脚本 (`scripts/auth-test.js`)
|
||||
|
||||
## 配置信息
|
||||
|
||||
### Alist 服务器配置
|
||||
|
||||
- **服务器地址**: `http://47.96.72.224:5244`
|
||||
- **用户名**: `ceruupdate`
|
||||
- **密码**: `123456`
|
||||
- **文件路径格式**: `/{version}/{文件名}`
|
||||
|
||||
### Authorization 头格式
|
||||
|
||||
经过测试确认,正确的格式是:
|
||||
|
||||
```
|
||||
Authorization: {token}
|
||||
```
|
||||
|
||||
**注意**: 不需要 "Bearer " 前缀
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 桌面应用
|
||||
|
||||
1. **智能下载**: 优先使用 Alist API,失败时自动回退
|
||||
2. **进度显示**: 支持下载进度显示和节流
|
||||
3. **错误处理**: 完善的错误处理和日志记录
|
||||
|
||||
### 网站
|
||||
|
||||
1. **自动检测**: 自动检测用户操作系统并推荐对应版本
|
||||
2. **版本信息**: 自动获取最新版本信息和文件大小
|
||||
3. **多层备用**: Alist → GitHub API → GitHub 页面的三层备用机制
|
||||
4. **用户体验**: 加载状态、成功通知、错误提示
|
||||
|
||||
## 测试结果
|
||||
|
||||
✅ **Alist 连接测试**: 通过
|
||||
✅ **认证测试**: 通过
|
||||
✅ **文件列表获取**: 通过
|
||||
✅ **Authorization 头格式**: 已修复并验证
|
||||
|
||||
## 可用文件
|
||||
|
||||
测试显示 Alist 服务器当前包含以下文件:
|
||||
|
||||
- `v1.2.1/` (版本目录)
|
||||
- `1111`
|
||||
- `L3YxLjIuMS8tMS4yLjEtYXJtNjQtbWFjLnppcA==`
|
||||
- `file2.msi`
|
||||
- `file.msi`
|
||||
|
||||
## 后续维护
|
||||
|
||||
1. **添加新版本**: 在 Alist 中创建新的版本目录(如 `v1.2.2/`)
|
||||
2. **上传文件**: 将对应平台的安装包上传到版本目录中
|
||||
3. **文件命名**: 确保文件名包含平台标识(如 `windows`, `mac`, `linux` 等)
|
||||
|
||||
## 备注
|
||||
|
||||
- 所有修改都保持了向后兼容性
|
||||
- 实现了完善的错误处理和备用机制
|
||||
- 用户体验不会因为迁移而受到影响
|
||||
- 可以随时回退到 GitHub 下载方式
|
||||
BIN
docs/assets/head.jpg
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
docs/assets/image-20250827175023917.png
Normal file
|
After Width: | Height: | Size: 719 KiB |
BIN
docs/assets/image-20250827175109430.png
Normal file
|
After Width: | Height: | Size: 981 KiB |
BIN
docs/assets/image-20250827175356006.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
docs/assets/image-20250827175547444.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
docs/assets/image-20250827181535681.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
docs/assets/image-20250827181604432.png
Normal file
|
After Width: | Height: | Size: 528 KiB |
BIN
docs/assets/image-20250827181634134.png
Normal file
|
After Width: | Height: | Size: 528 KiB |
10
docs/assets/logo.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="1200" height="1200" viewBox="0 0 1200 1200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1200" height="1200" rx="379" fill="white"/>
|
||||
<path d="M957.362 204.197C728.535 260.695 763.039 192.264 634.41 175.368C451.817 151.501 504.125 315.925 504.125 315.925L630.545 673.497C591.211 654.805 544.287 643.928 494.188 643.928C353.275 643.928 239 729.467 239 834.964C239 940.567 353.137 1026 494.188 1026C635.1 1026 749.375 940.461 749.375 834.964C749.375 832.218 749.237 829.473 749.099 826.727C749.513 825.988 749.789 825.143 750.065 824.087C757.932 789.449 634.272 348.345 634.272 348.345C634.272 348.345 764.971 401.886 860.89 351.936C971.163 294.699 964.953 202.402 957.362 204.197Z" fill="url(#paint0_linear_4_16)" stroke="#29293A" stroke-opacity="0.23"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_4_16" x1="678.412" y1="-1151.29" x2="796.511" y2="832.071" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.572115" stop-color="#B8F1ED"/>
|
||||
<stop offset="0.9999" stop-color="#B8F1CC"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -1,225 +0,0 @@
|
||||
# 音频发布-订阅模式使用指南
|
||||
|
||||
## 概述
|
||||
|
||||
这个改进的发布-订阅模式解决了原有实现中无法单个删除订阅者的问题。新的实现提供了以下特性:
|
||||
|
||||
- ✅ 支持单个订阅者的精确取消
|
||||
- ✅ 自动生成唯一订阅ID
|
||||
- ✅ 类型安全的事件系统
|
||||
- ✅ 错误处理和日志记录
|
||||
- ✅ 内存泄漏防护
|
||||
|
||||
## 核心特性
|
||||
|
||||
### 1. 精确的订阅管理
|
||||
|
||||
每个订阅都会返回一个取消订阅函数,调用该函数即可精确取消对应的订阅:
|
||||
|
||||
```typescript
|
||||
// 订阅事件
|
||||
const unsubscribe = audioStore.subscribe('ended', () => {
|
||||
console.log('音频播放结束')
|
||||
})
|
||||
|
||||
// 取消订阅
|
||||
unsubscribe()
|
||||
```
|
||||
|
||||
### 2. 支持的事件类型
|
||||
|
||||
- `ended`: 音频播放结束
|
||||
- `seeked`: 音频拖拽完成
|
||||
- `timeupdate`: 音频时间更新
|
||||
- `play`: 音频开始播放
|
||||
- `pause`: 音频暂停播放
|
||||
|
||||
### 3. 类型安全
|
||||
|
||||
所有的事件类型和回调函数都有完整的TypeScript类型定义,确保编译时类型检查。
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 基础订阅
|
||||
|
||||
```typescript
|
||||
import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
||||
|
||||
const audioStore = ControlAudioStore()
|
||||
|
||||
// 订阅播放结束事件
|
||||
const unsubscribeEnded = audioStore.subscribe('ended', () => {
|
||||
console.log('音频播放结束了')
|
||||
})
|
||||
|
||||
// 订阅时间更新事件
|
||||
const unsubscribeTimeUpdate = audioStore.subscribe('timeupdate', () => {
|
||||
console.log('当前时间:', audioStore.Audio.currentTime)
|
||||
})
|
||||
```
|
||||
|
||||
### 在Vue组件中使用
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { inject, onMounted, onUnmounted } from 'vue'
|
||||
import type { AudioSubscribeMethod, UnsubscribeFunction } from '@renderer/types/audio'
|
||||
|
||||
// 注入订阅方法
|
||||
const audioSubscribe = inject<AudioSubscribeMethod>('audioSubscribe')
|
||||
|
||||
// 存储取消订阅函数
|
||||
const unsubscribeFunctions: UnsubscribeFunction[] = []
|
||||
|
||||
onMounted(() => {
|
||||
if (!audioSubscribe) return
|
||||
|
||||
// 订阅多个事件
|
||||
unsubscribeFunctions.push(
|
||||
audioSubscribe('play', () => console.log('开始播放')),
|
||||
audioSubscribe('pause', () => console.log('暂停播放')),
|
||||
audioSubscribe('ended', () => console.log('播放结束'))
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 组件卸载时取消所有订阅
|
||||
unsubscribeFunctions.forEach((unsubscribe) => unsubscribe())
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### 条件订阅和取消
|
||||
|
||||
```typescript
|
||||
let endedUnsubscribe: UnsubscribeFunction | null = null
|
||||
|
||||
// 条件订阅
|
||||
const subscribeToEnded = () => {
|
||||
if (!endedUnsubscribe) {
|
||||
endedUnsubscribe = audioStore.subscribe('ended', handleAudioEnded)
|
||||
}
|
||||
}
|
||||
|
||||
// 条件取消订阅
|
||||
const unsubscribeFromEnded = () => {
|
||||
if (endedUnsubscribe) {
|
||||
endedUnsubscribe()
|
||||
endedUnsubscribe = null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 高级功能
|
||||
|
||||
### 批量管理订阅
|
||||
|
||||
```typescript
|
||||
// 清空特定事件的所有订阅者
|
||||
audioStore.clearEventSubscribers('ended')
|
||||
|
||||
// 清空所有事件的所有订阅者
|
||||
audioStore.clearAllSubscribers()
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
|
||||
系统内置了错误处理机制,如果某个回调函数执行出错,不会影响其他订阅者:
|
||||
|
||||
```typescript
|
||||
audioStore.subscribe('ended', () => {
|
||||
throw new Error('这个错误不会影响其他订阅者')
|
||||
})
|
||||
|
||||
audioStore.subscribe('ended', () => {
|
||||
console.log('这个回调仍然会正常执行')
|
||||
})
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 及时清理订阅
|
||||
|
||||
```typescript
|
||||
// ✅ 好的做法:组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
unsubscribeFunctions.forEach((unsubscribe) => unsubscribe())
|
||||
})
|
||||
|
||||
// ❌ 不好的做法:忘记清理,可能导致内存泄漏
|
||||
```
|
||||
|
||||
### 2. 使用数组管理多个订阅
|
||||
|
||||
```typescript
|
||||
// ✅ 好的做法:统一管理
|
||||
const unsubscribeFunctions: UnsubscribeFunction[] = []
|
||||
|
||||
unsubscribeFunctions.push(
|
||||
audioStore.subscribe('play', handlePlay),
|
||||
audioStore.subscribe('pause', handlePause)
|
||||
)
|
||||
|
||||
// 统一清理
|
||||
unsubscribeFunctions.forEach((fn) => fn())
|
||||
```
|
||||
|
||||
### 3. 避免在高频事件中执行重操作
|
||||
|
||||
```typescript
|
||||
// ❌ 不好的做法:在timeupdate中执行重操作
|
||||
audioStore.subscribe('timeupdate', () => {
|
||||
// 这会每秒执行多次,影响性能
|
||||
updateComplexUI()
|
||||
})
|
||||
|
||||
// ✅ 好的做法:使用节流或防抖
|
||||
let lastUpdate = 0
|
||||
audioStore.subscribe('timeupdate', () => {
|
||||
const now = Date.now()
|
||||
if (now - lastUpdate > 100) {
|
||||
// 限制更新频率
|
||||
updateUI()
|
||||
lastUpdate = now
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### 从旧版本迁移
|
||||
|
||||
旧版本:
|
||||
|
||||
```typescript
|
||||
// 旧的实现方式
|
||||
provide('setAudioEnd', setEndCallback)
|
||||
|
||||
function setEndCallback(fn: Function): void {
|
||||
endCallback.push(fn)
|
||||
}
|
||||
```
|
||||
|
||||
新版本:
|
||||
|
||||
```typescript
|
||||
// 新的实现方式
|
||||
provide('audioSubscribe', audioStore.subscribe)
|
||||
|
||||
// 使用时
|
||||
const unsubscribe = audioSubscribe('ended', () => {
|
||||
// 处理播放结束
|
||||
})
|
||||
|
||||
// 可以精确取消
|
||||
unsubscribe()
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
1. **避免重复订阅**:在订阅前检查是否已经订阅
|
||||
2. **及时取消订阅**:组件卸载或不再需要时立即取消
|
||||
3. **合理使用事件**:避免在高频事件中执行重操作
|
||||
4. **批量操作**:需要清理多个订阅时使用批量清理方法
|
||||
|
||||
这个改进的发布-订阅模式为Ceru Music应用提供了更加灵活和可靠的音频事件管理机制。
|
||||
@@ -1,121 +0,0 @@
|
||||
# 自动更新功能说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
本项目集成了完整的自动更新功能,使用 Electron 的 `autoUpdater` 模块和 TDesign 的通知组件,为用户提供友好的更新体验。
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 主进程 (Main Process)
|
||||
|
||||
1. **autoUpdate.ts** - 自动更新核心逻辑
|
||||
- 配置更新服务器地址
|
||||
- 监听 autoUpdater 事件
|
||||
- 通过 IPC 向渲染进程发送更新消息
|
||||
|
||||
2. **events/autoUpdate.ts** - IPC 事件处理
|
||||
- 注册检查更新和安装更新的 IPC 处理器
|
||||
|
||||
### 渲染进程 (Renderer Process)
|
||||
|
||||
1. **services/autoUpdateService.ts** - 更新服务
|
||||
- 处理来自主进程的更新消息
|
||||
- 使用 TDesign Notification 显示更新通知
|
||||
- 管理更新状态和用户交互
|
||||
|
||||
2. **composables/useAutoUpdate.ts** - Vue 组合式函数
|
||||
- 封装自动更新功能,便于在组件中使用
|
||||
- 管理监听器的生命周期
|
||||
|
||||
3. **components/Settings/UpdateSettings.vue** - 更新设置组件
|
||||
- 提供手动检查更新的界面
|
||||
- 显示当前版本信息
|
||||
|
||||
## 更新流程
|
||||
|
||||
1. **启动检查**: 应用启动后延迟3秒自动检查更新
|
||||
2. **检查更新**: 向更新服务器发送请求检查新版本
|
||||
3. **下载更新**: 如有新版本,自动下载更新包
|
||||
4. **安装提示**: 下载完成后提示用户重启安装
|
||||
5. **自动安装**: 用户确认后退出应用并安装更新
|
||||
|
||||
## 通知类型
|
||||
|
||||
- **检查更新**: 显示正在检查更新的信息通知
|
||||
- **发现新版本**: 显示发现新版本并开始下载的成功通知
|
||||
- **无需更新**: 显示当前已是最新版本的信息通知
|
||||
- **下载进度**: 实时显示下载进度和速度
|
||||
- **下载完成**: 显示下载完成并提供重启按钮
|
||||
- **更新错误**: 显示更新过程中的错误信息
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 更新服务器配置
|
||||
|
||||
在 `src/main/autoUpdate.ts` 中配置更新服务器地址:
|
||||
|
||||
```typescript
|
||||
const server = 'https://update.ceru.shiqianjiang.cn/';
|
||||
```
|
||||
|
||||
### 版本检查
|
||||
|
||||
更新服务器需要提供以下格式的 API:
|
||||
- URL: `${server}/update/${platform}/${currentVersion}`
|
||||
- 返回: 更新信息 JSON
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 在组件中使用
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useAutoUpdate } from '@/composables/useAutoUpdate'
|
||||
|
||||
const { checkForUpdates } = useAutoUpdate()
|
||||
|
||||
// 手动检查更新
|
||||
const handleCheckUpdate = async () => {
|
||||
await checkForUpdates()
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 监听更新消息
|
||||
|
||||
```typescript
|
||||
import { autoUpdateService } from '@/services/autoUpdateService'
|
||||
|
||||
// 开始监听
|
||||
autoUpdateService.startListening()
|
||||
|
||||
// 停止监听
|
||||
autoUpdateService.stopListening()
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **权限要求**: 自动更新需要应用具有写入权限
|
||||
2. **网络连接**: 需要稳定的网络连接来下载更新
|
||||
3. **用户体验**: 更新过程中避免强制重启,给用户选择权
|
||||
4. **错误处理**: 妥善处理网络错误和下载失败的情况
|
||||
|
||||
## 开发调试
|
||||
|
||||
在开发环境中,可以通过以下方式测试自动更新:
|
||||
|
||||
1. 修改 `package.json` 中的版本号
|
||||
2. 在更新设置页面手动触发检查更新
|
||||
3. 观察控制台日志和通知显示
|
||||
|
||||
## 构建配置
|
||||
|
||||
确保在 `electron-builder` 配置中启用自动更新:
|
||||
|
||||
```json
|
||||
{
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "https://update.ceru.shiqianjiang.cn/"
|
||||
}
|
||||
}
|
||||
1587
docs/design.html
@@ -1,3 +1,7 @@
|
||||
---
|
||||
layout: doc
|
||||
---
|
||||
|
||||
# CeruMusic 插件开发文档
|
||||
|
||||
## 概述
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
layout: doc
|
||||
---
|
||||
|
||||
# CeruMusicPluginHost 使用文档
|
||||
|
||||
## 概述
|
||||
@@ -109,9 +113,7 @@ try {
|
||||
|
||||
### 构造函数
|
||||
|
||||
```javascript
|
||||
new CeruMusicPluginHost(pluginCode?)
|
||||
```
|
||||
`new CeruMusicPluginHost(pluginCode)`
|
||||
|
||||
**参数:**
|
||||
|
||||
@@ -125,9 +127,9 @@ new CeruMusicPluginHost(pluginCode?)
|
||||
|
||||
**参数:**
|
||||
|
||||
- `pluginPath` (string): 插件文件路径
|
||||
`pluginPath` (string): 插件文件路径
|
||||
|
||||
**返回:** Promise<Object> - 插件导出的对象
|
||||
**返回:** `Promise<Object>` - 插件导出的对象
|
||||
|
||||
#### getPluginInfo()
|
||||
|
||||
@@ -151,7 +153,7 @@ new CeruMusicPluginHost(pluginCode?)
|
||||
- `musicInfo` (Object): 歌曲信息对象
|
||||
- `quality` (string): 音质标识
|
||||
|
||||
**返回:** Promise<string> - 音乐播放链接
|
||||
**返回:** `Promise<string>` - 音乐播放链接
|
||||
|
||||
#### getPic(source, musicInfo)
|
||||
|
||||
@@ -162,7 +164,7 @@ new CeruMusicPluginHost(pluginCode?)
|
||||
- `source` (string): 音源标识
|
||||
- `musicInfo` (Object): 歌曲信息对象
|
||||
|
||||
**返回:** Promise<string> - 封面链接
|
||||
**返回:** `Promise<string>` - 封面链接
|
||||
|
||||
#### getLyric(source, musicInfo)
|
||||
|
||||
@@ -173,7 +175,7 @@ new CeruMusicPluginHost(pluginCode?)
|
||||
- `source` (string): 音源标识
|
||||
- `musicInfo` (Object): 歌曲信息对象
|
||||
|
||||
**返回:** Promise<string> - 歌词内容
|
||||
**返回:** `Promise<string>` - 歌词内容
|
||||
|
||||
## 插件环境
|
||||
|
||||
@@ -11,7 +11,7 @@ Ceru Music 是一个基于 Electron + Vue 3 的跨平台桌面音乐播放器,
|
||||
- **前端框架**: Vue 3 + TypeScript + Composition API
|
||||
- **桌面框架**: Electron (v37.2.3)
|
||||
- **UI组件库**: TDesign Vue Next (v1.15.2)
|
||||
- 
|
||||
- 
|
||||
- **状态管理**: Pinia (v3.0.3)
|
||||
- **路由管理**: Vue Router (v4.5.1)
|
||||
- **构建工具**: Vite + electron-vite
|
||||
@@ -397,7 +397,7 @@ export const useAppStore = defineStore('app', {
|
||||
|
||||
### 欢迎页面设计
|
||||
|
||||

|
||||

|
||||
|
||||
```vue
|
||||
<template>
|
||||
@@ -456,7 +456,7 @@ function skipWelcome() {
|
||||
|
||||
##### 界面UI参考
|
||||
|
||||
下载安装使用
|
||||
|
||||
### Window 安装
|
||||
|
||||
由于没有证书原因 **`Window`** 平台可能会出现安装包体误报**危险**。请放心我们的软件都是**开源**在 `Github` 自动化打包的。**具体安装步骤如下**
|
||||
|
||||
<img src="../assets/image-20250826214921963.png" alt="image-20250826214921963" style="zoom: 50%;" />如果出现类似图例效果请先点击 **右侧 三个点**
|
||||
|
||||
<img src="../assets/image-20250826215101522.png" alt="image-20250826215101522" style="zoom:50%;" />**点击保留**
|
||||
|
||||
<img src="../assets/image-20250826215206862.png" alt="image-20250826215206862" style="zoom:50%;" />**点击下拉按钮**
|
||||
|
||||
<img src="../assets/image-20250826215251525.png" alt="image-20250826215251525" style="zoom:50%;" />**任然保留**就可以双击打开安装到此教程结束
|
||||
|
||||
### Mac OS 系统下载安装
|
||||
|
||||
由于同样没有**签名**的原因mac的护栏也会拦截提示安装包损坏
|
||||
|
||||
<img src="../assets/3f50d3b838287b4bf1523d0f955fdf37.png" alt="3f50d3b838287b4bf1523d0f955fdf37" style="zoom:50%;" />请不用担心这是典型的签名问题
|
||||
|
||||
适用于 macOS 14 Sonoma 及以上版本。
|
||||
|
||||
注意:由于我们不提供经过签名的程序包体,因此在安装后首次运行可能会出现 “**澜音** 已损坏” 之类的提示,此时只需打开终端,输入命令
|
||||
|
||||
```bash
|
||||
sudo xattr -r -d com.apple.quarantine /Applications/澜音.app
|
||||
```
|
||||
|
||||
并回车,输入密码再次回车,重新尝试启动程序即可
|
||||
|
||||
_要是还有问题可自行在搜索引擎查询由于 。`apple`官方证书需要99刀的价格实在无能为力见谅_ 如果你有能力成为`澜音`的赞助者可联系
|
||||
|
||||
- QQ:`2115295703`
|
||||
- 微信:`cl_wj0623`
|
||||
|
||||
### 插件安装
|
||||
|
||||
首次进入应用需要在软件右上角设置导入**音源**才能使用可查询`Ceru插件`**(目前生态欠缺)** 或现成的**落雪**插件导入使用
|
||||
|
||||
###### 导入完成点击使用
|
||||
203
docs/index.md
Normal file
@@ -0,0 +1,203 @@
|
||||
---
|
||||
# https://vitepress.dev/reference/default-theme-home-page
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: 'Ceru Music'
|
||||
text: '澜音 播放器'
|
||||
tagline: 澜音是一个跨平台的音乐播放器应用,支持基于合规插件获取公开音乐信息与播放功能。
|
||||
image:
|
||||
src: '/logo.svg'
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 下载应用
|
||||
link: https://ceru.shiqianjiang.cn/#download
|
||||
target: _blank
|
||||
- theme: alt
|
||||
text: 使用文档
|
||||
link: /guide/
|
||||
|
||||
features:
|
||||
- title: 多平台支持
|
||||
icon: 🚀
|
||||
details: 支持网易云音乐、QQ音乐等多个平台,搜索
|
||||
- title: 跨平台支持
|
||||
icon: 🪟
|
||||
details: 原生桌面应用,支持 Windows、macOS、Linux 三大操作系统
|
||||
- title: 歌词显示
|
||||
icon: 🎼
|
||||
details: 实时歌词显示,支持专辑信息获取,让音乐体验更丰富
|
||||
- title: 优雅界面
|
||||
icon: 💻
|
||||
details: 现代化设计语言,流畅动画效果,为你带来愉悦的视觉体验
|
||||
- title: 代码开源
|
||||
details: 代码完全开源,供给大家使用开发
|
||||
icon: 👐
|
||||
link: https://github.com/timeshiftsauce/CeruMusic
|
||||
---
|
||||
|
||||
<div style="margin-top:4rem"></div>
|
||||
|
||||
# Ceru Music(澜音)
|
||||
|
||||
一个跨平台的音乐播放器应用,支持基于合规插件获取公开音乐信息与播放功能。
|
||||
|
||||
## 项目简介
|
||||
|
||||
Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,**仅提供插件运行框架与播放功能**,不直接存储、提供任何音乐源文件。用户需通过自行选择、安装合规插件获取音乐相关数据,项目旨在为开发者提供桌面应用技术实践与学习案例,为用户提供合规的音乐播放工具框架。
|
||||
|
||||
<img src="./assets/image-20250827175023917.png" alt="image-20250827175023917" style="zoom: 33%;" /><img src="./assets/image-20250827175109430.png" alt="image-20250827175109430" style="zoom:33%;" />
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Electron**:用于构建跨平台桌面应用
|
||||
- **Vue 3**:前端框架,提供响应式 UI
|
||||
- **TypeScript**:增强代码可维护性和类型安全
|
||||
- **Pinia**:状态管理工具
|
||||
- **Vite**:快速的前端构建工具
|
||||
- **CeruPlugins**:音乐插件运行环境(仅提供框架,不包含默认插件)
|
||||
- **AMLL**:音乐生态辅助模块
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 提供插件加载与管理功能,支持通过合规插件获取公开音乐信息
|
||||
- 支持通过插件获取歌词、专辑封面等公开元数据
|
||||
- 支持虚拟滚动列表,优化大量数据渲染性能
|
||||
- 本地播放列表管理(仅存储用户手动创建的列表结构,不包含音乐文件)
|
||||
- **提示**:本地数据仅保存在用户设备本地,未进行云端备份,用户需自行备份以防止数据丢失
|
||||
- 精美的用户界面与动画效果
|
||||
- **插件生态框架**(插件需用户自行获取并确保合规性)
|
||||
|
||||
## 安装与使用
|
||||
|
||||
### 推荐开发环境
|
||||
|
||||
- **IDE**: VS Code 或 WebStorm
|
||||
- **Node.js 版本**: 22 及以上
|
||||
- **包管理器**: **yarn**
|
||||
|
||||
### 项目设置
|
||||
|
||||
1. 安装依赖:
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
2. 启动开发服务器:
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
3. 构建应用:
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
```
|
||||
|
||||
### 平台构建指令
|
||||
|
||||
- Windows
|
||||
|
||||
```bash
|
||||
yarn build:win
|
||||
```
|
||||
|
||||
- macOS
|
||||
|
||||
```bash
|
||||
yarn build:mac
|
||||
```
|
||||
|
||||
- Linux
|
||||
|
||||
```bash
|
||||
yarn build:linux
|
||||
```
|
||||
|
||||
> 提示:构建后的应用仅包含播放器框架,需用户自行配置合规插件方可获取音乐数据。
|
||||
|
||||
## 文档与资源
|
||||
|
||||
- [产品设计文档](./guide/design):涵盖项目架构、核心功能设计和开发规范(不含任何音乐数据源信息)。
|
||||
- [插件开发文档](./guide/CeruMusicPluginDev):仅提供插件开发技术规范,**明确要求插件开发者需遵守数据来源平台的用户协议与版权法**,禁止开发、传播获取非公开数据的插件。
|
||||
|
||||
## 开源许可
|
||||
|
||||
本项目源代码遵循 **Apache License 2.0**,仅授权用户对项目框架进行学习、修改与二次开发,不包含任何音乐数据相关授权。详情请参阅 [LICENSE](https://github.com/timeshiftsauce/CeruMusic/blob/main/LICENSE) 文件,使用前请务必阅读许可条款。
|
||||
|
||||
## 贡献指南
|
||||
|
||||
欢迎开发者贡献代码与反馈建议,贡献内容需符合以下要求:
|
||||
|
||||
1. 仅涉及播放器框架功能优化、bug 修复、文档完善,不包含任何音乐数据源相关代码。
|
||||
2. 遵循 [Git 提交规范](./guide/design#git提交规范) 并确保代码符合项目风格指南。
|
||||
3. 贡献的代码需无第三方版权纠纷,且不违反开源许可协议。
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有技术问题或合作意向(仅限技术交流),请通过 Gitee 私信联系项目维护者。
|
||||
|
||||
## 项目开发者
|
||||
|
||||
- **时迁酱**:产品总体设计与开发
|
||||
|
||||
<img src="./assets/head.jpg" alt="head.jpg (940×940)" style="zoom:15%;" />
|
||||
|
||||
- **无聊的霜霜**:首页设计&Ai助手
|
||||
|
||||
<img src="./assets/image-20250827181604432.png" alt="image-20250827181604432" style="zoom:25%;" />
|
||||
|
||||
- **Star**:**插件管理**相关功能&部分接口封装
|
||||
|
||||
<img src="./assets/image-20250827181535681.png" alt="image-20250827181535681" style="zoom:25%;" />
|
||||
|
||||
**Tips**: 排名不分先后
|
||||
|
||||
# 法律声明与免责条款
|
||||
|
||||
**重要提示:使用本项目前,请务必仔细阅读本条款,使用本项目即视为你已充分理解并同意本条款全部内容。**
|
||||
|
||||
### 一、定义约定
|
||||
|
||||
- “Apache License 2.0”:指 Ceru Music(澜音)桌面播放器框架及源代码,不包含任何第三方插件或音乐数据。
|
||||
- “**用户**”:指下载、安装、使用本项目的个人或组织。
|
||||
- “**合规插件**”:指符合数据来源平台用户协议、不侵犯第三方版权、不获取非公开数据的插件。
|
||||
- “**版权内容**”:指包括但不限于音乐文件、歌词、专辑封面、艺人信息等受著作权法保护的内容。
|
||||
|
||||
### 二、数据与内容责任
|
||||
|
||||
1. 本项目**不直接获取、存储、传输任何音乐数据或版权内容**,仅提供插件运行框架。用户通过插件获取的所有数据,其合法性、准确性由插件提供者及用户**自行负责**,本项目不承担任何责任。
|
||||
2. 若用户使用的插件存在获取非公开数据、侵犯第三方版权等违规行为,相关法律责任由用户及插件提供者承担,与本项目无关。
|
||||
3. 本项目使用的字体、图片等素材,均来自开源社区或已获得合法授权,若存在侵权请联系项目维护者立即移除,本项目将积极配合处理。
|
||||
|
||||
### 三、版权合规要求
|
||||
|
||||
1. 用户承诺:使用本项目时,仅通过合规插件获取音乐相关信息,且获取、使用版权内容的行为符合**《中华人民共和国著作权法》**及相关法律法规,不侵犯**任何第三方**合法权益。
|
||||
2. 用户需知晓:任何未经授权下载、传播、使用受版权保护的音乐文件的行为,均可能构成侵权,需自行承担法律后果。
|
||||
3. 本项目倡导 “尊重版权、支持正版”,提醒用户通过官方音乐平台获取授权音乐服务。
|
||||
|
||||
### 四、免责声明
|
||||
|
||||
1. 因用户使用非合规插件、违反法律法规或第三方协议导致的任何法律责任(包括但不限于侵权赔偿、行政处罚),均由用户自行承担,本项目不承担任何直接、间接、连带或衍生责任。
|
||||
2. 因本项目框架本身的 **bug** 导致的用户设备故障、数据丢失,本项目仅承担在合理范围内的技术修复责任,不承担由此产生的间接损失(如商誉损失、业务中断损失等)。
|
||||
3. 本项目为开源学习项目,不提供商业服务,对用户使用本项目的效果不做任何明示或暗示的保证。
|
||||
|
||||
### 五、使用限制
|
||||
|
||||
1. 本项目仅允许用于**非商业、纯技术学习目的**,禁止用于任何商业运营、盈利活动,禁止修改后用于侵犯第三方权益的场景。
|
||||
2. 禁止在违反当地法律法规、本声明或第三方协议的前提下使用本项目,若用户所在地区禁止此类工具的使用,应立即停止使用。
|
||||
3. 禁止将本项目源代码或构建后的应用,与违规插件捆绑传播,禁止利用本项目从事任何违法违规活动。
|
||||
|
||||
### 六、其他
|
||||
|
||||
1. 本声明的效力、解释及适用,均适用中华人民共和国法律(不含港澳台地区法律)。
|
||||
2. 若用户与本项目维护者就本声明产生争议,应首先通过友好协商解决;协商不成的,任何一方均有权向本项目维护者所在地有管辖权的人民法院提起诉讼。
|
||||
|
||||
## 赞助
|
||||
|
||||
若您认可本项目的技术价值,欢迎通过以下方式支持开发者(仅用于项目技术维护与迭代):
|
||||
<img src="./assets/image-20250827175356006.png" alt="赞助方式1" style="zoom:33%;" /><img src="./assets/image-20250827175547444.png" alt="赞助方式2" style="zoom: 33%;" />
|
||||
|
||||
---
|
||||
10
docs/public/logo.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="1200" height="1200" viewBox="0 0 1200 1200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1200" height="1200" rx="379" fill="white"/>
|
||||
<path d="M957.362 204.197C728.535 260.695 763.039 192.264 634.41 175.368C451.817 151.501 504.125 315.925 504.125 315.925L630.545 673.497C591.211 654.805 544.287 643.928 494.188 643.928C353.275 643.928 239 729.467 239 834.964C239 940.567 353.137 1026 494.188 1026C635.1 1026 749.375 940.461 749.375 834.964C749.375 832.218 749.237 829.473 749.099 826.727C749.513 825.988 749.789 825.143 750.065 824.087C757.932 789.449 634.272 348.345 634.272 348.345C634.272 348.345 764.971 401.886 860.89 351.936C971.163 294.699 964.953 202.402 957.362 204.197Z" fill="url(#paint0_linear_4_16)" stroke="#29293A" stroke-opacity="0.23"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_4_16" x1="678.412" y1="-1151.29" x2="796.511" y2="832.071" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.572115" stop-color="#B8F1ED"/>
|
||||
<stop offset="0.9999" stop-color="#B8F1CC"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
444
docs/songlist-api.md
Normal file
@@ -0,0 +1,444 @@
|
||||
# 歌单管理 API 文档
|
||||
|
||||
本文档介绍了 CeruMusic 中歌单管理功能的使用方法,包括后端服务类和前端 API 接口。
|
||||
|
||||
## 概述
|
||||
|
||||
歌单管理系统提供了完整的歌单和歌曲管理功能,包括:
|
||||
|
||||
- 📁 **歌单管理**:创建、删除、编辑、搜索歌单
|
||||
- 🎵 **歌曲管理**:添加、移除、搜索歌单中的歌曲
|
||||
- 📊 **统计分析**:获取歌单和歌曲的统计信息
|
||||
- 🔧 **数据维护**:验证和修复歌单数据完整性
|
||||
- ⚡ **批量操作**:支持批量删除和批量移除操作
|
||||
|
||||
## 架构设计
|
||||
|
||||
```
|
||||
前端 (Renderer Process)
|
||||
├── src/renderer/src/api/songList.ts # 前端 API 封装
|
||||
├── src/renderer/src/examples/songListUsage.ts # 使用示例
|
||||
└── src/types/songList.ts # TypeScript 类型定义
|
||||
|
||||
主进程 (Main Process)
|
||||
├── src/main/events/songList.ts # IPC 事件处理
|
||||
├── src/main/services/songList/ManageSongList.ts # 歌单管理服务
|
||||
└── src/main/services/songList/PlayListSongs.ts # 歌曲管理基类
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 前端使用
|
||||
|
||||
```typescript
|
||||
import songListAPI from '@/api/songList'
|
||||
|
||||
// 创建歌单
|
||||
const result = await songListAPI.create('我的收藏', '我最喜欢的歌曲')
|
||||
if (result.success) {
|
||||
console.log('歌单创建成功,ID:', result.data?.id)
|
||||
}
|
||||
|
||||
// 获取所有歌单
|
||||
const playlists = await songListAPI.getAll()
|
||||
if (playlists.success) {
|
||||
console.log('歌单列表:', playlists.data)
|
||||
}
|
||||
|
||||
// 添加歌曲到歌单
|
||||
const songs = [
|
||||
/* 歌曲数据 */
|
||||
]
|
||||
await songListAPI.addSongs(playlistId, songs)
|
||||
```
|
||||
|
||||
### 2. 类型安全
|
||||
|
||||
所有 API 都提供了完整的 TypeScript 类型支持:
|
||||
|
||||
```typescript
|
||||
import type { IPCResponse, SongListStatistics } from '@/types/songList'
|
||||
|
||||
const stats: IPCResponse<SongListStatistics> = await songListAPI.getStatistics()
|
||||
```
|
||||
|
||||
## API 参考
|
||||
|
||||
### 歌单管理
|
||||
|
||||
#### `create(name, description?, source?)`
|
||||
|
||||
创建新歌单
|
||||
|
||||
```typescript
|
||||
const result = await songListAPI.create('我的收藏', '描述', 'local')
|
||||
// 返回: { success: boolean, data?: { id: string }, error?: string }
|
||||
```
|
||||
|
||||
#### `getAll()`
|
||||
|
||||
获取所有歌单
|
||||
|
||||
```typescript
|
||||
const result = await songListAPI.getAll()
|
||||
// 返回: { success: boolean, data?: SongList[], error?: string }
|
||||
```
|
||||
|
||||
#### `getById(hashId)`
|
||||
|
||||
根据ID获取歌单
|
||||
|
||||
```typescript
|
||||
const result = await songListAPI.getById('playlist-id')
|
||||
// 返回: { success: boolean, data?: SongList | null, error?: string }
|
||||
```
|
||||
|
||||
#### `delete(hashId)`
|
||||
|
||||
删除歌单
|
||||
|
||||
```typescript
|
||||
const result = await songListAPI.delete('playlist-id')
|
||||
// 返回: { success: boolean, error?: string }
|
||||
```
|
||||
|
||||
#### `batchDelete(hashIds)`
|
||||
|
||||
批量删除歌单
|
||||
|
||||
```typescript
|
||||
const result = await songListAPI.batchDelete(['id1', 'id2'])
|
||||
// 返回: { success: boolean, data?: { success: string[], failed: string[] } }
|
||||
```
|
||||
|
||||
#### `edit(hashId, updates)`
|
||||
|
||||
编辑歌单信息
|
||||
|
||||
```typescript
|
||||
const result = await songListAPI.edit('playlist-id', {
|
||||
name: '新名称',
|
||||
description: '新描述'
|
||||
})
|
||||
```
|
||||
|
||||
#### `search(keyword, source?)`
|
||||
|
||||
搜索歌单
|
||||
|
||||
```typescript
|
||||
const result = await songListAPI.search('关键词', 'local')
|
||||
// 返回: { success: boolean, data?: SongList[], error?: string }
|
||||
```
|
||||
|
||||
### 歌曲管理
|
||||
|
||||
#### `addSongs(hashId, songs)`
|
||||
|
||||
添加歌曲到歌单
|
||||
|
||||
```typescript
|
||||
const songs: Songs[] = [
|
||||
/* 歌曲数据 */
|
||||
]
|
||||
const result = await songListAPI.addSongs('playlist-id', songs)
|
||||
```
|
||||
|
||||
#### `removeSong(hashId, songmid)`
|
||||
|
||||
移除单首歌曲
|
||||
|
||||
```typescript
|
||||
const result = await songListAPI.removeSong('playlist-id', 'song-id')
|
||||
// 返回: { success: boolean, data?: boolean, error?: string }
|
||||
```
|
||||
|
||||
#### `removeSongs(hashId, songmids)`
|
||||
|
||||
批量移除歌曲
|
||||
|
||||
```typescript
|
||||
const result = await songListAPI.removeSongs('playlist-id', ['song1', 'song2'])
|
||||
// 返回: { success: boolean, data?: { removed: number, notFound: number } }
|
||||
```
|
||||
|
||||
#### `getSongs(hashId)`
|
||||
|
||||
获取歌单中的歌曲
|
||||
|
||||
```typescript
|
||||
const result = await songListAPI.getSongs('playlist-id')
|
||||
// 返回: { success: boolean, data?: readonly Songs[], error?: string }
|
||||
```
|
||||
|
||||
#### `searchSongs(hashId, keyword)`
|
||||
|
||||
搜索歌单中的歌曲
|
||||
|
||||
```typescript
|
||||
const result = await songListAPI.searchSongs('playlist-id', '关键词')
|
||||
// 返回: { success: boolean, data?: Songs[], error?: string }
|
||||
```
|
||||
|
||||
### 统计信息
|
||||
|
||||
#### `getStatistics()`
|
||||
|
||||
获取歌单统计信息
|
||||
|
||||
```typescript
|
||||
const result = await songListAPI.getStatistics()
|
||||
// 返回: {
|
||||
// success: boolean,
|
||||
// data?: {
|
||||
// total: number,
|
||||
// bySource: Record<string, number>,
|
||||
// lastUpdated: string
|
||||
// }
|
||||
// }
|
||||
```
|
||||
|
||||
#### `getSongStatistics(hashId)`
|
||||
|
||||
获取歌单歌曲统计信息
|
||||
|
||||
```typescript
|
||||
const result = await songListAPI.getSongStatistics('playlist-id')
|
||||
// 返回: {
|
||||
// success: boolean,
|
||||
// data?: {
|
||||
// total: number,
|
||||
// bySinger: Record<string, number>,
|
||||
// byAlbum: Record<string, number>,
|
||||
// lastModified: string
|
||||
// }
|
||||
// }
|
||||
```
|
||||
|
||||
### 数据维护
|
||||
|
||||
#### `validateIntegrity(hashId)`
|
||||
|
||||
验证歌单数据完整性
|
||||
|
||||
```typescript
|
||||
const result = await songListAPI.validateIntegrity('playlist-id')
|
||||
// 返回: { success: boolean, data?: { isValid: boolean, issues: string[] } }
|
||||
```
|
||||
|
||||
#### `repairData(hashId)`
|
||||
|
||||
修复歌单数据
|
||||
|
||||
```typescript
|
||||
const result = await songListAPI.repairData('playlist-id')
|
||||
// 返回: { success: boolean, data?: { fixed: boolean, changes: string[] } }
|
||||
```
|
||||
|
||||
### 便捷方法
|
||||
|
||||
#### `getPlaylistDetail(hashId)`
|
||||
|
||||
获取歌单详细信息(包含歌曲列表)
|
||||
|
||||
```typescript
|
||||
const result = await songListAPI.getPlaylistDetail('playlist-id')
|
||||
// 返回: {
|
||||
// playlist: SongList | null,
|
||||
// songs: readonly Songs[],
|
||||
// success: boolean,
|
||||
// error?: string
|
||||
// }
|
||||
```
|
||||
|
||||
#### `checkAndRepair(hashId)`
|
||||
|
||||
检查并修复歌单数据
|
||||
|
||||
```typescript
|
||||
const result = await songListAPI.checkAndRepair('playlist-id')
|
||||
// 返回: {
|
||||
// needsRepair: boolean,
|
||||
// repairResult?: RepairResult,
|
||||
// success: boolean,
|
||||
// error?: string
|
||||
// }
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
所有 API 都返回统一的响应格式:
|
||||
|
||||
```typescript
|
||||
interface IPCResponse<T = any> {
|
||||
success: boolean // 操作是否成功
|
||||
data?: T // 返回的数据
|
||||
error?: string // 错误信息
|
||||
message?: string // 附加消息
|
||||
code?: string // 错误码
|
||||
}
|
||||
```
|
||||
|
||||
### 错误码说明
|
||||
|
||||
| 错误码 | 说明 |
|
||||
| -------------------- | ------------ |
|
||||
| `INVALID_HASH_ID` | 无效的歌单ID |
|
||||
| `PLAYLIST_NOT_FOUND` | 歌单不存在 |
|
||||
| `EMPTY_NAME` | 歌单名称为空 |
|
||||
| `CREATE_FAILED` | 创建失败 |
|
||||
| `DELETE_FAILED` | 删除失败 |
|
||||
| `EDIT_FAILED` | 编辑失败 |
|
||||
| `READ_FAILED` | 读取失败 |
|
||||
| `WRITE_FAILED` | 写入失败 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 完整的歌单管理流程
|
||||
|
||||
```typescript
|
||||
import songListAPI from '@/api/songList'
|
||||
|
||||
async function managePlaylist() {
|
||||
try {
|
||||
// 1. 创建歌单
|
||||
const createResult = await songListAPI.create('我的收藏', '我最喜欢的歌曲')
|
||||
if (!createResult.success) {
|
||||
throw new Error(createResult.error)
|
||||
}
|
||||
|
||||
const playlistId = createResult.data!.id
|
||||
|
||||
// 2. 添加歌曲
|
||||
const songs = [
|
||||
{
|
||||
songmid: 'song1',
|
||||
name: '歌曲1',
|
||||
singer: '歌手1',
|
||||
albumName: '专辑1',
|
||||
albumId: 'album1',
|
||||
duration: 240,
|
||||
source: 'local'
|
||||
}
|
||||
]
|
||||
|
||||
await songListAPI.addSongs(playlistId, songs)
|
||||
|
||||
// 3. 获取歌单详情
|
||||
const detail = await songListAPI.getPlaylistDetail(playlistId)
|
||||
console.log('歌单信息:', detail.playlist)
|
||||
console.log('歌曲列表:', detail.songs)
|
||||
|
||||
// 4. 搜索歌曲
|
||||
const searchResult = await songListAPI.searchSongs(playlistId, '歌曲')
|
||||
console.log('搜索结果:', searchResult.data)
|
||||
|
||||
// 5. 获取统计信息
|
||||
const stats = await songListAPI.getSongStatistics(playlistId)
|
||||
console.log('统计信息:', stats.data)
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### React 组件中的使用
|
||||
|
||||
```typescript
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import songListAPI from '@/api/songList'
|
||||
import type { SongList } from '@common/types/songList'
|
||||
|
||||
const PlaylistManager: React.FC = () => {
|
||||
const [playlists, setPlaylists] = useState<SongList[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// 加载歌单列表
|
||||
const loadPlaylists = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await songListAPI.getAll()
|
||||
if (result.success) {
|
||||
setPlaylists(result.data || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载歌单失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新歌单
|
||||
const createPlaylist = async (name: string) => {
|
||||
const result = await songListAPI.create(name)
|
||||
if (result.success) {
|
||||
await loadPlaylists() // 重新加载列表
|
||||
}
|
||||
}
|
||||
|
||||
// 删除歌单
|
||||
const deletePlaylist = async (id: string) => {
|
||||
const result = await songListAPI.safeDelete(id, async () => {
|
||||
return confirm('确定要删除这个歌单吗?')
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
await loadPlaylists() // 重新加载列表
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadPlaylists()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{loading ? (
|
||||
<div>加载中...</div>
|
||||
) : (
|
||||
<div>
|
||||
{playlists.map(playlist => (
|
||||
<div key={playlist.id}>
|
||||
<h3>{playlist.name}</h3>
|
||||
<p>{playlist.description}</p>
|
||||
<button onClick={() => deletePlaylist(playlist.id)}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 性能优化建议
|
||||
|
||||
1. **批量操作**:使用 `batchDelete` 和 `removeSongs` 进行批量操作
|
||||
2. **数据缓存**:在前端适当缓存歌单列表,避免频繁请求
|
||||
3. **懒加载**:歌曲列表可以按需加载,不必一次性加载所有数据
|
||||
4. **错误恢复**:使用 `checkAndRepair` 定期检查数据完整性
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 所有 API 都是异步的,需要使用 `await` 或 `.then()`
|
||||
2. 歌单 ID (`hashId`) 是唯一标识符,不要与数组索引混淆
|
||||
3. 歌曲 ID (`songmid`) 可能是字符串或数字类型
|
||||
4. 删除操作是不可逆的,建议使用 `safeDelete` 方法
|
||||
5. 大量数据操作时注意性能影响
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0 (2024-01-10)
|
||||
|
||||
- ✨ 初始版本发布
|
||||
- ✨ 完整的歌单管理功能
|
||||
- ✨ 批量操作支持
|
||||
- ✨ 数据完整性检查
|
||||
- ✨ TypeScript 类型支持
|
||||
- ✨ 详细的使用文档和示例
|
||||
|
||||
---
|
||||
|
||||
如有问题或建议,请提交 Issue 或 Pull Request。
|
||||
150
docs/webdav-sync-setup.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# WebDAV 同步配置指南
|
||||
|
||||
本项目包含两个 GitHub Actions 工作流,用于自动将 GitHub Releases 同步到 alist(WebDAV 服务器)。
|
||||
|
||||
## 工作流说明
|
||||
|
||||
### 1. 手动同步工作流 (`sync-releases-to-webdav.yml`)
|
||||
|
||||
- **触发方式**: 手动触发 (workflow_dispatch)
|
||||
- **功能**: 同步现有的所有版本或指定版本到 WebDAV
|
||||
- **参数**:
|
||||
- `tag_name`: 可选,指定要同步的版本标签(如 v1.0.0),留空则同步所有版本
|
||||
|
||||
### 2. 自动同步工作流 (集成在 `main.yml` 中)
|
||||
|
||||
- **触发方式**: 在 AutoBuild 完成后自动触发
|
||||
- **功能**: 自动将新构建的版本同步到 WebDAV
|
||||
- **参数**: 无需手动设置,自动获取发布信息
|
||||
|
||||
### 3. 独立自动同步工作流 (`auto-sync-release.yml`)
|
||||
|
||||
- **触发方式**: 当新版本发布时自动触发 (on release published)
|
||||
- **功能**: 备用的自动同步机制
|
||||
- **参数**: 无需手动设置,自动获取发布信息
|
||||
|
||||
## 配置要求
|
||||
|
||||
在 GitHub 仓库的 Settings > Secrets and variables > Actions 中添加以下密钥:
|
||||
|
||||
### 必需的 Secrets
|
||||
|
||||
1. **WEBDAV_BASE_URL**
|
||||
- 描述: WebDAV 服务器的基础 URL
|
||||
- 示例: `https://your-alist-domain.com/dav`
|
||||
- 注意: 不要在末尾添加斜杠
|
||||
|
||||
2. **WEBDAV_USERNAME**
|
||||
- 描述: WebDAV 服务器的用户名
|
||||
- 示例: `admin`
|
||||
|
||||
3. **WEBDAV_PASSWORD**
|
||||
- 描述: WebDAV 服务器的密码
|
||||
- 示例: `your-password`
|
||||
|
||||
4. **GITHUB_TOKEN**
|
||||
- 描述: GitHub 访问令牌(通常自动提供)
|
||||
- 注意: 如果默认的 `GITHUB_TOKEN` 权限不足,可能需要创建个人访问令牌
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 手动同步现有版本
|
||||
|
||||
1. 进入 GitHub 仓库的 Actions 页面
|
||||
2. 选择 "Sync Existing Releases to WebDAV" 工作流
|
||||
3. 点击 "Run workflow"
|
||||
4. 可选择指定版本标签或留空同步所有版本
|
||||
5. 点击 "Run workflow" 开始执行
|
||||
|
||||
### 自动同步新版本
|
||||
|
||||
现在有两种自动同步方式:
|
||||
|
||||
1. **集成同步** (推荐): 在主构建工作流 (`main.yml`) 中集成了 WebDAV 同步,当您推送 `v*` 标签时,会自动执行:
|
||||
- 构建应用 → 创建 Release → 同步到 WebDAV
|
||||
2. **独立同步**: 当您手动发布 Release 时,`auto-sync-release.yml` 工作流会自动触发
|
||||
|
||||
推荐使用集成同步方式,因为它确保了构建和同步的一致性。
|
||||
|
||||
## 文件结构
|
||||
|
||||
同步后的文件将按以下结构存储在 alist 中:
|
||||
|
||||
```
|
||||
/yd/ceru/
|
||||
├── v1.0.0/
|
||||
│ ├── app-setup.exe
|
||||
│ ├── app.dmg
|
||||
│ └── app.AppImage
|
||||
├── v1.1.0/
|
||||
│ ├── app-setup.exe
|
||||
│ ├── app.dmg
|
||||
│ └── app.AppImage
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **上传失败**
|
||||
- 检查 WebDAV 服务器是否正常运行
|
||||
- 验证用户名和密码是否正确
|
||||
- 确认 WebDAV URL 格式正确
|
||||
|
||||
2. **权限错误**
|
||||
- 确保 WebDAV 用户有写入权限
|
||||
- 检查目标目录是否存在且可写
|
||||
|
||||
3. **文件大小不匹配**
|
||||
- 网络问题导致下载不完整
|
||||
- GitHub API 限制或临时故障
|
||||
|
||||
4. **目录创建失败**
|
||||
- WebDAV 服务器不支持 MKCOL 方法
|
||||
- 权限不足或路径错误
|
||||
|
||||
### 调试步骤
|
||||
|
||||
1. 查看 Actions 运行日志
|
||||
2. 检查 WebDAV 服务器日志
|
||||
3. 验证所有 Secrets 配置正确
|
||||
4. 测试 WebDAV 连接是否正常
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
1. **密钥管理**
|
||||
- 不要在代码中硬编码密码
|
||||
- 定期更换 WebDAV 密码
|
||||
- 使用强密码
|
||||
|
||||
2. **权限控制**
|
||||
- 为 WebDAV 用户设置最小必要权限
|
||||
- 考虑使用专用的同步账户
|
||||
|
||||
3. **网络安全**
|
||||
- 建议使用 HTTPS 连接
|
||||
- 考虑 IP 白名单限制
|
||||
|
||||
## 自定义配置
|
||||
|
||||
如需修改同步路径或其他配置,请编辑对应的工作流文件:
|
||||
|
||||
- 修改存储路径: 更改 `remote_path` 变量
|
||||
- 调整重试逻辑: 修改错误处理部分
|
||||
- 添加通知: 集成 Slack、邮件等通知服务
|
||||
|
||||
## 支持的文件类型
|
||||
|
||||
工作流支持同步所有类型的 Release 资源文件,包括但不限于:
|
||||
|
||||
- 可执行文件 (.exe, .dmg, .AppImage)
|
||||
- 压缩包 (.zip, .tar.gz, .7z)
|
||||
- 安装包 (.msi, .deb, .rpm)
|
||||
- 其他二进制文件
|
||||
|
||||
## 版本兼容性
|
||||
|
||||
- GitHub Actions: 支持最新版本
|
||||
- alist: 支持 WebDAV 协议的版本
|
||||
- 操作系统: Ubuntu Latest (工作流运行环境)
|
||||
51
docs/使用文档.md
@@ -1,51 +0,0 @@
|
||||
# CeruMusic 使用教程
|
||||
|
||||
## 1. 软件下载
|
||||
|
||||
由于我们团段都是个人开发者原因 暂时无能力部署到 `OSS` 承担高下载量的能力,供大家下载只能通过[Github](https://github.com/timeshiftsauce/CeruMusic)下载安装使用
|
||||
|
||||
### Window 安装
|
||||
|
||||
由于没有证书原因 **`Window`** 平台可能会出现安装包体误报**危险**。请放心我们的软件都是**开源**在 `Github` 自动化打包的。**具体安装步骤如下**
|
||||
|
||||
<img src="assets/image-20250826214921963.png" alt="image-20250826214921963" style="zoom: 50%;" />如果出现类似图例效果请先点击 **右侧 三个点**
|
||||
|
||||
|
||||
|
||||
<img src="assets/image-20250826215101522.png" alt="image-20250826215101522" style="zoom:50%;" />**点击保留**
|
||||
|
||||
|
||||
|
||||
<img src="assets/image-20250826215206862.png" alt="image-20250826215206862" style="zoom:50%;" />**点击下拉按钮**
|
||||
|
||||
|
||||
|
||||
<img src="assets/image-20250826215251525.png" alt="image-20250826215251525" style="zoom:50%;" />**任然保留**就可以双击打开安装到此教程结束
|
||||
|
||||
### Mac OS 系统下载安装
|
||||
|
||||
由于同样没有**签名**的原因mac的护栏也会拦截提示安装包损坏
|
||||
|
||||
<img src="assets/3f50d3b838287b4bf1523d0f955fdf37.png" alt="3f50d3b838287b4bf1523d0f955fdf37" style="zoom:50%;" />请不用担心这是典型的签名问题
|
||||
|
||||
适用于 macOS 14 Sonoma 及以上版本。
|
||||
|
||||
注意:由于我们不提供经过签名的程序包体,因此在安装后首次运行可能会出现 “**澜音** 已损坏” 之类的提示,此时只需打开终端,输入命令
|
||||
|
||||
```bash
|
||||
sudo xattr -r -d com.apple.quarantine /Applications/澜音.app
|
||||
```
|
||||
|
||||
并回车,输入密码再次回车,重新尝试启动程序即可
|
||||
|
||||
*要是还有问题可自行在搜索引擎查询由于 。```apple```官方证书需要99刀的价格实在无能为力见谅* 如果你有能力成为`澜音`的赞助者可联系
|
||||
|
||||
- QQ:`2115295703`
|
||||
- 微信:`cl_wj0623`
|
||||
|
||||
### 插件安装
|
||||
|
||||
首次进入应用需要在软件右上角设置导入**音源**才能使用可查询`Ceru插件`**(目前生态欠缺)** 或现成的**落雪**插件导入使用
|
||||
|
||||
###### 导入完成点击使用
|
||||
|
||||
@@ -19,6 +19,7 @@ win:
|
||||
- target: nsis
|
||||
arch:
|
||||
- x64
|
||||
- ia32
|
||||
# 简化版本信息设置,避免rcedit错误
|
||||
fileAssociations:
|
||||
- ext: cerumusic
|
||||
@@ -30,7 +31,7 @@ win:
|
||||
# 或者使用证书存储
|
||||
# certificateSubjectName: "Your Company Name"
|
||||
nsis:
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
artifactName: ${name}-${version}-${arch}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
@@ -40,16 +41,17 @@ nsis:
|
||||
allowToChangeInstallationDirectory: true
|
||||
allowElevation: true
|
||||
mac:
|
||||
icon: 'resources/icons/icon.icns'
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extendInfo:
|
||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
- NSDocumentsFolderUsageDescription: 需要访问文档文件夹来保存和打开您创建的文件。
|
||||
- NSDownloadsFolderUsageDescription: 需要访问下载文件夹来管理您下载的内容。
|
||||
notarize: false
|
||||
dmg:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
title: ${productName}
|
||||
linux:
|
||||
icon: 'resources/icons'
|
||||
target:
|
||||
- AppImage
|
||||
- snap
|
||||
|
||||
235
eslint.config.js
Normal file
@@ -0,0 +1,235 @@
|
||||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import vue from 'eslint-plugin-vue'
|
||||
import prettier from '@electron-toolkit/eslint-config-prettier'
|
||||
|
||||
export default [
|
||||
// 基础 JavaScript 推荐配置
|
||||
js.configs.recommended,
|
||||
|
||||
// TypeScript 推荐配置
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
// Vue 3 推荐配置
|
||||
...vue.configs['flat/recommended'],
|
||||
|
||||
// 忽略的文件和目录
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/out/**',
|
||||
'**/build/**',
|
||||
'**/.vitepress/**',
|
||||
'**/docs/**',
|
||||
'**/website/**',
|
||||
'**/coverage/**',
|
||||
'**/*.min.js',
|
||||
'**/auto-imports.d.ts',
|
||||
'**/components.d.ts',
|
||||
'src/preload/index.d.ts', // 忽略类型定义文件
|
||||
'src/renderer/src/assets/icon_font/**', // 忽略第三方图标字体文件
|
||||
'src/main/utils/musicSdk/**', // 忽略第三方音乐 SDK
|
||||
'src/main/utils/request.js', // 忽略第三方请求库
|
||||
'scripts/**', // 忽略脚本文件
|
||||
'src/common/utils/lyricUtils/**' // 忽略第三方歌词工具
|
||||
]
|
||||
},
|
||||
|
||||
// 全局配置
|
||||
{
|
||||
files: ['**/*.{js,ts,vue}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module'
|
||||
},
|
||||
rules: {
|
||||
// 代码质量 (放宽规则)
|
||||
'no-unused-vars': 'off', // 由 TypeScript 处理
|
||||
'no-undef': 'off', // 由 TypeScript 处理
|
||||
'prefer-const': 'warn', // 降级为警告
|
||||
'no-var': 'warn', // 降级为警告
|
||||
'no-duplicate-imports': 'off', // 允许重复导入
|
||||
'no-useless-return': 'off',
|
||||
'no-useless-concat': 'off',
|
||||
'no-useless-escape': 'off',
|
||||
'no-unreachable': 'warn',
|
||||
'no-debugger': 'off',
|
||||
|
||||
// 代码风格 (大幅放宽)
|
||||
eqeqeq: 'off', // 允许 == 和 ===
|
||||
curly: 'off', // 允许不使用大括号
|
||||
'brace-style': 'off',
|
||||
'comma-dangle': 'off',
|
||||
quotes: 'off',
|
||||
semi: 'off',
|
||||
indent: 'off',
|
||||
'object-curly-spacing': 'off',
|
||||
'array-bracket-spacing': 'off',
|
||||
'space-before-function-paren': 'off',
|
||||
|
||||
// 最佳实践 (放宽)
|
||||
'no-eval': 'warn',
|
||||
'no-implied-eval': 'warn',
|
||||
'no-new-func': 'warn',
|
||||
'no-alert': 'off',
|
||||
'no-empty': 'off', // 允许空块
|
||||
'no-extra-boolean-cast': 'off',
|
||||
'no-extra-semi': 'off',
|
||||
'no-irregular-whitespace': 'off',
|
||||
'no-multiple-empty-lines': 'off',
|
||||
'no-trailing-spaces': 'off',
|
||||
'eol-last': 'off',
|
||||
'no-fallthrough': 'off', // 允许 switch case 穿透
|
||||
'no-case-declarations': 'off', // 允许 case 中声明变量
|
||||
'no-empty-pattern': 'off', // 允许空对象模式
|
||||
'no-prototype-builtins': 'off', // 允许直接调用 hasOwnProperty
|
||||
'no-self-assign': 'off', // 允许自赋值
|
||||
'no-async-promise-executor': 'off' // 允许异步 Promise 执行器
|
||||
}
|
||||
},
|
||||
|
||||
// 主进程 TypeScript 配置
|
||||
{
|
||||
files: ['src/main/**/*.ts', 'src/preload/**/*.ts', 'src/common/**/*.ts', 'src/types/**/*.ts'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: './tsconfig.node.json',
|
||||
tsconfigRootDir: process.cwd()
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
// TypeScript 特定规则 (大幅放宽)
|
||||
'@typescript-eslint/no-unused-vars': 'off', // 完全关闭未使用变量检查
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'@typescript-eslint/no-var-requires': 'off', // 允许 require
|
||||
'@typescript-eslint/ban-ts-comment': 'off', // 允许 @ts-ignore
|
||||
'@typescript-eslint/no-empty-function': 'off', // 允许空函数
|
||||
'@typescript-eslint/no-inferrable-types': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': 'off', // 允许未使用的表达式
|
||||
'@typescript-eslint/no-require-imports': 'off', // 允许 require 导入
|
||||
'@typescript-eslint/no-unsafe-function-type': 'off', // 允许 Function 类型
|
||||
'@typescript-eslint/prefer-as-const': 'off' // 允许字面量类型
|
||||
}
|
||||
},
|
||||
|
||||
// 渲染进程 TypeScript 配置
|
||||
{
|
||||
files: ['src/renderer/**/*.ts'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: './tsconfig.web.json',
|
||||
tsconfigRootDir: process.cwd()
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
// TypeScript 特定规则 (大幅放宽)
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-inferrable-types': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'@typescript-eslint/no-unsafe-function-type': 'off',
|
||||
'@typescript-eslint/prefer-as-const': 'off'
|
||||
}
|
||||
},
|
||||
|
||||
// Vue 特定配置
|
||||
{
|
||||
files: ['src/renderer/**/*.vue'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: '@typescript-eslint/parser',
|
||||
extraFileExtensions: ['.vue']
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
// Vue 特定规则 (大幅放宽)
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-v-html': 'off', // 允许 v-html
|
||||
'vue/require-default-prop': 'off',
|
||||
'vue/require-explicit-emits': 'off', // 不强制显式 emits
|
||||
'vue/component-definition-name-casing': 'off',
|
||||
'vue/component-name-in-template-casing': 'off',
|
||||
'vue/custom-event-name-casing': 'off', // 允许任意事件命名
|
||||
'vue/define-macros-order': 'off',
|
||||
'vue/html-self-closing': 'off',
|
||||
'vue/max-attributes-per-line': 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'vue/multiline-html-element-content-newline': 'off',
|
||||
'vue/no-side-effects-in-computed-properties': 'off', // 允许计算属性中的副作用
|
||||
'vue/no-required-prop-with-default': 'off', // 允许带默认值的必需属性
|
||||
|
||||
// TypeScript 在 Vue 中的规则 (放宽)
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unsafe-function-type': 'off'
|
||||
}
|
||||
},
|
||||
|
||||
// 主进程文件配置 (Node.js 环境)
|
||||
{
|
||||
files: [
|
||||
'src/main/**/*.{ts,js}',
|
||||
'src/preload/**/*.{ts,js}',
|
||||
'electron.vite.config.*',
|
||||
'scripts/**/*.{js,ts}'
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
process: 'readonly',
|
||||
global: 'readonly'
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
// Node.js 特定规则 (放宽)
|
||||
'no-console': 'off',
|
||||
'no-process-exit': 'off' // 允许 process.exit()
|
||||
}
|
||||
},
|
||||
|
||||
// 渲染进程文件配置 (浏览器环境)
|
||||
{
|
||||
files: ['src/renderer/**/*.{ts,js,vue}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
window: 'readonly',
|
||||
document: 'readonly',
|
||||
navigator: 'readonly',
|
||||
console: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
clearInterval: 'readonly'
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
// 浏览器环境特定规则
|
||||
'no-console': 'off'
|
||||
}
|
||||
},
|
||||
|
||||
// 配置文件特殊规则
|
||||
{
|
||||
files: ['*.config.{js,ts}', 'vite.config.*', 'electron.vite.config.*'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
'@typescript-eslint/no-var-requires': 'off'
|
||||
}
|
||||
},
|
||||
|
||||
// Prettier 配置 (必须放在最后)
|
||||
prettier
|
||||
]
|
||||
@@ -1,102 +0,0 @@
|
||||
const baseRule = {
|
||||
'no-new': 'off',
|
||||
camelcase: 'off',
|
||||
'no-return-assign': 'off',
|
||||
'space-before-function-paren': ['error', 'never'],
|
||||
'no-var': 'error',
|
||||
'no-fallthrough': 'off',
|
||||
eqeqeq: 'off',
|
||||
'require-atomic-updates': ['error', { allowProperties: true }],
|
||||
'no-multiple-empty-lines': [1, { max: 2 }],
|
||||
'comma-dangle': [2, 'always-multiline'],
|
||||
'standard/no-callback-literal': 'off',
|
||||
'prefer-const': 'off',
|
||||
'no-labels': 'off',
|
||||
'node/no-callback-literal': 'off',
|
||||
'multiline-ternary': 'off'
|
||||
}
|
||||
const typescriptRule = {
|
||||
...baseRule,
|
||||
'@typescript-eslint/strict-boolean-expressions': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/space-before-function-paren': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'@typescript-eslint/restrict-template-expressions': [
|
||||
1,
|
||||
{
|
||||
allowBoolean: true,
|
||||
allowAny: true
|
||||
}
|
||||
],
|
||||
'@typescript-eslint/restrict-plus-operands': [
|
||||
1,
|
||||
{
|
||||
allowBoolean: true,
|
||||
allowAny: true
|
||||
}
|
||||
],
|
||||
'@typescript-eslint/no-misused-promises': [
|
||||
'error',
|
||||
{
|
||||
checksVoidReturn: {
|
||||
arguments: false,
|
||||
attributes: false
|
||||
}
|
||||
}
|
||||
],
|
||||
'@typescript-eslint/naming-convention': 'off',
|
||||
'@typescript-eslint/return-await': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/comma-dangle': 'off',
|
||||
'@typescript-eslint/no-unsafe-argument': 'off'
|
||||
}
|
||||
const vueRule = {
|
||||
...typescriptRule,
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/max-attributes-per-line': 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'vue/use-v-on-exact': 'off'
|
||||
}
|
||||
|
||||
export const base = {
|
||||
extends: ['standard'],
|
||||
rules: baseRule,
|
||||
parser: '@babel/eslint-parser'
|
||||
}
|
||||
|
||||
export const html = {
|
||||
files: ['*.html'],
|
||||
plugins: ['html']
|
||||
}
|
||||
|
||||
export const typescript = {
|
||||
files: ['*.ts'],
|
||||
rules: typescriptRule,
|
||||
parser: '@typescript-eslint/parser',
|
||||
extends: ['standard-with-typescript']
|
||||
}
|
||||
|
||||
export const vue = {
|
||||
files: ['*.vue'],
|
||||
rules: vueRule,
|
||||
parser: 'vue-eslint-parser',
|
||||
extends: [
|
||||
// 'plugin:vue/vue3-essential',
|
||||
'plugin:vue/base',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'plugin:vue-pug/vue3-recommended',
|
||||
// "plugin:vue/strongly-recommended"
|
||||
'standard-with-typescript'
|
||||
],
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
parser: {
|
||||
// Script parser for `<script>`
|
||||
js: '@typescript-eslint/parser',
|
||||
|
||||
// Script parser for `<script lang="ts">`
|
||||
ts: '@typescript-eslint/parser'
|
||||
},
|
||||
extraFileExtensions: ['.vue']
|
||||
}
|
||||
}
|
||||
18
package.json
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "ceru-music",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"description": "一款简洁优雅的音乐播放器",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "sqj,wldss,star",
|
||||
"license": "MIT",
|
||||
"homepage": "https://electron-vite.org",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://ceru.docs.shiqianjiang.cn",
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint --cache . --fix",
|
||||
@@ -18,11 +18,15 @@
|
||||
"onlybuild": "electron-vite build && electron-builder --win --x64",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "yarn run build && electron-builder --dir",
|
||||
"build:win": "yarn run build && electron-builder --win --x64 --config --publish never",
|
||||
"build:win": "yarn run build && electron-builder --win --config --publish never",
|
||||
"build:win32": "yarn run build && electron-builder --win --ia32 --config --publish never",
|
||||
"build:mac": "yarn run build && electron-builder --mac --config --publish never",
|
||||
"build:linux": "yarn run build && electron-builder --linux --config --publish never",
|
||||
"build:deps": "electron-builder install-app-deps && yarn run build && electron-builder --win --x64 --config",
|
||||
"buildico": "electron-icon-builder --input=./resources/logo.png --output=resources --flatten"
|
||||
"buildico": "electron-icon-builder --input=./resources/logo.png --output=resources --flatten",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@applemusic-like-lyrics/lyric": "^0.2.4",
|
||||
@@ -42,6 +46,7 @@
|
||||
"@pixi/sprite": "^7.4.3",
|
||||
"@types/needle": "^3.3.0",
|
||||
"NeteaseCloudMusicApi": "^4.27.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"axios": "^1.11.0",
|
||||
"color-extraction": "^1.0.8",
|
||||
"crypto-js": "^4.2.0",
|
||||
@@ -90,7 +95,8 @@
|
||||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"vite-plugin-vue-devtools": "^8.0.0",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vue": "^3.5.17",
|
||||
"vitepress": "^2.0.0-alpha.12",
|
||||
"vue": "^3.5.21",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^3.0.3"
|
||||
}
|
||||
|
||||
BIN
resources/default-cover.png
Normal file
|
After Width: | Height: | Size: 824 KiB |
66
scripts/auth-test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const axios = require('axios')
|
||||
|
||||
const ALIST_BASE_URL = 'http://47.96.72.224:5244'
|
||||
const ALIST_USERNAME = 'ceruupdate'
|
||||
const ALIST_PASSWORD = '123456'
|
||||
|
||||
async function test() {
|
||||
// 认证
|
||||
const auth = await axios.post(`${ALIST_BASE_URL}/api/auth/login`, {
|
||||
username: ALIST_USERNAME,
|
||||
password: ALIST_PASSWORD
|
||||
})
|
||||
|
||||
const token = auth.data.data.token
|
||||
console.log('Token received')
|
||||
|
||||
// 测试直接 token 格式
|
||||
try {
|
||||
const list = await axios.post(
|
||||
`${ALIST_BASE_URL}/api/fs/list`,
|
||||
{
|
||||
path: '/',
|
||||
password: '',
|
||||
page: 1,
|
||||
per_page: 30,
|
||||
refresh: false
|
||||
},
|
||||
{
|
||||
headers: { Authorization: token }
|
||||
}
|
||||
)
|
||||
|
||||
console.log('Direct token works:', list.data.code === 200)
|
||||
if (list.data.code === 200) {
|
||||
console.log(
|
||||
'Files:',
|
||||
list.data.data.content.map((f) => f.name)
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Direct token failed')
|
||||
}
|
||||
|
||||
// 测试 Bearer 格式
|
||||
try {
|
||||
const list2 = await axios.post(
|
||||
`${ALIST_BASE_URL}/api/fs/list`,
|
||||
{
|
||||
path: '/',
|
||||
password: '',
|
||||
page: 1,
|
||||
per_page: 30,
|
||||
refresh: false
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}
|
||||
)
|
||||
|
||||
console.log('Bearer format works:', list2.data.code === 200)
|
||||
} catch (e) {
|
||||
console.log('Bearer format failed')
|
||||
}
|
||||
}
|
||||
|
||||
test().catch(console.error)
|
||||
148
scripts/test-alist.js
Normal file
@@ -0,0 +1,148 @@
|
||||
const axios = require('axios')
|
||||
|
||||
// Alist API 配置
|
||||
const ALIST_BASE_URL = 'http://47.96.72.224:5244'
|
||||
const ALIST_USERNAME = 'ceruupdate'
|
||||
const ALIST_PASSWORD = '123456'
|
||||
|
||||
async function testAlistConnection() {
|
||||
console.log('Testing Alist connection...')
|
||||
|
||||
try {
|
||||
// 0. 首先测试服务器是否可访问
|
||||
console.log('0. Testing server accessibility...')
|
||||
const pingResponse = await axios.get(`${ALIST_BASE_URL}/ping`, {
|
||||
timeout: 5000
|
||||
})
|
||||
console.log('Server ping successful:', pingResponse.status)
|
||||
|
||||
// 1. 测试认证
|
||||
console.log('1. Testing authentication...')
|
||||
console.log(`Trying to authenticate with username: ${ALIST_USERNAME}`)
|
||||
|
||||
const authResponse = await axios.post(
|
||||
`${ALIST_BASE_URL}/api/auth/login`,
|
||||
{
|
||||
username: ALIST_USERNAME,
|
||||
password: ALIST_PASSWORD
|
||||
},
|
||||
{
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
console.log('Auth response:', authResponse.data)
|
||||
|
||||
if (authResponse.data.code !== 200) {
|
||||
// 尝试获取公共访问权限
|
||||
console.log('Authentication failed, trying public access...')
|
||||
|
||||
// 尝试不使用认证直接访问文件列表
|
||||
const publicListResponse = await axios.post(
|
||||
`${ALIST_BASE_URL}/api/fs/list`,
|
||||
{
|
||||
path: '/',
|
||||
password: '',
|
||||
page: 1,
|
||||
per_page: 30,
|
||||
refresh: false
|
||||
},
|
||||
{
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
console.log('Public access response:', publicListResponse.data)
|
||||
|
||||
if (publicListResponse.data.code === 200) {
|
||||
console.log('✓ Public access successful')
|
||||
return // 如果公共访问成功,就不需要认证
|
||||
}
|
||||
|
||||
throw new Error(`Authentication failed: ${authResponse.data.message}`)
|
||||
}
|
||||
|
||||
const token = authResponse.data.data.token
|
||||
console.log('✓ Authentication successful')
|
||||
|
||||
// 2. 测试文件列表
|
||||
console.log('2. Testing file listing...')
|
||||
const listResponse = await axios.post(
|
||||
`${ALIST_BASE_URL}/api/fs/list`,
|
||||
{
|
||||
path: '/',
|
||||
password: '',
|
||||
page: 1,
|
||||
per_page: 30,
|
||||
refresh: false
|
||||
},
|
||||
{
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
console.log('List response:', listResponse.data)
|
||||
|
||||
if (listResponse.data.code === 200) {
|
||||
console.log('✓ File listing successful')
|
||||
console.log('Available directories/files:')
|
||||
listResponse.data.data.content.forEach((item) => {
|
||||
console.log(` - ${item.name} (${item.is_dir ? 'directory' : 'file'})`)
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 测试获取特定文件信息(如果存在版本目录)
|
||||
console.log('3. Testing file info retrieval...')
|
||||
try {
|
||||
const fileInfoResponse = await axios.post(
|
||||
`${ALIST_BASE_URL}/api/fs/get`,
|
||||
{
|
||||
path: '/v1.0.0' // 测试版本目录
|
||||
},
|
||||
{
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
Authorization: token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
console.log('File info response:', fileInfoResponse.data)
|
||||
|
||||
if (fileInfoResponse.data.code === 200) {
|
||||
console.log('✓ File info retrieval successful')
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'ℹ Version directory /v1.0.0 not found (this is expected if no updates are available)'
|
||||
)
|
||||
}
|
||||
|
||||
console.log('\n✅ Alist connection test completed successfully!')
|
||||
} catch (error) {
|
||||
console.error('❌ Alist connection test failed:', error.message)
|
||||
|
||||
if (error.response) {
|
||||
console.error('Response status:', error.response.status)
|
||||
console.error('Response data:', error.response.data)
|
||||
} else if (error.request) {
|
||||
console.error('No response received. Check if the Alist server is running and accessible.')
|
||||
}
|
||||
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
testAlistConnection()
|
||||
@@ -26,7 +26,7 @@ export function compareVer(currentVer: string, targetVer: string): -1 | 0 | 1 {
|
||||
.replace(/[^0-9.]/g, fix)
|
||||
.split('.')
|
||||
const targetVerArr: Array<string | number> = ('' + targetVer).replace(/[^0-9.]/g, fix).split('.')
|
||||
let c = Math.max(currentVerArr.length, targetVerArr.length)
|
||||
const c = Math.max(currentVerArr.length, targetVerArr.length)
|
||||
for (let i = 0; i < c; i++) {
|
||||
// convert to integer the most efficient way
|
||||
currentVerArr[i] = ~~currentVerArr[i]
|
||||
|
||||
15
src/common/types/playList.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export default interface PlayList {
|
||||
songmid: string | number
|
||||
hash?: string
|
||||
singer: string
|
||||
name: string
|
||||
albumName: string
|
||||
albumId: string | number
|
||||
source: string
|
||||
interval: string
|
||||
img: string
|
||||
lrc: null | string
|
||||
types: string[]
|
||||
_types: Record<string, any>
|
||||
typeUrl: Record<string, any>
|
||||
}
|
||||
12
src/common/types/songList.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import PlayList from './playList'
|
||||
export type Songs = PlayList
|
||||
|
||||
export type SongList = {
|
||||
id: string //hashId 对应歌单文件名.json
|
||||
name: string // 歌单名
|
||||
createTime: string
|
||||
updateTime: string
|
||||
description: string // 歌单描述
|
||||
coverImgUrl: string //歌单封面 默认第一首歌的图片
|
||||
source: 'local' | 'wy' | 'tx' | 'mg' | 'kg' | 'kw' // 来源
|
||||
}
|
||||
@@ -27,10 +27,10 @@ export const toDateObj = (date: any): Date | '' => {
|
||||
switch (typeof date) {
|
||||
case 'string':
|
||||
if (!date.includes('T')) date = date.split('.')[0].replace(/-/g, '/')
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
|
||||
case 'number':
|
||||
date = new Date(date)
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
|
||||
case 'object':
|
||||
break
|
||||
default:
|
||||
|
||||
@@ -24,13 +24,13 @@ const headExp = /^.*\[id:\$\w+\]\n/
|
||||
const parseLyric = (str) => {
|
||||
str = str.replace(/\r/g, '')
|
||||
if (headExp.test(str)) str = str.replace(headExp, '')
|
||||
let trans = str.match(/\[language:([\w=\\/+]+)\]/)
|
||||
const trans = str.match(/\[language:([\w=\\/+]+)\]/)
|
||||
let lyric
|
||||
let rlyric
|
||||
let tlyric
|
||||
if (trans) {
|
||||
str = str.replace(/\[language:[\w=\\/+]+\]\n/, '')
|
||||
let json = JSON.parse(Buffer.from(trans[1], 'base64').toString())
|
||||
const json = JSON.parse(Buffer.from(trans[1], 'base64').toString())
|
||||
for (const item of json.content) {
|
||||
switch (item.type) {
|
||||
case 0:
|
||||
@@ -44,23 +44,23 @@ const parseLyric = (str) => {
|
||||
}
|
||||
let i = 0
|
||||
let crlyric = str.replace(/\[((\d+),\d+)\].*/g, (str) => {
|
||||
let result = str.match(/\[((\d+),\d+)\].*/)
|
||||
let lineStartTime = parseInt(result[2]) // 行开始时间
|
||||
const result = str.match(/\[((\d+),\d+)\].*/)
|
||||
const lineStartTime = parseInt(result[2]) // 行开始时间
|
||||
let time = lineStartTime
|
||||
let ms = time % 1000
|
||||
const ms = time % 1000
|
||||
time /= 1000
|
||||
let m = parseInt(time / 60)
|
||||
const m = parseInt(time / 60)
|
||||
.toString()
|
||||
.padStart(2, '0')
|
||||
time %= 60
|
||||
let s = parseInt(time).toString().padStart(2, '0')
|
||||
const s = parseInt(time).toString().padStart(2, '0')
|
||||
time = `${m}:${s}.${ms}`
|
||||
if (rlyric) rlyric[i] = `[${time}]${rlyric[i]?.join('') ?? ''}`
|
||||
if (tlyric) tlyric[i] = `[${time}]${tlyric[i]?.join('') ?? ''}`
|
||||
i++
|
||||
|
||||
// 保持原始的 [start,duration] 格式,将相对时间戳转换为绝对时间戳
|
||||
let processedStr = str.replace(/<(\d+),(\d+),(\d+)>/g, (match, start, duration, param) => {
|
||||
const processedStr = str.replace(/<(\d+),(\d+),(\d+)>/g, (match, start, duration, param) => {
|
||||
const absoluteStart = lineStartTime + parseInt(start)
|
||||
return `(${absoluteStart},${duration},${param})`
|
||||
})
|
||||
|
||||
@@ -38,7 +38,7 @@ const handleScrollY = (
|
||||
// @ts-expect-error
|
||||
const start = element.scrollTop ?? element.scrollY ?? 0
|
||||
if (to > start) {
|
||||
let maxScrollTop = element.scrollHeight - element.clientHeight
|
||||
const maxScrollTop = element.scrollHeight - element.clientHeight
|
||||
if (to > maxScrollTop) to = maxScrollTop
|
||||
} else if (to < start) {
|
||||
if (to < 0) to = 0
|
||||
@@ -55,7 +55,7 @@ const handleScrollY = (
|
||||
|
||||
let currentTime = 0
|
||||
let val: number
|
||||
let key = Math.random()
|
||||
const key = Math.random()
|
||||
|
||||
const animateScroll = () => {
|
||||
element.lx_scrollTimeout = undefined
|
||||
@@ -156,7 +156,7 @@ const handleScrollX = (
|
||||
// @ts-expect-error
|
||||
const start = element.scrollLeft || element.scrollX || 0
|
||||
if (to > start) {
|
||||
let maxScrollLeft = element.scrollWidth - element.clientWidth
|
||||
const maxScrollLeft = element.scrollWidth - element.clientWidth
|
||||
if (to > maxScrollLeft) to = maxScrollLeft
|
||||
} else if (to < start) {
|
||||
if (to < 0) to = 0
|
||||
@@ -173,7 +173,7 @@ const handleScrollX = (
|
||||
|
||||
let currentTime = 0
|
||||
let val: number
|
||||
let key = Math.random()
|
||||
const key = Math.random()
|
||||
|
||||
const animateScroll = () => {
|
||||
element.lx_scrollTimeout = undefined
|
||||
@@ -272,7 +272,7 @@ const handleScrollXR = (
|
||||
// @ts-expect-error
|
||||
const start = element.scrollLeft || (element.scrollX as number) || 0
|
||||
if (to < start) {
|
||||
let maxScrollLeft = -element.scrollWidth + element.clientWidth
|
||||
const maxScrollLeft = -element.scrollWidth + element.clientWidth
|
||||
if (to < maxScrollLeft) to = maxScrollLeft
|
||||
} else if (to > start) {
|
||||
if (to > 0) to = 0
|
||||
@@ -290,7 +290,7 @@ const handleScrollXR = (
|
||||
|
||||
let currentTime = 0
|
||||
let val: number
|
||||
let key = Math.random()
|
||||
const key = Math.random()
|
||||
|
||||
const animateScroll = () => {
|
||||
element.lx_scrollTimeout = undefined
|
||||
@@ -371,7 +371,7 @@ export const scrollXRTo = (
|
||||
/**
|
||||
* 设置标题
|
||||
*/
|
||||
let dom_title = document.getElementsByTagName('title')[0]
|
||||
const dom_title = document.getElementsByTagName('title')[0]
|
||||
export const setTitle = (title: string | null) => {
|
||||
title ||= 'LX Music'
|
||||
dom_title.innerText = title
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 业务工具方法
|
||||
|
||||
import { LX } from "../../types/global"
|
||||
import { LX } from '../../types/global'
|
||||
|
||||
export const toNewMusicInfo = (oldMusicInfo: any): LX.Music.MusicInfo => {
|
||||
const meta: Record<string, any> = {
|
||||
|
||||
@@ -1,54 +1,160 @@
|
||||
import { BrowserWindow, app, shell } from 'electron';
|
||||
import axios from 'axios';
|
||||
import fs from 'fs';
|
||||
import path from 'node:path';
|
||||
import { BrowserWindow, app, shell } from 'electron'
|
||||
import axios from 'axios'
|
||||
import fs from 'fs'
|
||||
import path from 'node:path'
|
||||
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let currentUpdateInfo: UpdateInfo | null = null;
|
||||
let downloadProgress = { percent: 0, transferred: 0, total: 0 };
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let currentUpdateInfo: UpdateInfo | null = null
|
||||
let downloadProgress = { percent: 0, transferred: 0, total: 0 }
|
||||
|
||||
// 更新信息接口
|
||||
interface UpdateInfo {
|
||||
url: string;
|
||||
name: string;
|
||||
notes: string;
|
||||
pub_date: string;
|
||||
url: string
|
||||
name: string
|
||||
notes: string
|
||||
pub_date: string
|
||||
}
|
||||
|
||||
// 更新服务器配置
|
||||
const UPDATE_SERVER = 'https://update.ceru.shiqianjiang.cn';
|
||||
const UPDATE_API_URL = `${UPDATE_SERVER}/update/${process.platform}/${app.getVersion()}`;
|
||||
const UPDATE_SERVER = 'https://update.ceru.shiqianjiang.cn'
|
||||
const UPDATE_API_URL = `${UPDATE_SERVER}/update/${process.platform}/${app.getVersion()}`
|
||||
|
||||
// Alist API 配置
|
||||
const ALIST_BASE_URL = 'https://alist.shiqianjiang.cn' // 请替换为实际的 alist 域名
|
||||
const ALIST_USERNAME = 'ceruupdate'
|
||||
const ALIST_PASSWORD = '123456'
|
||||
|
||||
// Alist 认证 token
|
||||
let alistToken: string | null = null
|
||||
|
||||
// 获取 Alist 认证 token
|
||||
async function getAlistToken(): Promise<string> {
|
||||
if (alistToken) {
|
||||
return alistToken
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Authenticating with Alist...')
|
||||
const response = await axios.post(
|
||||
`${ALIST_BASE_URL}/api/auth/login`,
|
||||
{
|
||||
username: ALIST_USERNAME,
|
||||
password: ALIST_PASSWORD
|
||||
},
|
||||
{
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
console.log('Alist auth response:', response.data)
|
||||
|
||||
if (response.data.code === 200) {
|
||||
alistToken = response.data.data.token
|
||||
console.log('Alist authentication successful')
|
||||
return alistToken! // 我们已经确认 token 存在
|
||||
} else {
|
||||
throw new Error(`Alist authentication failed: ${response.data.message || 'Unknown error'}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Alist authentication error:', error)
|
||||
if (error.response) {
|
||||
throw new Error(
|
||||
`Failed to authenticate with Alist: HTTP ${error.response.status} - ${error.response.data?.message || error.response.statusText}`
|
||||
)
|
||||
} else {
|
||||
throw new Error(`Failed to authenticate with Alist: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 Alist 文件下载链接
|
||||
async function getAlistDownloadUrl(version: string, fileName: string): Promise<string> {
|
||||
const token = await getAlistToken()
|
||||
const filePath = `/${version}/${fileName}`
|
||||
|
||||
try {
|
||||
console.log(`Getting file info for: ${filePath}`)
|
||||
const response = await axios.post(
|
||||
`${ALIST_BASE_URL}/api/fs/get`,
|
||||
{
|
||||
path: filePath
|
||||
},
|
||||
{
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
Authorization: token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
console.log('Alist file info response:', response.data)
|
||||
|
||||
if (response.data.code === 200) {
|
||||
const fileInfo = response.data.data
|
||||
|
||||
// 检查文件是否存在且有下载链接
|
||||
if (fileInfo && fileInfo.raw_url) {
|
||||
console.log('Using raw_url for download:', fileInfo.raw_url)
|
||||
return fileInfo.raw_url
|
||||
} else if (fileInfo && fileInfo.sign) {
|
||||
// 使用签名构建下载链接
|
||||
const downloadUrl = `${ALIST_BASE_URL}/d${filePath}?sign=${fileInfo.sign}`
|
||||
console.log('Using signed download URL:', downloadUrl)
|
||||
return downloadUrl
|
||||
} else {
|
||||
// 尝试直接下载链接(无签名)
|
||||
const directUrl = `${ALIST_BASE_URL}/d${filePath}`
|
||||
console.log('Using direct download URL:', directUrl)
|
||||
return directUrl
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Failed to get file info: ${response.data.message || 'Unknown error'}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Alist file info error:', error)
|
||||
if (error.response) {
|
||||
throw new Error(
|
||||
`Failed to get download URL from Alist: HTTP ${error.response.status} - ${error.response.data?.message || error.response.statusText}`
|
||||
)
|
||||
} else {
|
||||
throw new Error(`Failed to get download URL from Alist: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化自动更新器
|
||||
export function initAutoUpdater(window: BrowserWindow) {
|
||||
mainWindow = window;
|
||||
console.log('Auto updater initialized');
|
||||
mainWindow = window
|
||||
console.log('Auto updater initialized')
|
||||
}
|
||||
|
||||
// 检查更新
|
||||
export async function checkForUpdates(window?: BrowserWindow) {
|
||||
if (window) {
|
||||
mainWindow = window;
|
||||
mainWindow = window
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Checking for updates...');
|
||||
mainWindow?.webContents.send('auto-updater:checking-for-update');
|
||||
console.log('Checking for updates...')
|
||||
mainWindow?.webContents.send('auto-updater:checking-for-update')
|
||||
|
||||
const updateInfo = await fetchUpdateInfo();
|
||||
const updateInfo = await fetchUpdateInfo()
|
||||
|
||||
if (updateInfo && isNewerVersion(updateInfo.name, app.getVersion())) {
|
||||
console.log('Update available:', updateInfo);
|
||||
currentUpdateInfo = updateInfo;
|
||||
mainWindow?.webContents.send('auto-updater:update-available', updateInfo);
|
||||
console.log('Update available:', updateInfo)
|
||||
currentUpdateInfo = updateInfo
|
||||
mainWindow?.webContents.send('auto-updater:update-available', updateInfo)
|
||||
} else {
|
||||
console.log('No update available');
|
||||
mainWindow?.webContents.send('auto-updater:update-not-available');
|
||||
console.log('No update available')
|
||||
mainWindow?.webContents.send('auto-updater:update-not-available')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for updates:', error);
|
||||
mainWindow?.webContents.send('auto-updater:error', (error as Error).message);
|
||||
console.error('Error checking for updates:', error)
|
||||
mainWindow?.webContents.send('auto-updater:error', (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,26 +164,26 @@ async function fetchUpdateInfo(): Promise<UpdateInfo | null> {
|
||||
const response = await axios.get(UPDATE_API_URL, {
|
||||
timeout: 10000, // 10秒超时
|
||||
validateStatus: (status) => status === 200 || status === 204 // 允许 200 和 204 状态码
|
||||
});
|
||||
})
|
||||
|
||||
if (response.status === 200) {
|
||||
return response.data as UpdateInfo;
|
||||
return response.data as UpdateInfo
|
||||
} else if (response.status === 204) {
|
||||
// 204 表示没有更新
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
// 服务器响应了错误状态码
|
||||
throw new Error(`HTTP ${error.response.status}: ${error.response.statusText}`);
|
||||
throw new Error(`HTTP ${error.response.status}: ${error.response.statusText}`)
|
||||
} else if (error.request) {
|
||||
// 请求已发出但没有收到响应
|
||||
throw new Error('Network error: No response received');
|
||||
throw new Error('Network error: No response received')
|
||||
} else {
|
||||
// 其他错误
|
||||
throw new Error(`Request failed: ${error.message}`);
|
||||
throw new Error(`Request failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,101 +191,119 @@ async function fetchUpdateInfo(): Promise<UpdateInfo | null> {
|
||||
// 比较版本号
|
||||
function isNewerVersion(remoteVersion: string, currentVersion: string): boolean {
|
||||
const parseVersion = (version: string) => {
|
||||
return version.replace(/^v/, '').split('.').map(num => parseInt(num, 10));
|
||||
};
|
||||
|
||||
const remote = parseVersion(remoteVersion);
|
||||
const current = parseVersion(currentVersion);
|
||||
|
||||
for (let i = 0; i < Math.max(remote.length, current.length); i++) {
|
||||
const r = remote[i] || 0;
|
||||
const c = current[i] || 0;
|
||||
|
||||
if (r > c) return true;
|
||||
if (r < c) return false;
|
||||
return version
|
||||
.replace(/^v/, '')
|
||||
.split('.')
|
||||
.map((num) => parseInt(num, 10))
|
||||
}
|
||||
|
||||
return false;
|
||||
const remote = parseVersion(remoteVersion)
|
||||
const current = parseVersion(currentVersion)
|
||||
|
||||
for (let i = 0; i < Math.max(remote.length, current.length); i++) {
|
||||
const r = remote[i] || 0
|
||||
const c = current[i] || 0
|
||||
|
||||
if (r > c) return true
|
||||
if (r < c) return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 下载更新
|
||||
export async function downloadUpdate() {
|
||||
if (!currentUpdateInfo) {
|
||||
throw new Error('No update info available');
|
||||
throw new Error('No update info available')
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Starting download:', currentUpdateInfo.url);
|
||||
|
||||
// 通知渲染进程开始下载
|
||||
mainWindow?.webContents.send('auto-updater:download-started', currentUpdateInfo);
|
||||
console.log('Starting download:', currentUpdateInfo.url)
|
||||
|
||||
const downloadPath = await downloadFile(currentUpdateInfo.url);
|
||||
console.log('Download completed:', downloadPath);
|
||||
// 通知渲染进程开始下载
|
||||
mainWindow?.webContents.send('auto-updater:download-started', currentUpdateInfo)
|
||||
|
||||
const downloadPath = await downloadFile(currentUpdateInfo.url)
|
||||
console.log('Download completed:', downloadPath)
|
||||
|
||||
mainWindow?.webContents.send('auto-updater:update-downloaded', {
|
||||
downloadPath,
|
||||
updateInfo: currentUpdateInfo
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
mainWindow?.webContents.send('auto-updater:error', (error as Error).message);
|
||||
console.error('Download failed:', error)
|
||||
mainWindow?.webContents.send('auto-updater:error', (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
async function downloadFile(url: string): Promise<string> {
|
||||
const fileName = path.basename(url);
|
||||
const downloadPath = path.join(app.getPath('temp'), fileName);
|
||||
async function downloadFile(originalUrl: string): Promise<string> {
|
||||
const fileName = path.basename(originalUrl)
|
||||
const downloadPath = path.join(app.getPath('temp'), fileName)
|
||||
|
||||
// 进度节流变量
|
||||
let lastProgressSent = 0;
|
||||
let lastProgressTime = 0;
|
||||
const PROGRESS_THROTTLE_INTERVAL = 500; // 500ms 发送一次进度
|
||||
const PROGRESS_THRESHOLD = 1; // 进度变化超过1%才发送
|
||||
let lastProgressSent = 0
|
||||
let lastProgressTime = 0
|
||||
const PROGRESS_THROTTLE_INTERVAL = 500 // 500ms 发送一次进度
|
||||
const PROGRESS_THRESHOLD = 1 // 进度变化超过1%才发送
|
||||
|
||||
try {
|
||||
let downloadUrl = originalUrl
|
||||
|
||||
try {
|
||||
// 从当前更新信息中提取版本号
|
||||
const version = currentUpdateInfo?.name || app.getVersion()
|
||||
|
||||
// 尝试使用 alist API 获取下载链接
|
||||
downloadUrl = await getAlistDownloadUrl(version, fileName)
|
||||
console.log('Using Alist download URL:', downloadUrl)
|
||||
} catch (alistError) {
|
||||
console.warn('Alist download failed, falling back to original URL:', alistError)
|
||||
console.log('Using original download URL:', originalUrl)
|
||||
downloadUrl = originalUrl
|
||||
}
|
||||
|
||||
const response = await axios({
|
||||
method: 'GET',
|
||||
url: url,
|
||||
url: downloadUrl,
|
||||
responseType: 'stream',
|
||||
timeout: 30000, // 30秒超时
|
||||
onDownloadProgress: (progressEvent) => {
|
||||
const { loaded, total } = progressEvent;
|
||||
const percent = total ? (loaded / total) * 100 : 0;
|
||||
const currentTime = Date.now();
|
||||
const { loaded, total } = progressEvent
|
||||
const percent = total ? (loaded / total) * 100 : 0
|
||||
const currentTime = Date.now()
|
||||
|
||||
// 节流逻辑:只在进度变化显著或时间间隔足够时发送
|
||||
const progressDiff = Math.abs(percent - lastProgressSent);
|
||||
const timeDiff = currentTime - lastProgressTime;
|
||||
|
||||
const progressDiff = Math.abs(percent - lastProgressSent)
|
||||
const timeDiff = currentTime - lastProgressTime
|
||||
|
||||
if (progressDiff >= PROGRESS_THRESHOLD || timeDiff >= PROGRESS_THROTTLE_INTERVAL) {
|
||||
downloadProgress = {
|
||||
percent,
|
||||
transferred: loaded,
|
||||
total: total || 0
|
||||
};
|
||||
}
|
||||
|
||||
mainWindow?.webContents.send('auto-updater:download-progress', downloadProgress);
|
||||
lastProgressSent = percent;
|
||||
lastProgressTime = currentTime;
|
||||
mainWindow?.webContents.send('auto-updater:download-progress', downloadProgress)
|
||||
lastProgressSent = percent
|
||||
lastProgressTime = currentTime
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// 发送初始进度
|
||||
const totalSize = parseInt(response.headers['content-length'] || '0', 10);
|
||||
const totalSize = parseInt(response.headers['content-length'] || '0', 10)
|
||||
mainWindow?.webContents.send('auto-updater:download-progress', {
|
||||
percent: 0,
|
||||
transferred: 0,
|
||||
total: totalSize
|
||||
});
|
||||
})
|
||||
|
||||
// 创建写入流
|
||||
const writer = fs.createWriteStream(downloadPath);
|
||||
const writer = fs.createWriteStream(downloadPath)
|
||||
|
||||
// 将响应数据流写入文件
|
||||
response.data.pipe(writer);
|
||||
response.data.pipe(writer)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
writer.on('finish', () => {
|
||||
@@ -188,37 +312,36 @@ async function downloadFile(url: string): Promise<string> {
|
||||
percent: 100,
|
||||
transferred: totalSize,
|
||||
total: totalSize
|
||||
});
|
||||
})
|
||||
|
||||
console.log('File download completed:', downloadPath);
|
||||
resolve(downloadPath);
|
||||
});
|
||||
console.log('File download completed:', downloadPath)
|
||||
resolve(downloadPath)
|
||||
})
|
||||
|
||||
writer.on('error', (error) => {
|
||||
// 删除部分下载的文件
|
||||
fs.unlink(downloadPath, () => {});
|
||||
reject(error);
|
||||
});
|
||||
fs.unlink(downloadPath, () => {})
|
||||
reject(error)
|
||||
})
|
||||
|
||||
response.data.on('error', (error: Error) => {
|
||||
writer.destroy();
|
||||
fs.unlink(downloadPath, () => {});
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
writer.destroy()
|
||||
fs.unlink(downloadPath, () => {})
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
} catch (error: any) {
|
||||
// 删除可能创建的文件
|
||||
if (fs.existsSync(downloadPath)) {
|
||||
fs.unlink(downloadPath, () => {});
|
||||
fs.unlink(downloadPath, () => {})
|
||||
}
|
||||
|
||||
if (error.response) {
|
||||
throw new Error(`Download failed: HTTP ${error.response.status} ${error.response.statusText}`);
|
||||
throw new Error(`Download failed: HTTP ${error.response.status} ${error.response.statusText}`)
|
||||
} else if (error.request) {
|
||||
throw new Error('Download failed: Network error');
|
||||
throw new Error('Download failed: Network error')
|
||||
} else {
|
||||
throw new Error(`Download failed: ${error.message}`);
|
||||
throw new Error(`Download failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,37 +349,37 @@ async function downloadFile(url: string): Promise<string> {
|
||||
// 退出并安装
|
||||
export function quitAndInstall() {
|
||||
if (!currentUpdateInfo) {
|
||||
console.error('No update info available for installation');
|
||||
return;
|
||||
console.error('No update info available for installation')
|
||||
return
|
||||
}
|
||||
|
||||
// 对于不同平台,处理方式不同
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: 打开安装程序
|
||||
const fileName = path.basename(currentUpdateInfo.url);
|
||||
const downloadPath = path.join(app.getPath('temp'), fileName);
|
||||
const fileName = path.basename(currentUpdateInfo.url)
|
||||
const downloadPath = path.join(app.getPath('temp'), fileName)
|
||||
|
||||
if (fs.existsSync(downloadPath)) {
|
||||
shell.openPath(downloadPath).then(() => {
|
||||
app.quit();
|
||||
});
|
||||
app.quit()
|
||||
})
|
||||
} else {
|
||||
console.error('Downloaded file not found:', downloadPath);
|
||||
console.error('Downloaded file not found:', downloadPath)
|
||||
}
|
||||
} else if (process.platform === 'darwin') {
|
||||
// macOS: 打开 dmg 或 zip 文件
|
||||
const fileName = path.basename(currentUpdateInfo.url);
|
||||
const downloadPath = path.join(app.getPath('temp'), fileName);
|
||||
const fileName = path.basename(currentUpdateInfo.url)
|
||||
const downloadPath = path.join(app.getPath('temp'), fileName)
|
||||
|
||||
if (fs.existsSync(downloadPath)) {
|
||||
shell.openPath(downloadPath).then(() => {
|
||||
app.quit();
|
||||
});
|
||||
app.quit()
|
||||
})
|
||||
} else {
|
||||
console.error('Downloaded file not found:', downloadPath);
|
||||
console.error('Downloaded file not found:', downloadPath)
|
||||
}
|
||||
} else {
|
||||
// Linux: 打开下载文件夹
|
||||
shell.showItemInFolder(path.join(app.getPath('temp'), path.basename(currentUpdateInfo.url)));
|
||||
shell.showItemInFolder(path.join(app.getPath('temp'), path.basename(currentUpdateInfo.url)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { ipcMain, BrowserWindow } from 'electron';
|
||||
import { initAutoUpdater, checkForUpdates, downloadUpdate, quitAndInstall } from '../autoUpdate';
|
||||
import { ipcMain, BrowserWindow } from 'electron'
|
||||
import { initAutoUpdater, checkForUpdates, downloadUpdate, quitAndInstall } from '../autoUpdate'
|
||||
|
||||
// 注册自动更新相关的IPC事件
|
||||
export function registerAutoUpdateEvents() {
|
||||
// 检查更新
|
||||
ipcMain.handle('auto-updater:check-for-updates', (event) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender);
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
if (window) {
|
||||
checkForUpdates(window);
|
||||
checkForUpdates(window)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// 下载更新
|
||||
ipcMain.handle('auto-updater:download-update', () => {
|
||||
downloadUpdate();
|
||||
});
|
||||
downloadUpdate()
|
||||
})
|
||||
|
||||
// 安装更新
|
||||
ipcMain.handle('auto-updater:quit-and-install', () => {
|
||||
quitAndInstall();
|
||||
});
|
||||
quitAndInstall()
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化自动更新(在主窗口创建后调用)
|
||||
export function initAutoUpdateForWindow(window: BrowserWindow) {
|
||||
initAutoUpdater(window);
|
||||
}
|
||||
initAutoUpdater(window)
|
||||
}
|
||||
|
||||
@@ -30,4 +30,4 @@ ipcMain.handle('music-cache:get-size', async () => {
|
||||
console.error('获取缓存大小失败:', error)
|
||||
return 0
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
361
src/main/events/songList.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import ManageSongList, { SongListError } from '../services/songList/ManageSongList'
|
||||
import type { SongList, Songs } from '@common/types/songList'
|
||||
|
||||
// 创建新歌单
|
||||
ipcMain.handle(
|
||||
'songlist:create',
|
||||
async (_, name: string, description: string = '', source: SongList['source']) => {
|
||||
try {
|
||||
const result = ManageSongList.createPlaylist(name, description, source)
|
||||
return { success: true, data: result, message: '歌单创建成功' }
|
||||
} catch (error) {
|
||||
console.error('创建歌单失败:', error)
|
||||
const message = error instanceof SongListError ? error.message : '创建歌单失败'
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
code: error instanceof SongListError ? error.code : 'UNKNOWN_ERROR'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 获取所有歌单
|
||||
ipcMain.handle('songlist:get-all', async () => {
|
||||
try {
|
||||
const songLists = ManageSongList.Read()
|
||||
return { success: true, data: songLists }
|
||||
} catch (error) {
|
||||
console.error('获取歌单列表失败:', error)
|
||||
const message = error instanceof SongListError ? error.message : '获取歌单列表失败'
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
code: error instanceof SongListError ? error.code : 'UNKNOWN_ERROR'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 根据ID获取歌单信息
|
||||
ipcMain.handle('songlist:get-by-id', async (_, hashId: string) => {
|
||||
try {
|
||||
const songList = ManageSongList.getById(hashId)
|
||||
return { success: true, data: songList }
|
||||
} catch (error) {
|
||||
console.error('获取歌单信息失败:', error)
|
||||
return { success: false, error: '获取歌单信息失败' }
|
||||
}
|
||||
})
|
||||
|
||||
// 删除歌单
|
||||
ipcMain.handle('songlist:delete', async (_, hashId: string) => {
|
||||
try {
|
||||
ManageSongList.deleteById(hashId)
|
||||
return { success: true, message: '歌单删除成功' }
|
||||
} catch (error) {
|
||||
console.error('删除歌单失败:', error)
|
||||
const message = error instanceof SongListError ? error.message : '删除歌单失败'
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
code: error instanceof SongListError ? error.code : 'UNKNOWN_ERROR'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 批量删除歌单
|
||||
ipcMain.handle('songlist:batch-delete', async (_, hashIds: string[]) => {
|
||||
try {
|
||||
const result = ManageSongList.batchDelete(hashIds)
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
message: `成功删除 ${result.success.length} 个歌单,失败 ${result.failed.length} 个`
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量删除歌单失败:', error)
|
||||
return { success: false, error: '批量删除歌单失败' }
|
||||
}
|
||||
})
|
||||
|
||||
// 编辑歌单信息
|
||||
ipcMain.handle(
|
||||
'songlist:edit',
|
||||
async (_, hashId: string, updates: Partial<Omit<SongList, 'id' | 'createTime'>>) => {
|
||||
try {
|
||||
ManageSongList.editById(hashId, updates)
|
||||
return { success: true, message: '歌单信息更新成功' }
|
||||
} catch (error) {
|
||||
console.error('编辑歌单失败:', error)
|
||||
const message = error instanceof SongListError ? error.message : '编辑歌单失败'
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
code: error instanceof SongListError ? error.code : 'UNKNOWN_ERROR'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 更新歌单封面
|
||||
ipcMain.handle('songlist:update-cover', async (_, hashId: string, coverImgUrl: string) => {
|
||||
try {
|
||||
ManageSongList.updateCoverImgById(hashId, coverImgUrl)
|
||||
return { success: true, message: '封面更新成功' }
|
||||
} catch (error) {
|
||||
console.error('更新封面失败:', error)
|
||||
const message = error instanceof SongListError ? error.message : '更新封面失败'
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
code: error instanceof SongListError ? error.code : 'UNKNOWN_ERROR'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 搜索歌单
|
||||
ipcMain.handle('songlist:search', async (_, keyword: string, source?: SongList['source']) => {
|
||||
try {
|
||||
const results = ManageSongList.search(keyword, source)
|
||||
return { success: true, data: results }
|
||||
} catch (error) {
|
||||
console.error('搜索歌单失败:', error)
|
||||
return { success: false, error: '搜索歌单失败', data: [] }
|
||||
}
|
||||
})
|
||||
|
||||
// 获取歌单统计信息
|
||||
ipcMain.handle('songlist:get-statistics', async () => {
|
||||
try {
|
||||
const statistics = ManageSongList.getStatistics()
|
||||
return { success: true, data: statistics }
|
||||
} catch (error) {
|
||||
console.error('获取统计信息失败:', error)
|
||||
return { success: false, error: '获取统计信息失败' }
|
||||
}
|
||||
})
|
||||
|
||||
// 检查歌单是否存在
|
||||
ipcMain.handle('songlist:exists', async (_, hashId: string) => {
|
||||
try {
|
||||
const exists = ManageSongList.exists(hashId)
|
||||
return { success: true, data: exists }
|
||||
} catch (error) {
|
||||
console.error('检查歌单存在性失败:', error)
|
||||
return { success: false, error: '检查歌单存在性失败', data: false }
|
||||
}
|
||||
})
|
||||
|
||||
// === 歌曲管理相关 IPC 事件 ===
|
||||
|
||||
// 添加歌曲到歌单
|
||||
ipcMain.handle('songlist:add-songs', async (_, hashId: string, songs: Songs[]) => {
|
||||
try {
|
||||
const instance = new ManageSongList(hashId)
|
||||
instance.addSongs(songs)
|
||||
return { success: true, message: `成功添加 ${songs.length} 首歌曲` }
|
||||
} catch (error) {
|
||||
console.error('添加歌曲失败:', error)
|
||||
const message = error instanceof SongListError ? error.message : '添加歌曲失败'
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
code: error instanceof SongListError ? error.code : 'UNKNOWN_ERROR'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 从歌单移除歌曲
|
||||
ipcMain.handle('songlist:remove-song', async (_, hashId: string, songmid: string | number) => {
|
||||
try {
|
||||
const instance = new ManageSongList(hashId)
|
||||
const removed = instance.removeSong(songmid)
|
||||
return {
|
||||
success: true,
|
||||
data: removed,
|
||||
message: removed ? '歌曲移除成功' : '歌曲不存在'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('移除歌曲失败:', error)
|
||||
const message = error instanceof SongListError ? error.message : '移除歌曲失败'
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
code: error instanceof SongListError ? error.code : 'UNKNOWN_ERROR'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 批量移除歌曲
|
||||
ipcMain.handle(
|
||||
'songlist:remove-songs',
|
||||
async (_, hashId: string, songmids: (string | number)[]) => {
|
||||
try {
|
||||
const instance = new ManageSongList(hashId)
|
||||
const result = instance.removeSongs(songmids)
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
message: `成功移除 ${result.removed} 首歌曲,${result.notFound} 首未找到`
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量移除歌曲失败:', error)
|
||||
const message = error instanceof SongListError ? error.message : '批量移除歌曲失败'
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
code: error instanceof SongListError ? error.code : 'UNKNOWN_ERROR'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 清空歌单
|
||||
ipcMain.handle('songlist:clear-songs', async (_, hashId: string) => {
|
||||
try {
|
||||
const instance = new ManageSongList(hashId)
|
||||
instance.clearSongs()
|
||||
return { success: true, message: '歌单已清空' }
|
||||
} catch (error) {
|
||||
console.error('清空歌单失败:', error)
|
||||
const message = error instanceof SongListError ? error.message : '清空歌单失败'
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
code: error instanceof SongListError ? error.code : 'UNKNOWN_ERROR'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 获取歌单中的歌曲列表
|
||||
ipcMain.handle('songlist:get-songs', async (_, hashId: string) => {
|
||||
try {
|
||||
const instance = new ManageSongList(hashId)
|
||||
const songs = instance.getSongs()
|
||||
return { success: true, data: songs }
|
||||
} catch (error) {
|
||||
console.error('获取歌曲列表失败:', error)
|
||||
const message = error instanceof SongListError ? error.message : '获取歌曲列表失败'
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
code: error instanceof SongListError ? error.code : 'UNKNOWN_ERROR'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 获取歌单歌曲数量
|
||||
ipcMain.handle('songlist:get-song-count', async (_, hashId: string) => {
|
||||
try {
|
||||
const instance = new ManageSongList(hashId)
|
||||
const count = instance.getCount()
|
||||
return { success: true, data: count }
|
||||
} catch (error) {
|
||||
console.error('获取歌曲数量失败:', error)
|
||||
const message = error instanceof SongListError ? error.message : '获取歌曲数量失败'
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
code: error instanceof SongListError ? error.code : 'UNKNOWN_ERROR'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 检查歌曲是否在歌单中
|
||||
ipcMain.handle('songlist:has-song', async (_, hashId: string, songmid: string | number) => {
|
||||
try {
|
||||
const instance = new ManageSongList(hashId)
|
||||
const hasSong = instance.hasSong(songmid)
|
||||
return { success: true, data: hasSong }
|
||||
} catch (error) {
|
||||
console.error('检查歌曲存在性失败:', error)
|
||||
return { success: false, error: '检查歌曲存在性失败', data: false }
|
||||
}
|
||||
})
|
||||
|
||||
// 根据ID获取歌曲
|
||||
ipcMain.handle('songlist:get-song', async (_, hashId: string, songmid: string | number) => {
|
||||
try {
|
||||
const instance = new ManageSongList(hashId)
|
||||
const song = instance.getSong(songmid)
|
||||
return { success: true, data: song }
|
||||
} catch (error) {
|
||||
console.error('获取歌曲失败:', error)
|
||||
return { success: false, error: '获取歌曲失败', data: null }
|
||||
}
|
||||
})
|
||||
|
||||
// 搜索歌单中的歌曲
|
||||
ipcMain.handle('songlist:search-songs', async (_, hashId: string, keyword: string) => {
|
||||
try {
|
||||
const instance = new ManageSongList(hashId)
|
||||
const results = instance.searchSongs(keyword)
|
||||
return { success: true, data: results }
|
||||
} catch (error) {
|
||||
console.error('搜索歌曲失败:', error)
|
||||
return { success: false, error: '搜索歌曲失败', data: [] }
|
||||
}
|
||||
})
|
||||
|
||||
// 获取歌单歌曲统计信息
|
||||
ipcMain.handle('songlist:get-song-statistics', async (_, hashId: string) => {
|
||||
try {
|
||||
const instance = new ManageSongList(hashId)
|
||||
const statistics = instance.getStatistics()
|
||||
return { success: true, data: statistics }
|
||||
} catch (error) {
|
||||
console.error('获取歌曲统计信息失败:', error)
|
||||
return { success: false, error: '获取歌曲统计信息失败' }
|
||||
}
|
||||
})
|
||||
|
||||
// 验证歌单完整性
|
||||
ipcMain.handle('songlist:validate-integrity', async (_, hashId: string) => {
|
||||
try {
|
||||
const instance = new ManageSongList(hashId)
|
||||
const result = instance.validateIntegrity()
|
||||
return { success: true, data: result }
|
||||
} catch (error) {
|
||||
console.error('验证歌单完整性失败:', error)
|
||||
return { success: false, error: '验证歌单完整性失败' }
|
||||
}
|
||||
})
|
||||
|
||||
// 修复歌单数据
|
||||
ipcMain.handle('songlist:repair-data', async (_, hashId: string) => {
|
||||
try {
|
||||
const instance = new ManageSongList(hashId)
|
||||
const result = instance.repairData()
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
message: result.fixed ? `数据修复完成: ${result.changes.join(', ')}` : '数据无需修复'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('修复歌单数据失败:', error)
|
||||
const message = error instanceof SongListError ? error.message : '修复歌单数据失败'
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
code: error instanceof SongListError ? error.code : 'UNKNOWN_ERROR'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 强制保存歌单
|
||||
ipcMain.handle('songlist:force-save', async (_, hashId: string) => {
|
||||
try {
|
||||
const instance = new ManageSongList(hashId)
|
||||
instance.forceSave()
|
||||
return { success: true, message: '歌单保存成功' }
|
||||
} catch (error) {
|
||||
console.error('强制保存歌单失败:', error)
|
||||
const message = error instanceof SongListError ? error.message : '强制保存歌单失败'
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
code: error instanceof SongListError ? error.code : 'UNKNOWN_ERROR'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -8,6 +8,24 @@ import pluginService from './services/plugin'
|
||||
import aiEvents from './events/ai'
|
||||
import './services/musicSdk/index'
|
||||
|
||||
// 获取单实例锁
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
|
||||
if (!gotTheLock) {
|
||||
// 如果没有获得锁,说明已经有实例在运行,退出当前实例
|
||||
app.quit()
|
||||
} else {
|
||||
// 当第二个实例尝试启动时,聚焦到第一个实例的窗口
|
||||
app.on('second-instance', () => {
|
||||
// 如果有窗口存在,聚焦到该窗口
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
if (!mainWindow.isVisible()) mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// import wy from './utils/musicSdk/wy/index'
|
||||
// import kg from './utils/musicSdk/kg/index'
|
||||
// wy.hotSearch.getList().then((res) => {
|
||||
@@ -40,6 +58,7 @@ function createTray(): void {
|
||||
label: '播放/暂停',
|
||||
click: () => {
|
||||
// 这里可以添加播放控制逻辑
|
||||
console.log('music-control')
|
||||
mainWindow?.webContents.send('music-control')
|
||||
}
|
||||
},
|
||||
@@ -75,7 +94,7 @@ function createWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1100,
|
||||
height: 750,
|
||||
minWidth: 970,
|
||||
minWidth: 1100,
|
||||
minHeight: 670,
|
||||
show: false,
|
||||
center: true,
|
||||
@@ -91,7 +110,8 @@ function createWindow(): void {
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false
|
||||
contextIsolation: false,
|
||||
backgroundThrottling: false
|
||||
}
|
||||
})
|
||||
if (process.platform == 'darwin') mainWindow.setWindowButtonVisibility(false)
|
||||
@@ -190,25 +210,33 @@ ipcMain.handle('service-music-request', async (_, api, args) => {
|
||||
return await musicService.request(api, args)
|
||||
})
|
||||
|
||||
// 获取应用版本号
|
||||
ipcMain.handle('get-app-version', () => {
|
||||
return app.getVersion()
|
||||
})
|
||||
|
||||
aiEvents(mainWindow)
|
||||
import './events/musicCache'
|
||||
import './events/songList'
|
||||
import { registerAutoUpdateEvents, initAutoUpdateForWindow } from './events/autoUpdate'
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.whenReady().then(async () => {
|
||||
app.whenReady().then(() => {
|
||||
// Set app user model id for windows
|
||||
|
||||
electronApp.setAppUserModelId('com.cerulean.music')
|
||||
|
||||
// 初始化插件系统
|
||||
try {
|
||||
await pluginService.initializePlugins()
|
||||
console.log('插件系统初始化完成')
|
||||
} catch (error) {
|
||||
console.error('插件系统初始化失败:', error)
|
||||
}
|
||||
setTimeout(async () => {
|
||||
// 初始化插件系统
|
||||
try {
|
||||
await pluginService.initializePlugins()
|
||||
console.log('插件系统初始化完成')
|
||||
} catch (error) {
|
||||
console.error('插件系统初始化失败:', error)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
@@ -217,9 +245,6 @@ app.whenReady().then(async () => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
// IPC test
|
||||
ipcMain.on('ping', () => console.log('pong'))
|
||||
|
||||
// 窗口控制 IPC 处理
|
||||
ipcMain.on('window-minimize', () => {
|
||||
const window = BrowserWindow.getFocusedWindow()
|
||||
@@ -277,10 +302,17 @@ app.whenReady().then(async () => {
|
||||
|
||||
createWindow()
|
||||
createTray()
|
||||
|
||||
|
||||
// 注册自动更新事件
|
||||
registerAutoUpdateEvents()
|
||||
|
||||
ipcMain.on('startPing', () => {
|
||||
if (ping) clearInterval(ping)
|
||||
console.log('start-----开始')
|
||||
startPing()
|
||||
})
|
||||
ipcMain.on('stopPing', () => {
|
||||
clearInterval(ping)
|
||||
})
|
||||
// 初始化自动更新器
|
||||
if (mainWindow) {
|
||||
initAutoUpdateForWindow(mainWindow)
|
||||
@@ -306,3 +338,60 @@ app.on('before-quit', () => {
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
// code. You can also put them in separate files and require them here.
|
||||
|
||||
let ping: NodeJS.Timeout
|
||||
function startPing() {
|
||||
let interval = 3000
|
||||
|
||||
ping = setInterval(() => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents
|
||||
.executeJavaScript(
|
||||
`
|
||||
(function() {
|
||||
const audio = document.getElementById("globaAudio");
|
||||
if(!audio) return { playing:false, ended: false };
|
||||
|
||||
if(audio.ended) return { playing:false, ended: true };
|
||||
|
||||
return { playing: !audio.paused, ended: false, currentTime: audio.currentTime, duration: audio.duration };
|
||||
})()
|
||||
`
|
||||
)
|
||||
.then((res) => {
|
||||
console.log(res)
|
||||
if (res.duration - res.currentTime <= 20) {
|
||||
clearInterval(ping)
|
||||
interval = 500
|
||||
ping = setInterval(() => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents
|
||||
.executeJavaScript(
|
||||
`
|
||||
(function() {
|
||||
const audio = document.getElementById("globaAudio");
|
||||
if(!audio) return { playing:false, ended: false };
|
||||
|
||||
if(audio.ended) return { playing:false, ended: true };
|
||||
|
||||
return { playing: !audio.paused, ended: false, currentTime: audio.currentTime, duration: audio.duration };
|
||||
})()
|
||||
`
|
||||
)
|
||||
.then((res) => {
|
||||
console.log(res)
|
||||
if (res && res.ended) {
|
||||
mainWindow?.webContents.send('song-ended')
|
||||
console.log('next song')
|
||||
clearInterval(ping)
|
||||
}
|
||||
})
|
||||
.catch((err) => console.warn(err))
|
||||
}
|
||||
}, interval)
|
||||
}
|
||||
})
|
||||
.catch((err) => console.warn(err))
|
||||
}
|
||||
}, interval)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as fs from 'fs/promises'
|
||||
import * as crypto from 'crypto'
|
||||
import axios from 'axios'
|
||||
|
||||
|
||||
export class MusicCacheService {
|
||||
private cacheDir: string
|
||||
private cacheIndex: Map<string, string> = new Map()
|
||||
@@ -20,7 +19,7 @@ export class MusicCacheService {
|
||||
try {
|
||||
// 确保缓存目录存在
|
||||
await fs.mkdir(this.cacheDir, { recursive: true })
|
||||
|
||||
|
||||
// 加载缓存索引
|
||||
await this.loadCacheIndex()
|
||||
} catch (error) {
|
||||
@@ -60,12 +59,12 @@ export class MusicCacheService {
|
||||
|
||||
async getCachedMusicUrl(songId: string, originalUrlPromise: Promise<string>): Promise<string> {
|
||||
const cacheKey = this.generateCacheKey(songId)
|
||||
console.log('hash',cacheKey)
|
||||
|
||||
console.log('hash', cacheKey)
|
||||
|
||||
// 检查是否已缓存
|
||||
if (this.cacheIndex.has(cacheKey)) {
|
||||
const cachedFilePath = this.cacheIndex.get(cacheKey)!
|
||||
|
||||
|
||||
try {
|
||||
// 验证文件是否存在
|
||||
await fs.access(cachedFilePath)
|
||||
@@ -86,7 +85,7 @@ export class MusicCacheService {
|
||||
private async downloadAndCache(songId: string, url: string, cacheKey: string): Promise<string> {
|
||||
try {
|
||||
console.log(`开始下载歌曲: ${songId}`)
|
||||
|
||||
|
||||
const response = await axios({
|
||||
method: 'GET',
|
||||
url: url,
|
||||
@@ -108,7 +107,7 @@ export class MusicCacheService {
|
||||
// 更新缓存索引
|
||||
this.cacheIndex.set(cacheKey, cacheFilePath)
|
||||
await this.saveCacheIndex()
|
||||
|
||||
|
||||
console.log(`歌曲缓存完成: ${cacheFilePath}`)
|
||||
resolve(`file://${cacheFilePath}`)
|
||||
} catch (error) {
|
||||
@@ -143,7 +142,7 @@ export class MusicCacheService {
|
||||
// 清空缓存索引
|
||||
this.cacheIndex.clear()
|
||||
await this.saveCacheIndex()
|
||||
|
||||
|
||||
console.log('音乐缓存已清空')
|
||||
} catch (error) {
|
||||
console.error('清空缓存失败:', error)
|
||||
@@ -152,7 +151,7 @@ export class MusicCacheService {
|
||||
|
||||
async getCacheSize(): Promise<number> {
|
||||
let totalSize = 0
|
||||
|
||||
|
||||
for (const filePath of this.cacheIndex.values()) {
|
||||
try {
|
||||
const stats = await fs.stat(filePath)
|
||||
@@ -161,14 +160,14 @@ export class MusicCacheService {
|
||||
// 文件不存在,忽略
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return totalSize
|
||||
}
|
||||
|
||||
async getCacheInfo(): Promise<{ count: number; size: number; sizeFormatted: string }> {
|
||||
const size = await this.getCacheSize()
|
||||
const count = this.cacheIndex.size
|
||||
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
@@ -186,4 +185,4 @@ export class MusicCacheService {
|
||||
}
|
||||
|
||||
// 单例实例
|
||||
export const musicCacheService = new MusicCacheService()
|
||||
export const musicCacheService = new MusicCacheService()
|
||||
|
||||
@@ -19,7 +19,7 @@ export function request<T extends keyof MainApi>(
|
||||
return (Api[method] as (args: any) => any)(args)
|
||||
}
|
||||
throw new Error(`未知的方法: ${method}`)
|
||||
}catch (error:any){
|
||||
} catch (error: any) {
|
||||
throw new Error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import { fileURLToPath } from 'url'
|
||||
|
||||
const fileLock: Record<string, boolean> = {}
|
||||
|
||||
|
||||
function main(source: string) {
|
||||
const Api = musicSdk[source]
|
||||
return {
|
||||
@@ -38,7 +37,6 @@ function main(source: string) {
|
||||
// 获取原始URL
|
||||
const originalUrlPromise = usePlugin.getMusicUrl(source, songInfo, quality)
|
||||
|
||||
|
||||
// 生成歌曲唯一标识
|
||||
const songId = `${songInfo.name}-${songInfo.singer}-${source}-${quality}`
|
||||
|
||||
@@ -161,6 +159,26 @@ function main(source: string) {
|
||||
message: '下载成功',
|
||||
path: songPath
|
||||
}
|
||||
},
|
||||
|
||||
async parsePlaylistId({ url }: { url: string }) {
|
||||
try {
|
||||
return await Api.songList.handleParseId(url)
|
||||
} catch (e: any) {
|
||||
return {
|
||||
error: '解析歌单链接失败 ' + (e.error || e.message || e)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async getPlaylistDetailById(id: string, page: number = 1) {
|
||||
try {
|
||||
return await Api.songList.getListDetail(id, page)
|
||||
} catch (e: any) {
|
||||
return {
|
||||
error: '获取歌单详情失败 ' + (e.error || e.message || e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,4 +92,4 @@ export interface PlaylistDetailResult {
|
||||
|
||||
export interface DownloadSingleSongArgs extends GetMusicUrlArg {
|
||||
path?: string
|
||||
}
|
||||
}
|
||||
|
||||
755
src/main/services/songList/ManageSongList.ts
Normal file
@@ -0,0 +1,755 @@
|
||||
import type { SongList, Songs } from '@common/types/songList'
|
||||
import PlayListSongs from './PlayListSongs'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import { getAppDirPath } from '../../utils/path'
|
||||
|
||||
// 常量定义
|
||||
const DEFAULT_COVER_IDENTIFIER = 'default-cover'
|
||||
const SONGLIST_DIR = 'songList'
|
||||
const INDEX_FILE = 'index.json'
|
||||
|
||||
// 错误类型定义
|
||||
class SongListError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code?: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'SongListError'
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数类
|
||||
class SongListUtils {
|
||||
/**
|
||||
* 获取默认封面标识符
|
||||
*/
|
||||
static getDefaultCoverUrl(): string {
|
||||
return DEFAULT_COVER_IDENTIFIER
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取歌单管理入口文件路径
|
||||
*/
|
||||
static getSongListIndexPath(): string {
|
||||
return path.join(getAppDirPath('userData'), SONGLIST_DIR, INDEX_FILE)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取歌单文件路径
|
||||
*/
|
||||
static getSongListFilePath(hashId: string): string {
|
||||
return path.join(getAppDirPath('userData'), SONGLIST_DIR, `${hashId}.json`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保目录存在
|
||||
*/
|
||||
static ensureDirectoryExists(dirPath: string): void {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一hashId
|
||||
*/
|
||||
static generateUniqueId(name: string): string {
|
||||
return crypto.createHash('md5').update(`${name}_${Date.now()}_${Math.random()}`).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证歌曲封面URL是否有效
|
||||
*/
|
||||
static isValidCoverUrl(url: string | undefined | null): boolean {
|
||||
return Boolean(url && url.trim() !== '' && url !== DEFAULT_COVER_IDENTIFIER)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证hashId格式
|
||||
*/
|
||||
static isValidHashId(hashId: string): boolean {
|
||||
return Boolean(hashId && typeof hashId === 'string' && hashId.trim().length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全的JSON解析
|
||||
*/
|
||||
static safeJsonParse<T>(content: string, defaultValue: T): T {
|
||||
try {
|
||||
return JSON.parse(content) as T
|
||||
} catch {
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class ManageSongList extends PlayListSongs {
|
||||
private readonly hashId: string
|
||||
|
||||
constructor(hashId: string) {
|
||||
if (!SongListUtils.isValidHashId(hashId)) {
|
||||
throw new SongListError('无效的歌单ID', 'INVALID_HASH_ID')
|
||||
}
|
||||
|
||||
super(hashId)
|
||||
this.hashId = hashId.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 静态方法:创建新歌单
|
||||
* @param name 歌单名称
|
||||
* @param description 歌单描述
|
||||
* @param source 歌单来源
|
||||
* @returns 包含hashId的对象 (id字段就是hashId)
|
||||
*/
|
||||
static createPlaylist(
|
||||
name: string,
|
||||
description: string = '',
|
||||
source: SongList['source']
|
||||
): { id: string } {
|
||||
// 参数验证
|
||||
if (!name?.trim()) {
|
||||
throw new SongListError('歌单名称不能为空', 'EMPTY_NAME')
|
||||
}
|
||||
|
||||
if (!source) {
|
||||
throw new SongListError('歌单来源不能为空', 'EMPTY_SOURCE')
|
||||
}
|
||||
|
||||
try {
|
||||
const id = SongListUtils.generateUniqueId(name)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const songListInfo: SongList = {
|
||||
id,
|
||||
name: name.trim(),
|
||||
createTime: now,
|
||||
updateTime: now,
|
||||
description: description?.trim() || '',
|
||||
coverImgUrl: SongListUtils.getDefaultCoverUrl(),
|
||||
source
|
||||
}
|
||||
|
||||
// 创建歌单文件
|
||||
ManageSongList.createSongListFile(id)
|
||||
|
||||
// 更新入口文件
|
||||
ManageSongList.updateIndexFile(songListInfo, 'add')
|
||||
|
||||
// 验证歌单可以正常实例化
|
||||
try {
|
||||
new ManageSongList(id)
|
||||
// 如果能成功创建实例,说明文件创建成功
|
||||
} catch (verifyError) {
|
||||
console.error('歌单创建验证失败:', verifyError)
|
||||
// 清理已创建的文件
|
||||
try {
|
||||
const filePath = SongListUtils.getSongListFilePath(id)
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath)
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error('清理失败的歌单文件时出错:', cleanupError)
|
||||
}
|
||||
throw new SongListError('歌单创建后验证失败', 'CREATION_VERIFICATION_FAILED')
|
||||
}
|
||||
|
||||
return { id }
|
||||
} catch (error) {
|
||||
console.error('创建歌单失败:', error)
|
||||
if (error instanceof SongListError) {
|
||||
throw error
|
||||
}
|
||||
throw new SongListError(
|
||||
`创建歌单失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
'CREATE_FAILED'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建歌单文件
|
||||
* @param hashId 歌单hashId
|
||||
*/
|
||||
private static createSongListFile(hashId: string): void {
|
||||
const songListFilePath = SongListUtils.getSongListFilePath(hashId)
|
||||
const dir = path.dirname(songListFilePath)
|
||||
|
||||
SongListUtils.ensureDirectoryExists(dir)
|
||||
|
||||
try {
|
||||
// 使用原子性写入确保文件完整性
|
||||
const tempPath = `${songListFilePath}.tmp`
|
||||
const content = JSON.stringify([], null, 2)
|
||||
|
||||
fs.writeFileSync(tempPath, content)
|
||||
fs.renameSync(tempPath, songListFilePath)
|
||||
|
||||
// 确保文件确实存在且可读
|
||||
if (!fs.existsSync(songListFilePath)) {
|
||||
throw new Error('文件创建后验证失败')
|
||||
}
|
||||
|
||||
// 验证文件内容
|
||||
const verifyContent = fs.readFileSync(songListFilePath, 'utf-8')
|
||||
JSON.parse(verifyContent) // 确保内容是有效的JSON
|
||||
} catch (error) {
|
||||
throw new SongListError(
|
||||
`创建歌单文件失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
'FILE_CREATE_FAILED'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除当前歌单
|
||||
*/
|
||||
delete(): void {
|
||||
const hashId = this.getHashId()
|
||||
|
||||
try {
|
||||
// 检查歌单是否存在
|
||||
if (!ManageSongList.exists(hashId)) {
|
||||
throw new SongListError('歌单不存在', 'PLAYLIST_NOT_FOUND')
|
||||
}
|
||||
|
||||
// 删除歌单文件
|
||||
const filePath = SongListUtils.getSongListFilePath(hashId)
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath)
|
||||
}
|
||||
|
||||
// 从入口文件中移除
|
||||
ManageSongList.updateIndexFile({ id: hashId } as SongList, 'remove')
|
||||
} catch (error) {
|
||||
console.error('删除歌单失败:', error)
|
||||
if (error instanceof SongListError) {
|
||||
throw error
|
||||
}
|
||||
throw new SongListError(
|
||||
`删除歌单失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
'DELETE_FAILED'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改当前歌单信息
|
||||
* @param updates 要更新的字段
|
||||
*/
|
||||
edit(updates: Partial<Omit<SongList, 'id' | 'createTime'>>): void {
|
||||
if (!updates || Object.keys(updates).length === 0) {
|
||||
throw new SongListError('更新内容不能为空', 'EMPTY_UPDATES')
|
||||
}
|
||||
|
||||
const hashId = this.getHashId()
|
||||
|
||||
try {
|
||||
const songLists = ManageSongList.readIndexFile()
|
||||
const index = songLists.findIndex((item) => item.id === hashId)
|
||||
|
||||
if (index === -1) {
|
||||
throw new SongListError('歌单不存在', 'PLAYLIST_NOT_FOUND')
|
||||
}
|
||||
|
||||
// 验证和清理更新数据
|
||||
const cleanUpdates = ManageSongList.validateAndCleanUpdates(updates)
|
||||
|
||||
// 更新歌单信息
|
||||
songLists[index] = {
|
||||
...songLists[index],
|
||||
...cleanUpdates,
|
||||
updateTime: new Date().toISOString()
|
||||
}
|
||||
|
||||
// 保存到入口文件
|
||||
ManageSongList.writeIndexFile(songLists)
|
||||
} catch (error) {
|
||||
console.error('修改歌单失败:', error)
|
||||
if (error instanceof SongListError) {
|
||||
throw error
|
||||
}
|
||||
throw new SongListError(
|
||||
`修改歌单失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
'EDIT_FAILED'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前歌单的hashId
|
||||
* @returns hashId
|
||||
*/
|
||||
private getHashId(): string {
|
||||
return this.hashId
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证和清理更新数据
|
||||
* @param updates 原始更新数据
|
||||
* @returns 清理后的更新数据
|
||||
*/
|
||||
private static validateAndCleanUpdates(
|
||||
updates: Partial<Omit<SongList, 'id' | 'createTime'>>
|
||||
): Partial<Omit<SongList, 'id' | 'createTime'>> {
|
||||
const cleanUpdates: Partial<Omit<SongList, 'id' | 'createTime'>> = {}
|
||||
|
||||
// 验证歌单名称
|
||||
if (updates.name !== undefined) {
|
||||
const trimmedName = updates.name.trim()
|
||||
if (!trimmedName) {
|
||||
throw new SongListError('歌单名称不能为空', 'EMPTY_NAME')
|
||||
}
|
||||
cleanUpdates.name = trimmedName
|
||||
}
|
||||
|
||||
// 处理描述
|
||||
if (updates.description !== undefined) {
|
||||
cleanUpdates.description = updates.description?.trim() || ''
|
||||
}
|
||||
|
||||
// 处理封面URL
|
||||
if (updates.coverImgUrl !== undefined) {
|
||||
cleanUpdates.coverImgUrl = updates.coverImgUrl || SongListUtils.getDefaultCoverUrl()
|
||||
}
|
||||
|
||||
// 处理来源
|
||||
if (updates.source !== undefined) {
|
||||
if (!updates.source) {
|
||||
throw new SongListError('歌单来源不能为空', 'EMPTY_SOURCE')
|
||||
}
|
||||
cleanUpdates.source = updates.source
|
||||
}
|
||||
|
||||
return cleanUpdates
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取歌单列表
|
||||
* @returns 歌单列表数组
|
||||
*/
|
||||
static Read(): SongList[] {
|
||||
try {
|
||||
return ManageSongList.readIndexFile()
|
||||
} catch (error) {
|
||||
console.error('读取歌单列表失败:', error)
|
||||
if (error instanceof SongListError) {
|
||||
throw error
|
||||
}
|
||||
throw new SongListError(
|
||||
`读取歌单列表失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
'READ_FAILED'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据hashId获取单个歌单信息
|
||||
* @param hashId 歌单hashId
|
||||
* @returns 歌单信息或null
|
||||
*/
|
||||
static getById(hashId: string): SongList | null {
|
||||
if (!SongListUtils.isValidHashId(hashId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const songLists = ManageSongList.readIndexFile()
|
||||
return songLists.find((item) => item.id === hashId) || null
|
||||
} catch (error) {
|
||||
console.error('获取歌单信息失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取入口文件
|
||||
* @returns 歌单列表数组
|
||||
*/
|
||||
private static readIndexFile(): SongList[] {
|
||||
const indexPath = SongListUtils.getSongListIndexPath()
|
||||
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
ManageSongList.initializeIndexFile()
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(indexPath, 'utf-8')
|
||||
const parsed = SongListUtils.safeJsonParse<unknown>(content, [])
|
||||
|
||||
// 验证数据格式
|
||||
if (!Array.isArray(parsed)) {
|
||||
console.warn('入口文件格式错误,重新初始化')
|
||||
ManageSongList.initializeIndexFile()
|
||||
return []
|
||||
}
|
||||
|
||||
return parsed as SongList[]
|
||||
} catch (error) {
|
||||
console.error('解析入口文件失败:', error)
|
||||
// 备份损坏的文件并重新初始化
|
||||
ManageSongList.backupCorruptedFile(indexPath)
|
||||
ManageSongList.initializeIndexFile()
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 备份损坏的文件
|
||||
* @param filePath 文件路径
|
||||
*/
|
||||
private static backupCorruptedFile(filePath: string): void {
|
||||
try {
|
||||
const backupPath = `${filePath}.backup.${Date.now()}`
|
||||
fs.copyFileSync(filePath, backupPath)
|
||||
console.log(`已备份损坏的文件到: ${backupPath}`)
|
||||
} catch (error) {
|
||||
console.error('备份损坏文件失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化入口文件
|
||||
*/
|
||||
private static initializeIndexFile(): void {
|
||||
const indexPath = SongListUtils.getSongListIndexPath()
|
||||
const dir = path.dirname(indexPath)
|
||||
|
||||
SongListUtils.ensureDirectoryExists(dir)
|
||||
|
||||
try {
|
||||
fs.writeFileSync(indexPath, JSON.stringify([], null, 2))
|
||||
} catch (error) {
|
||||
throw new SongListError(
|
||||
`初始化入口文件失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
'INIT_FAILED'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入入口文件
|
||||
* @param songLists 歌单列表
|
||||
*/
|
||||
private static writeIndexFile(songLists: SongList[]): void {
|
||||
if (!Array.isArray(songLists)) {
|
||||
throw new SongListError('歌单列表必须是数组格式', 'INVALID_DATA_FORMAT')
|
||||
}
|
||||
|
||||
const indexPath = SongListUtils.getSongListIndexPath()
|
||||
const dir = path.dirname(indexPath)
|
||||
|
||||
SongListUtils.ensureDirectoryExists(dir)
|
||||
|
||||
try {
|
||||
// 先写入临时文件,再重命名,确保原子性操作
|
||||
const tempPath = `${indexPath}.tmp`
|
||||
fs.writeFileSync(tempPath, JSON.stringify(songLists, null, 2))
|
||||
fs.renameSync(tempPath, indexPath)
|
||||
} catch (error) {
|
||||
console.error('写入入口文件失败:', error)
|
||||
throw new SongListError(
|
||||
`写入入口文件失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
'WRITE_FAILED'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新入口文件
|
||||
* @param songListInfo 歌单信息
|
||||
* @param action 操作类型
|
||||
*/
|
||||
private static updateIndexFile(songListInfo: SongList, action: 'add' | 'remove'): void {
|
||||
const songLists = ManageSongList.readIndexFile()
|
||||
|
||||
switch (action) {
|
||||
case 'add':
|
||||
// 检查是否已存在,避免重复添加
|
||||
if (!songLists.some((item) => item.id === songListInfo.id)) {
|
||||
songLists.push(songListInfo)
|
||||
}
|
||||
break
|
||||
|
||||
case 'remove':
|
||||
const index = songLists.findIndex((item) => item.id === songListInfo.id)
|
||||
if (index !== -1) {
|
||||
songLists.splice(index, 1)
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
throw new SongListError(`不支持的操作类型: ${action}`, 'INVALID_ACTION')
|
||||
}
|
||||
|
||||
ManageSongList.writeIndexFile(songLists)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新当前歌单封面图片URL
|
||||
* @param coverImgUrl 封面图片URL
|
||||
*/
|
||||
updateCoverImg(coverImgUrl: string): void {
|
||||
try {
|
||||
const finalCoverUrl = coverImgUrl || SongListUtils.getDefaultCoverUrl()
|
||||
this.edit({ coverImgUrl: finalCoverUrl })
|
||||
} catch (error) {
|
||||
console.error('更新封面失败:', error)
|
||||
if (error instanceof SongListError) {
|
||||
throw error
|
||||
}
|
||||
throw new SongListError(
|
||||
`更新封面失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
'UPDATE_COVER_FAILED'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重写父类的addSongs方法,添加自动设置封面功能
|
||||
* @param songs 要添加的歌曲列表
|
||||
*/
|
||||
addSongs(songs: Songs[]): void {
|
||||
if (!Array.isArray(songs) || songs.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// 调用父类方法添加歌曲
|
||||
super.addSongs(songs)
|
||||
|
||||
// 异步更新封面,不阻塞主要功能
|
||||
setImmediate(() => {
|
||||
this.updateCoverIfNeeded(songs)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并更新封面图片
|
||||
* @param newSongs 新添加的歌曲列表
|
||||
*/
|
||||
private updateCoverIfNeeded(newSongs: Songs[]): void {
|
||||
try {
|
||||
const currentPlaylist = ManageSongList.getById(this.hashId)
|
||||
|
||||
if (!currentPlaylist) {
|
||||
console.warn(`歌单 ${this.hashId} 不存在,跳过封面更新`)
|
||||
return
|
||||
}
|
||||
|
||||
const shouldUpdateCover = this.shouldUpdateCover(currentPlaylist.coverImgUrl)
|
||||
|
||||
if (shouldUpdateCover) {
|
||||
const validCoverUrl = this.findValidCoverFromSongs(newSongs)
|
||||
|
||||
if (validCoverUrl) {
|
||||
this.updateCoverImg(validCoverUrl)
|
||||
} else if (
|
||||
!currentPlaylist.coverImgUrl ||
|
||||
currentPlaylist.coverImgUrl === SongListUtils.getDefaultCoverUrl()
|
||||
) {
|
||||
// 如果没有找到有效封面且当前也没有封面,设置默认封面
|
||||
this.updateCoverImg(SongListUtils.getDefaultCoverUrl())
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新封面失败:', error)
|
||||
// 不抛出错误,避免影响添加歌曲的主要功能
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否应该更新封面
|
||||
* @param currentCoverUrl 当前封面URL
|
||||
* @returns 是否应该更新
|
||||
*/
|
||||
private shouldUpdateCover(currentCoverUrl: string): boolean {
|
||||
return !currentCoverUrl || currentCoverUrl === SongListUtils.getDefaultCoverUrl()
|
||||
}
|
||||
|
||||
/**
|
||||
* 从歌曲列表中查找有效的封面图片
|
||||
* @param songs 歌曲列表
|
||||
* @returns 有效的封面URL或null
|
||||
*/
|
||||
private findValidCoverFromSongs(songs: Songs[]): string | null {
|
||||
// 优先检查新添加的歌曲
|
||||
for (const song of songs) {
|
||||
if (SongListUtils.isValidCoverUrl(song.img)) {
|
||||
return song.img
|
||||
}
|
||||
}
|
||||
|
||||
// 如果新添加的歌曲都没有封面,检查当前歌单中的所有歌曲
|
||||
try {
|
||||
for (const song of this.list) {
|
||||
if (SongListUtils.isValidCoverUrl(song.img)) {
|
||||
return song.img
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取歌单歌曲列表失败:', error)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查歌单是否存在
|
||||
* @param hashId 歌单hashId
|
||||
* @returns 是否存在
|
||||
*/
|
||||
static exists(hashId: string): boolean {
|
||||
if (!SongListUtils.isValidHashId(hashId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const songLists = ManageSongList.readIndexFile()
|
||||
return songLists.some((item) => item.id === hashId)
|
||||
} catch (error) {
|
||||
console.error('检查歌单存在性失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取歌单统计信息
|
||||
* @returns 统计信息
|
||||
*/
|
||||
static getStatistics(): { total: number; bySource: Record<string, number>; lastUpdated: string } {
|
||||
try {
|
||||
const songLists = ManageSongList.readIndexFile()
|
||||
const bySource: Record<string, number> = {}
|
||||
|
||||
songLists.forEach((playlist) => {
|
||||
const source = playlist.source || 'unknown'
|
||||
bySource[source] = (bySource[source] || 0) + 1
|
||||
})
|
||||
|
||||
return {
|
||||
total: songLists.length,
|
||||
bySource,
|
||||
lastUpdated: new Date().toISOString()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取统计信息失败:', error)
|
||||
return {
|
||||
total: 0,
|
||||
bySource: {},
|
||||
lastUpdated: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前歌单信息
|
||||
* @returns 歌单信息或null
|
||||
*/
|
||||
getPlaylistInfo(): SongList | null {
|
||||
return ManageSongList.getById(this.hashId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量操作:删除多个歌单
|
||||
* @param hashIds 歌单ID数组
|
||||
* @returns 操作结果
|
||||
*/
|
||||
static batchDelete(hashIds: string[]): { success: string[]; failed: string[] } {
|
||||
const result = { success: [] as string[], failed: [] as string[] }
|
||||
|
||||
for (const hashId of hashIds) {
|
||||
try {
|
||||
ManageSongList.deleteById(hashId)
|
||||
result.success.push(hashId)
|
||||
} catch (error) {
|
||||
console.error(`删除歌单 ${hashId} 失败:`, error)
|
||||
result.failed.push(hashId)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索歌单
|
||||
* @param keyword 搜索关键词
|
||||
* @param source 可选的来源筛选
|
||||
* @returns 匹配的歌单列表
|
||||
*/
|
||||
static search(keyword: string, source?: SongList['source']): SongList[] {
|
||||
if (!keyword?.trim()) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const songLists = ManageSongList.readIndexFile()
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
|
||||
return songLists.filter((playlist) => {
|
||||
const matchesKeyword =
|
||||
playlist.name.toLowerCase().includes(lowerKeyword) ||
|
||||
playlist.description.toLowerCase().includes(lowerKeyword)
|
||||
const matchesSource = !source || playlist.source === source
|
||||
|
||||
return matchesKeyword && matchesSource
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('搜索歌单失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 静态方法别名,用于删除和编辑指定hashId的歌单
|
||||
/**
|
||||
* 静态方法:删除指定歌单
|
||||
* @param hashId 歌单hashId
|
||||
*/
|
||||
static deleteById(hashId: string): void {
|
||||
if (!SongListUtils.isValidHashId(hashId)) {
|
||||
throw new SongListError('无效的歌单ID', 'INVALID_HASH_ID')
|
||||
}
|
||||
|
||||
const instance = new ManageSongList(hashId)
|
||||
instance.delete()
|
||||
}
|
||||
|
||||
/**
|
||||
* 静态方法:编辑指定歌单
|
||||
* @param hashId 歌单hashId
|
||||
* @param updates 要更新的字段
|
||||
*/
|
||||
static editById(hashId: string, updates: Partial<Omit<SongList, 'id' | 'createTime'>>): void {
|
||||
if (!SongListUtils.isValidHashId(hashId)) {
|
||||
throw new SongListError('无效的歌单ID', 'INVALID_HASH_ID')
|
||||
}
|
||||
|
||||
const instance = new ManageSongList(hashId)
|
||||
instance.edit(updates)
|
||||
}
|
||||
|
||||
/**
|
||||
* 静态方法:更新指定歌单封面
|
||||
* @param hashId 歌单hashId
|
||||
* @param coverImgUrl 封面图片URL
|
||||
*/
|
||||
static updateCoverImgById(hashId: string, coverImgUrl: string): void {
|
||||
if (!SongListUtils.isValidHashId(hashId)) {
|
||||
throw new SongListError('无效的歌单ID', 'INVALID_HASH_ID')
|
||||
}
|
||||
|
||||
const instance = new ManageSongList(hashId)
|
||||
instance.updateCoverImg(coverImgUrl)
|
||||
}
|
||||
|
||||
// 保持向后兼容的别名方法
|
||||
static Delete = ManageSongList.deleteById
|
||||
static Edit = ManageSongList.editById
|
||||
static read = ManageSongList.Read
|
||||
}
|
||||
|
||||
// 导出错误类供外部使用
|
||||
export { SongListError }
|
||||
452
src/main/services/songList/PlayListSongs.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import type { Songs as SongItem } from '@common/types/songList'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { getAppDirPath } from '../../utils/path'
|
||||
|
||||
// 错误类定义
|
||||
class PlayListError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code?: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'PlayListError'
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数类
|
||||
class PlayListUtils {
|
||||
/**
|
||||
* 获取歌单文件路径
|
||||
*/
|
||||
static getFilePath(hashId: string): string {
|
||||
if (!hashId || typeof hashId !== 'string' || !hashId.trim()) {
|
||||
throw new PlayListError('无效的歌单ID', 'INVALID_HASH_ID')
|
||||
}
|
||||
return path.join(getAppDirPath('userData'), 'songList', `${hashId.trim()}.json`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保目录存在
|
||||
*/
|
||||
static ensureDirectoryExists(dirPath: string): void {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全的JSON解析
|
||||
*/
|
||||
static safeJsonParse<T>(content: string, defaultValue: T): T {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
return parsed as T
|
||||
} catch {
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全的JSON解析(专门用于数组)
|
||||
*/
|
||||
static safeJsonParseArray<T>(content: string, defaultValue: T[]): T[] {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
return Array.isArray(parsed) ? parsed : defaultValue
|
||||
} catch {
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证歌曲对象
|
||||
*/
|
||||
static isValidSong(song: any): song is SongItem {
|
||||
return (
|
||||
song &&
|
||||
typeof song === 'object' &&
|
||||
(typeof song.songmid === 'string' || typeof song.songmid === 'number') &&
|
||||
String(song.songmid).trim().length > 0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 去重歌曲列表
|
||||
*/
|
||||
static deduplicateSongs(songs: SongItem[]): SongItem[] {
|
||||
const seen = new Set<string>()
|
||||
return songs.filter((song) => {
|
||||
const songmidStr = String(song.songmid)
|
||||
if (seen.has(songmidStr)) {
|
||||
return false
|
||||
}
|
||||
seen.add(songmidStr)
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default class PlayListSongs {
|
||||
protected readonly filePath: string
|
||||
protected list: SongItem[]
|
||||
private isDirty: boolean = false
|
||||
|
||||
constructor(hashId: string) {
|
||||
this.filePath = PlayListUtils.getFilePath(hashId)
|
||||
this.list = []
|
||||
this.initList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化歌单列表
|
||||
*/
|
||||
private initList(): void {
|
||||
// 增加重试机制,处理文件创建的时序问题
|
||||
const maxRetries = 3
|
||||
const retryDelay = 100 // 100ms
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
if (!fs.existsSync(this.filePath)) {
|
||||
if (attempt < maxRetries - 1) {
|
||||
// 等待一段时间后重试
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < retryDelay) {
|
||||
// 简单的同步等待
|
||||
}
|
||||
continue
|
||||
}
|
||||
throw new PlayListError('歌单文件不存在', 'FILE_NOT_FOUND')
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(this.filePath, 'utf-8')
|
||||
const parsed = PlayListUtils.safeJsonParseArray<SongItem>(content, [])
|
||||
|
||||
// 验证和清理数据
|
||||
this.list = parsed.filter(PlayListUtils.isValidSong)
|
||||
|
||||
// 如果数据被清理过,标记为需要保存
|
||||
if (this.list.length !== parsed.length) {
|
||||
this.isDirty = true
|
||||
console.warn(
|
||||
`歌单文件包含无效数据,已自动清理 ${parsed.length - this.list.length} 条无效记录`
|
||||
)
|
||||
}
|
||||
|
||||
// 成功读取,退出重试循环
|
||||
return
|
||||
} catch (error) {
|
||||
if (attempt < maxRetries - 1) {
|
||||
console.warn(`读取歌单文件失败,第 ${attempt + 1} 次重试:`, error)
|
||||
// 等待一段时间后重试
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < retryDelay) {
|
||||
// 简单的同步等待
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
console.error('读取歌单文件失败:', error)
|
||||
throw new PlayListError(
|
||||
`读取歌单失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
'READ_FAILED'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查歌单文件是否存在
|
||||
*/
|
||||
static hasListFile(hashId: string): boolean {
|
||||
try {
|
||||
const filePath = PlayListUtils.getFilePath(hashId)
|
||||
return fs.existsSync(filePath)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加歌曲到歌单
|
||||
*/
|
||||
addSongs(songs: SongItem[]): void {
|
||||
if (!Array.isArray(songs) || songs.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证和过滤有效歌曲
|
||||
const validSongs = songs.filter(PlayListUtils.isValidSong)
|
||||
if (validSongs.length === 0) {
|
||||
console.warn('没有有效的歌曲可添加')
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 Set 提高查重性能,统一转换为字符串进行比较
|
||||
const existingSongMids = new Set(this.list.map((song) => String(song.songmid)))
|
||||
|
||||
// 添加不重复的歌曲
|
||||
const newSongs = validSongs.filter((song) => !existingSongMids.has(String(song.songmid)))
|
||||
|
||||
if (newSongs.length > 0) {
|
||||
this.list.push(...newSongs)
|
||||
this.isDirty = true
|
||||
this.saveToFile()
|
||||
|
||||
console.log(
|
||||
`成功添加 ${newSongs.length} 首歌曲,跳过 ${validSongs.length - newSongs.length} 首重复歌曲`
|
||||
)
|
||||
} else {
|
||||
console.log('所有歌曲都已存在,未添加任何歌曲')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从歌单中移除歌曲
|
||||
*/
|
||||
removeSong(songmid: string | number): boolean {
|
||||
if (!songmid && songmid !== 0) {
|
||||
throw new PlayListError('无效的歌曲ID', 'INVALID_SONG_ID')
|
||||
}
|
||||
|
||||
const songmidStr = String(songmid)
|
||||
const index = this.list.findIndex((item) => String(item.songmid) === songmidStr)
|
||||
if (index !== -1) {
|
||||
this.list.splice(index, 1)
|
||||
this.isDirty = true
|
||||
this.saveToFile()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量移除歌曲
|
||||
*/
|
||||
removeSongs(songmids: (string | number)[]): { removed: number; notFound: number } {
|
||||
if (!Array.isArray(songmids) || songmids.length === 0) {
|
||||
return { removed: 0, notFound: 0 }
|
||||
}
|
||||
|
||||
const validSongMids = songmids.filter(
|
||||
(id) => (id || id === 0) && (typeof id === 'string' || typeof id === 'number')
|
||||
)
|
||||
const songMidSet = new Set(validSongMids.map((id) => String(id)))
|
||||
|
||||
const initialLength = this.list.length
|
||||
this.list = this.list.filter((song) => !songMidSet.has(String(song.songmid)))
|
||||
|
||||
const removedCount = initialLength - this.list.length
|
||||
const notFoundCount = validSongMids.length - removedCount
|
||||
|
||||
if (removedCount > 0) {
|
||||
this.isDirty = true
|
||||
this.saveToFile()
|
||||
}
|
||||
|
||||
return { removed: removedCount, notFound: notFoundCount }
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空歌单
|
||||
*/
|
||||
clearSongs(): void {
|
||||
if (this.list.length > 0) {
|
||||
this.list = []
|
||||
this.isDirty = true
|
||||
this.saveToFile()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存到文件
|
||||
*/
|
||||
private saveToFile(): void {
|
||||
if (!this.isDirty) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const dir = path.dirname(this.filePath)
|
||||
PlayListUtils.ensureDirectoryExists(dir)
|
||||
|
||||
// 原子性写入:先写临时文件,再重命名
|
||||
const tempPath = `${this.filePath}.tmp`
|
||||
const content = JSON.stringify(this.list, null, 2)
|
||||
|
||||
fs.writeFileSync(tempPath, content)
|
||||
fs.renameSync(tempPath, this.filePath)
|
||||
|
||||
this.isDirty = false
|
||||
} catch (error) {
|
||||
console.error('保存歌单文件失败:', error)
|
||||
throw new PlayListError(
|
||||
`保存歌单失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
'SAVE_FAILED'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制保存到文件
|
||||
*/
|
||||
forceSave(): void {
|
||||
this.isDirty = true
|
||||
this.saveToFile()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取歌曲列表
|
||||
*/
|
||||
getSongs(): readonly SongItem[] {
|
||||
return Object.freeze([...this.list])
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取歌曲数量
|
||||
*/
|
||||
getCount(): number {
|
||||
return this.list.length
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查歌曲是否存在
|
||||
*/
|
||||
hasSong(songmid: string | number): boolean {
|
||||
if (!songmid && songmid !== 0) {
|
||||
return false
|
||||
}
|
||||
const songmidStr = String(songmid)
|
||||
return this.list.some((song) => String(song.songmid) === songmidStr)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据songmid获取歌曲
|
||||
*/
|
||||
getSong(songmid: string | number): SongItem | null {
|
||||
if (!songmid && songmid !== 0) {
|
||||
return null
|
||||
}
|
||||
const songmidStr = String(songmid)
|
||||
return this.list.find((song) => String(song.songmid) === songmidStr) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索歌曲
|
||||
*/
|
||||
searchSongs(keyword: string): SongItem[] {
|
||||
if (!keyword || typeof keyword !== 'string') {
|
||||
return []
|
||||
}
|
||||
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
return this.list.filter(
|
||||
(song) =>
|
||||
song.name?.toLowerCase().includes(lowerKeyword) ||
|
||||
song.singer?.toLowerCase().includes(lowerKeyword) ||
|
||||
song.albumName?.toLowerCase().includes(lowerKeyword)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取歌单统计信息
|
||||
*/
|
||||
getStatistics(): {
|
||||
total: number
|
||||
bySinger: Record<string, number>
|
||||
byAlbum: Record<string, number>
|
||||
lastModified: string
|
||||
} {
|
||||
const bySinger: Record<string, number> = {}
|
||||
const byAlbum: Record<string, number> = {}
|
||||
|
||||
this.list.forEach((song) => {
|
||||
// 统计歌手
|
||||
if (song.singer) {
|
||||
const singerName = String(song.singer)
|
||||
bySinger[singerName] = (bySinger[singerName] || 0) + 1
|
||||
}
|
||||
|
||||
// 统计专辑
|
||||
if (song.albumName) {
|
||||
const albumName = String(song.albumName)
|
||||
byAlbum[albumName] = (byAlbum[albumName] || 0) + 1
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
total: this.list.length,
|
||||
bySinger,
|
||||
byAlbum,
|
||||
lastModified: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证歌单完整性
|
||||
*/
|
||||
validateIntegrity(): { isValid: boolean; issues: string[] } {
|
||||
const issues: string[] = []
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!fs.existsSync(this.filePath)) {
|
||||
issues.push('歌单文件不存在')
|
||||
}
|
||||
|
||||
// 检查数据完整性
|
||||
const invalidSongs = this.list.filter((song) => !PlayListUtils.isValidSong(song))
|
||||
if (invalidSongs.length > 0) {
|
||||
issues.push(`发现 ${invalidSongs.length} 首无效歌曲`)
|
||||
}
|
||||
|
||||
// 检查重复歌曲
|
||||
const songMids = this.list.map((song) => String(song.songmid))
|
||||
const uniqueSongMids = new Set(songMids)
|
||||
if (songMids.length !== uniqueSongMids.size) {
|
||||
issues.push(`发现 ${songMids.length - uniqueSongMids.size} 首重复歌曲`)
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: issues.length === 0,
|
||||
issues
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修复歌单数据
|
||||
*/
|
||||
repairData(): { fixed: boolean; changes: string[] } {
|
||||
const changes: string[] = []
|
||||
let hasChanges = false
|
||||
|
||||
// 移除无效歌曲
|
||||
const validSongs = this.list.filter(PlayListUtils.isValidSong)
|
||||
if (validSongs.length !== this.list.length) {
|
||||
changes.push(`移除了 ${this.list.length - validSongs.length} 首无效歌曲`)
|
||||
this.list = validSongs
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// 去重
|
||||
const deduplicatedSongs = PlayListUtils.deduplicateSongs(this.list)
|
||||
if (deduplicatedSongs.length !== this.list.length) {
|
||||
changes.push(`移除了 ${this.list.length - deduplicatedSongs.length} 首重复歌曲`)
|
||||
this.list = deduplicatedSongs
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
this.isDirty = true
|
||||
this.saveToFile()
|
||||
}
|
||||
|
||||
return {
|
||||
fixed: hasChanges,
|
||||
changes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出错误类供外部使用
|
||||
export { PlayListError }
|
||||
@@ -51,14 +51,14 @@ export default {
|
||||
...sources,
|
||||
init() {
|
||||
const tasks = []
|
||||
for (let source of sources.sources) {
|
||||
let sm = sources[source.id]
|
||||
for (const source of sources.sources) {
|
||||
const sm = sources[source.id]
|
||||
sm && sm.init && tasks.push(sm.init())
|
||||
}
|
||||
return Promise.all(tasks)
|
||||
},
|
||||
async searchMusic({ name, singer, source: s, limit = 25 }) {
|
||||
const trimStr = (str) => (typeof str == 'string' ? str.trim() : str)
|
||||
const trimStr = (str) => (typeof str === 'string' ? str.trim() : str)
|
||||
const musicName = trimStr(name)
|
||||
const tasks = []
|
||||
const excludeSource = ['xm']
|
||||
@@ -106,7 +106,7 @@ export default {
|
||||
const getIntv = (interval) => {
|
||||
if (!interval) return 0
|
||||
// if (musicInfo._interval) return musicInfo._interval
|
||||
let intvArr = interval.split(':')
|
||||
const intvArr = interval.split(':')
|
||||
let intv = 0
|
||||
let unit = 1
|
||||
while (intvArr.length) {
|
||||
@@ -115,9 +115,9 @@ export default {
|
||||
}
|
||||
return intv
|
||||
}
|
||||
const trimStr = (str) => (typeof str == 'string' ? str.trim() : str || '')
|
||||
const trimStr = (str) => (typeof str === 'string' ? str.trim() : str || '')
|
||||
const filterStr = (str) =>
|
||||
typeof str == 'string'
|
||||
typeof str === 'string'
|
||||
? str.replace(/\s|'|\.|,|,|&|"|、|\(|\)|(|)|`|~|-|<|>|\||\/|\]|\[|!|!/g, '')
|
||||
: String(str || '')
|
||||
const fMusicName = filterStr(name).toLowerCase()
|
||||
|
||||
@@ -47,7 +47,7 @@ export default {
|
||||
)
|
||||
if (!albumList.info) return Promise.reject(new Error('Get album list failed.'))
|
||||
|
||||
let result = await getMusicInfosByList(albumList.info)
|
||||
const result = await getMusicInfosByList(albumList.info)
|
||||
|
||||
const info = await this.getAlbumInfo(id)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export default {
|
||||
// const res_id = (await getMusicInfoRaw(hash)).classification?.[0]?.res_id
|
||||
// if (!res_id) throw new Error('获取评论失败')
|
||||
|
||||
let timestamp = Date.now()
|
||||
const timestamp = Date.now()
|
||||
const params = `dfid=0&mid=16249512204336365674023395779019&clienttime=${timestamp}&uuid=0&extdata=${hash}&appid=1005&code=fc4be23b4e972707f36b8a828a93ba8a&schash=${hash}&clientver=11409&p=${page}&clienttoken=&pagesize=${limit}&ver=10&kugouid=0`
|
||||
// const params = `appid=1005&clienttime=${timestamp}&clienttoken=0&clientver=11409&code=fc4be23b4e972707f36b8a828a93ba8a&dfid=0&extdata=${hash}&kugouid=0&mid=16249512204336365674023395779019&mixsongid=${res_id}&p=${page}&pagesize=${limit}&uuid=0&ver=10`
|
||||
const _requestObj = httpFetch(
|
||||
@@ -40,7 +40,7 @@ export default {
|
||||
async getHotComment({ hash }, page = 1, limit = 20) {
|
||||
// console.log(songmid)
|
||||
if (this._requestObj2) this._requestObj2.cancelHttp()
|
||||
let timestamp = Date.now()
|
||||
const timestamp = Date.now()
|
||||
const params = `dfid=0&mid=16249512204336365674023395779019&clienttime=${timestamp}&uuid=0&extdata=${hash}&appid=1005&code=fc4be23b4e972707f36b8a828a93ba8a&schash=${hash}&clientver=11409&p=${page}&clienttoken=&pagesize=${limit}&ver=10&kugouid=0`
|
||||
// https://github.com/GitHub-ZC/wp_MusicApi/blob/bf9307dd138dc8ac6c4f7de29361209d4f5b665f/routes/v1/kugou/comment.js#L53
|
||||
const _requestObj2 = httpFetch(
|
||||
@@ -94,7 +94,7 @@ export default {
|
||||
},
|
||||
filterComment(rawList) {
|
||||
return rawList.map((item) => {
|
||||
let data = {
|
||||
const data = {
|
||||
id: item.id,
|
||||
text: decodeName(
|
||||
(item.atlist ? this.replaceAt(item.content, item.atlist) : item.content) || ''
|
||||
|
||||
@@ -2,7 +2,7 @@ import { httpFetch } from '../../request'
|
||||
import { decodeName, formatPlayTime, sizeFormate } from '../index'
|
||||
import { formatSingerName } from '../utils'
|
||||
|
||||
let boardList = [
|
||||
const boardList = [
|
||||
{ id: 'kg__8888', name: 'TOP500', bangid: '8888' },
|
||||
{ id: 'kg__6666', name: '飙升榜', bangid: '6666' },
|
||||
{ id: 'kg__59703', name: '蜂鸟流行音乐榜', bangid: '59703' },
|
||||
@@ -137,7 +137,7 @@ export default {
|
||||
return requestDataObj.promise
|
||||
},
|
||||
getSinger(singers) {
|
||||
let arr = []
|
||||
const arr = []
|
||||
singers.forEach((singer) => {
|
||||
arr.push(singer.author_name)
|
||||
})
|
||||
@@ -149,7 +149,7 @@ export default {
|
||||
const types = []
|
||||
const _types = {}
|
||||
if (item.filesize !== 0) {
|
||||
let size = sizeFormate(item.filesize)
|
||||
const size = sizeFormate(item.filesize)
|
||||
types.push({ type: '128k', size, hash: item.hash })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
@@ -157,7 +157,7 @@ export default {
|
||||
}
|
||||
}
|
||||
if (item['320filesize'] !== 0) {
|
||||
let size = sizeFormate(item['320filesize'])
|
||||
const size = sizeFormate(item['320filesize'])
|
||||
types.push({ type: '320k', size, hash: item['320hash'] })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
@@ -165,7 +165,7 @@ export default {
|
||||
}
|
||||
}
|
||||
if (item.sqfilesize !== 0) {
|
||||
let size = sizeFormate(item.sqfilesize)
|
||||
const size = sizeFormate(item.sqfilesize)
|
||||
types.push({ type: 'flac', size, hash: item.sqhash })
|
||||
_types.flac = {
|
||||
size,
|
||||
@@ -173,7 +173,7 @@ export default {
|
||||
}
|
||||
}
|
||||
if (item.filesize_high !== 0) {
|
||||
let size = sizeFormate(item.filesize_high)
|
||||
const size = sizeFormate(item.filesize_high)
|
||||
types.push({ type: 'flac24bit', size, hash: item.hash_high })
|
||||
_types.flac24bit = {
|
||||
size,
|
||||
@@ -201,7 +201,7 @@ export default {
|
||||
|
||||
filterBoardsData(rawList) {
|
||||
// console.log(rawList)
|
||||
let list = []
|
||||
const list = []
|
||||
for (const board of rawList) {
|
||||
if (board.isvol != 1) continue
|
||||
list.push({
|
||||
@@ -243,9 +243,9 @@ export default {
|
||||
if (body.errcode != 0) return this.getList(bangid, page, retryNum)
|
||||
|
||||
// console.log(body)
|
||||
let total = body.data.total
|
||||
let limit = 100
|
||||
let listData = this.filterData(body.data.info)
|
||||
const total = body.data.total
|
||||
const limit = 100
|
||||
const listData = this.filterData(body.data.info)
|
||||
// console.log(listData)
|
||||
return {
|
||||
total,
|
||||
@@ -256,7 +256,7 @@ export default {
|
||||
}
|
||||
},
|
||||
getDetailPageUrl(id) {
|
||||
if (typeof id == 'string') id = id.replace('kg__', '')
|
||||
if (typeof id === 'string') id = id.replace('kg__', '')
|
||||
return `https://www.kugou.com/yy/rank/home/1-${id}.html`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { decodeKrc } from '../../../../common/utils/lyricUtils/kg'
|
||||
export default {
|
||||
getIntv(interval) {
|
||||
if (!interval) return 0
|
||||
let intvArr = interval.split(':')
|
||||
const intvArr = interval.split(':')
|
||||
let intv = 0
|
||||
let unit = 1
|
||||
while (intvArr.length) {
|
||||
@@ -36,7 +36,7 @@ export default {
|
||||
// return requestObj
|
||||
// },
|
||||
searchLyric(name, hash, time, tryNum = 0) {
|
||||
let requestObj = httpFetch(
|
||||
const requestObj = httpFetch(
|
||||
`http://lyrics.kugou.com/search?ver=1&man=yes&client=pc&keyword=${encodeURIComponent(name)}&hash=${hash}&timelength=${time}&lrctxt=1`,
|
||||
{
|
||||
headers: {
|
||||
@@ -49,12 +49,12 @@ export default {
|
||||
requestObj.promise = requestObj.promise.then(({ body, statusCode }) => {
|
||||
if (statusCode !== 200) {
|
||||
if (tryNum > 5) return Promise.reject(new Error('歌词获取失败'))
|
||||
let tryRequestObj = this.searchLyric(name, hash, time, ++tryNum)
|
||||
const tryRequestObj = this.searchLyric(name, hash, time, ++tryNum)
|
||||
requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj)
|
||||
return tryRequestObj.promise
|
||||
}
|
||||
if (body.candidates.length) {
|
||||
let info = body.candidates[0]
|
||||
const info = body.candidates[0]
|
||||
return {
|
||||
id: info.id,
|
||||
accessKey: info.accesskey,
|
||||
@@ -66,7 +66,7 @@ export default {
|
||||
return requestObj
|
||||
},
|
||||
getLyricDownload(id, accessKey, fmt, tryNum = 0) {
|
||||
let requestObj = httpFetch(
|
||||
const requestObj = httpFetch(
|
||||
`http://lyrics.kugou.com/download?ver=1&client=pc&id=${id}&accesskey=${accessKey}&fmt=${fmt}&charset=utf8`,
|
||||
{
|
||||
headers: {
|
||||
@@ -79,7 +79,7 @@ export default {
|
||||
requestObj.promise = requestObj.promise.then(({ body, statusCode }) => {
|
||||
if (statusCode !== 200) {
|
||||
if (tryNum > 5) return Promise.reject(new Error('歌词获取失败'))
|
||||
let tryRequestObj = this.getLyric(id, accessKey, fmt, ++tryNum)
|
||||
const tryRequestObj = this.getLyric(id, accessKey, fmt, ++tryNum)
|
||||
requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj)
|
||||
return tryRequestObj.promise
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export default {
|
||||
return requestObj
|
||||
},
|
||||
getLyric(songInfo, tryNum = 0) {
|
||||
let requestObj = this.searchLyric(
|
||||
const requestObj = this.searchLyric(
|
||||
songInfo.name,
|
||||
songInfo.hash,
|
||||
songInfo._interval || this.getIntv(songInfo.interval)
|
||||
@@ -111,7 +111,7 @@ export default {
|
||||
requestObj.promise = requestObj.promise.then((result) => {
|
||||
if (!result) return Promise.reject(new Error('Get lyric failed'))
|
||||
|
||||
let requestObj2 = this.getLyricDownload(result.id, result.accessKey, result.fmt)
|
||||
const requestObj2 = this.getLyricDownload(result.id, result.accessKey, result.fmt)
|
||||
|
||||
requestObj.cancelHttp = requestObj2.cancelHttp.bind(requestObj2)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { decodeName, formatPlayTime, sizeFormate } from '../../index'
|
||||
import { createHttpFetch } from './util'
|
||||
|
||||
const createGetMusicInfosTask = (hashs) => {
|
||||
let data = {
|
||||
const data = {
|
||||
area_code: '1',
|
||||
show_privilege: 1,
|
||||
show_album_info: '1',
|
||||
@@ -16,13 +16,13 @@ const createGetMusicInfosTask = (hashs) => {
|
||||
fields: 'album_info,author_name,audio_info,ori_audio_name,base,songname,classification'
|
||||
}
|
||||
let list = hashs
|
||||
let tasks = []
|
||||
const tasks = []
|
||||
while (list.length) {
|
||||
tasks.push(Object.assign({ data: list.slice(0, 100) }, data))
|
||||
if (list.length < 100) break
|
||||
list = list.slice(100)
|
||||
}
|
||||
let url = 'http://gateway.kugou.com/v3/album_audio/audio'
|
||||
const url = 'http://gateway.kugou.com/v3/album_audio/audio'
|
||||
return tasks.map((task) =>
|
||||
createHttpFetch(url, {
|
||||
method: 'POST',
|
||||
@@ -41,8 +41,8 @@ const createGetMusicInfosTask = (hashs) => {
|
||||
|
||||
export const filterMusicInfoList = (rawList) => {
|
||||
// console.log(rawList)
|
||||
let ids = new Set()
|
||||
let list = []
|
||||
const ids = new Set()
|
||||
const list = []
|
||||
rawList.forEach((item) => {
|
||||
if (!item) return
|
||||
if (ids.has(item.audio_info.audio_id)) return
|
||||
@@ -50,7 +50,7 @@ export const filterMusicInfoList = (rawList) => {
|
||||
const types = []
|
||||
const _types = {}
|
||||
if (item.audio_info.filesize !== '0') {
|
||||
let size = sizeFormate(parseInt(item.audio_info.filesize))
|
||||
const size = sizeFormate(parseInt(item.audio_info.filesize))
|
||||
types.push({ type: '128k', size, hash: item.audio_info.hash })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
@@ -58,7 +58,7 @@ export const filterMusicInfoList = (rawList) => {
|
||||
}
|
||||
}
|
||||
if (item.audio_info.filesize_320 !== '0') {
|
||||
let size = sizeFormate(parseInt(item.audio_info.filesize_320))
|
||||
const size = sizeFormate(parseInt(item.audio_info.filesize_320))
|
||||
types.push({ type: '320k', size, hash: item.audio_info.hash_320 })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
@@ -66,7 +66,7 @@ export const filterMusicInfoList = (rawList) => {
|
||||
}
|
||||
}
|
||||
if (item.audio_info.filesize_flac !== '0') {
|
||||
let size = sizeFormate(parseInt(item.audio_info.filesize_flac))
|
||||
const size = sizeFormate(parseInt(item.audio_info.filesize_flac))
|
||||
types.push({ type: 'flac', size, hash: item.audio_info.hash_flac })
|
||||
_types.flac = {
|
||||
size,
|
||||
@@ -74,7 +74,7 @@ export const filterMusicInfoList = (rawList) => {
|
||||
}
|
||||
}
|
||||
if (item.audio_info.filesize_high !== '0') {
|
||||
let size = sizeFormate(parseInt(item.audio_info.filesize_high))
|
||||
const size = sizeFormate(parseInt(item.audio_info.filesize_high))
|
||||
types.push({ type: 'flac24bit', size, hash: item.audio_info.hash_high })
|
||||
_types.flac24bit = {
|
||||
size,
|
||||
|
||||
@@ -17,7 +17,7 @@ export default {
|
||||
const types = []
|
||||
const _types = {}
|
||||
if (rawData.FileSize !== 0) {
|
||||
let size = sizeFormate(rawData.FileSize)
|
||||
const size = sizeFormate(rawData.FileSize)
|
||||
types.push({ type: '128k', size, hash: rawData.FileHash })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
@@ -25,7 +25,7 @@ export default {
|
||||
}
|
||||
}
|
||||
if (rawData.HQFileSize !== 0) {
|
||||
let size = sizeFormate(rawData.HQFileSize)
|
||||
const size = sizeFormate(rawData.HQFileSize)
|
||||
types.push({ type: '320k', size, hash: rawData.HQFileHash })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
@@ -33,7 +33,7 @@ export default {
|
||||
}
|
||||
}
|
||||
if (rawData.SQFileSize !== 0) {
|
||||
let size = sizeFormate(rawData.SQFileSize)
|
||||
const size = sizeFormate(rawData.SQFileSize)
|
||||
types.push({ type: 'flac', size, hash: rawData.SQFileHash })
|
||||
_types.flac = {
|
||||
size,
|
||||
@@ -41,7 +41,7 @@ export default {
|
||||
}
|
||||
}
|
||||
if (rawData.ResFileSize !== 0) {
|
||||
let size = sizeFormate(rawData.ResFileSize)
|
||||
const size = sizeFormate(rawData.ResFileSize)
|
||||
types.push({ type: 'flac24bit', size, hash: rawData.ResFileHash })
|
||||
_types.flac24bit = {
|
||||
size,
|
||||
@@ -67,7 +67,7 @@ export default {
|
||||
}
|
||||
},
|
||||
handleResult(rawData) {
|
||||
let ids = new Set()
|
||||
const ids = new Set()
|
||||
const list = []
|
||||
rawData.forEach((item) => {
|
||||
const key = item.Audioid + item.FileHash
|
||||
@@ -89,7 +89,7 @@ export default {
|
||||
// http://newlyric.kuwo.cn/newlyric.lrc?62355680
|
||||
return this.musicSearch(str, page, limit).then((result) => {
|
||||
if (!result || result.error_code !== 0) return this.search(str, page, limit, retryNum)
|
||||
let list = this.handleResult(result.data.lists)
|
||||
const list = this.handleResult(result.data.lists)
|
||||
|
||||
if (list == null) return this.search(str, page, limit, retryNum)
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export default {
|
||||
})
|
||||
return requestObj.promise.then(({ body }) => {
|
||||
if (body.error_code !== 0) return Promise.reject(new Error('图片获取失败'))
|
||||
let info = body.data[0].info
|
||||
const info = body.data[0].info
|
||||
const img = info.imgsize ? info.image.replace('{size}', info.imgsize[0]) : info.image
|
||||
if (!img) return Promise.reject(new Error('Pic get failed'))
|
||||
return img
|
||||
|
||||
@@ -71,10 +71,10 @@ export default {
|
||||
if (tryNum > 2) throw new Error('try max num')
|
||||
|
||||
const { body } = await httpFetch(this.getSongListDetailUrl(id)).promise
|
||||
let listData = body.match(this.regExps.listData)
|
||||
let listInfo = body.match(this.regExps.listInfo)
|
||||
const listData = body.match(this.regExps.listData)
|
||||
const listInfo = body.match(this.regExps.listInfo)
|
||||
if (!listData) return this.getListDetailBySpecialId(id, page, ++tryNum)
|
||||
let list = await this.getMusicInfos(JSON.parse(listData[1]))
|
||||
const list = await this.getMusicInfos(JSON.parse(listData[1]))
|
||||
// listData = this.filterData(JSON.parse(listData[1]))
|
||||
let name
|
||||
let pic
|
||||
@@ -82,7 +82,7 @@ export default {
|
||||
name = listInfo[1]
|
||||
pic = listInfo[2]
|
||||
}
|
||||
let desc = this.parseHtmlDesc(body)
|
||||
const desc = this.parseHtmlDesc(body)
|
||||
|
||||
return {
|
||||
list,
|
||||
@@ -116,7 +116,7 @@ export default {
|
||||
const result = []
|
||||
if (rawData.status !== 1) return result
|
||||
for (const key of Object.keys(rawData.data)) {
|
||||
let tag = rawData.data[key]
|
||||
const tag = rawData.data[key]
|
||||
result.push({
|
||||
id: tag.special_id,
|
||||
name: tag.special_name,
|
||||
@@ -219,7 +219,7 @@ export default {
|
||||
},
|
||||
|
||||
createTask(hashs) {
|
||||
let data = {
|
||||
const data = {
|
||||
area_code: '1',
|
||||
show_privilege: 1,
|
||||
show_album_info: '1',
|
||||
@@ -233,13 +233,13 @@ export default {
|
||||
fields: 'album_info,author_name,audio_info,ori_audio_name,base,songname'
|
||||
}
|
||||
let list = hashs
|
||||
let tasks = []
|
||||
const tasks = []
|
||||
while (list.length) {
|
||||
tasks.push(Object.assign({ data: list.slice(0, 100) }, data))
|
||||
if (list.length < 100) break
|
||||
list = list.slice(100)
|
||||
}
|
||||
let url = 'http://gateway.kugou.com/v2/album_audio/audio'
|
||||
const url = 'http://gateway.kugou.com/v2/album_audio/audio'
|
||||
return tasks.map((task) =>
|
||||
this.createHttp(url, {
|
||||
method: 'POST',
|
||||
@@ -283,7 +283,7 @@ export default {
|
||||
// console.log(songInfo)
|
||||
// type 1单曲,2歌单,3电台,4酷狗码,5别人的播放队列
|
||||
let songList
|
||||
let info = songInfo.info
|
||||
const info = songInfo.info
|
||||
switch (info.type) {
|
||||
case 2:
|
||||
if (!info.global_collection_id) return this.getListDetailBySpecialId(info.id)
|
||||
@@ -319,7 +319,7 @@ export default {
|
||||
})
|
||||
// console.log(songList)
|
||||
}
|
||||
let list = await this.getMusicInfos(songList || songInfo.list)
|
||||
const list = await this.getMusicInfos(songList || songInfo.list)
|
||||
return {
|
||||
list,
|
||||
page: 1,
|
||||
@@ -354,7 +354,7 @@ export default {
|
||||
this.getUserListDetail5(chain)
|
||||
)
|
||||
}
|
||||
let list = await this.getMusicInfos(songInfo.list)
|
||||
const list = await this.getMusicInfos(songInfo.list)
|
||||
// console.log(info, songInfo)
|
||||
return {
|
||||
list,
|
||||
@@ -373,7 +373,7 @@ export default {
|
||||
},
|
||||
|
||||
deDuplication(datas) {
|
||||
let ids = new Set()
|
||||
const ids = new Set()
|
||||
return datas.filter(({ hash }) => {
|
||||
if (ids.has(hash)) return false
|
||||
ids.add(hash)
|
||||
@@ -408,9 +408,9 @@ export default {
|
||||
},
|
||||
|
||||
async getUserListDetailByLink({ info }, link) {
|
||||
let listInfo = info['0']
|
||||
const listInfo = info['0']
|
||||
let total = listInfo.count
|
||||
let tasks = []
|
||||
const tasks = []
|
||||
let page = 0
|
||||
while (total) {
|
||||
const limit = total > 90 ? 90 : total
|
||||
@@ -448,7 +448,7 @@ export default {
|
||||
}
|
||||
},
|
||||
createGetListDetail2Task(id, total) {
|
||||
let tasks = []
|
||||
const tasks = []
|
||||
let page = 0
|
||||
while (total) {
|
||||
const limit = total > 300 ? 300 : total
|
||||
@@ -481,13 +481,13 @@ export default {
|
||||
return Promise.all(tasks).then(([...datas]) => datas.flat())
|
||||
},
|
||||
async getUserListDetail2(global_collection_id) {
|
||||
let id = global_collection_id
|
||||
const id = global_collection_id
|
||||
if (id.length > 1000) throw new Error('get list error')
|
||||
const params =
|
||||
'appid=1058&specialid=0&global_specialid=' +
|
||||
id +
|
||||
'&format=jsonp&srcappid=2919&clientver=20000&clienttime=1586163242519&mid=1586163242519&uuid=1586163242519&dfid=-'
|
||||
let info = await this.createHttp(
|
||||
const info = await this.createHttp(
|
||||
`https://mobiles.kugou.com/api/v5/special/info_v2?${params}&signature=${signatureParams(params, 'web')}`,
|
||||
{
|
||||
headers: {
|
||||
@@ -501,7 +501,7 @@ export default {
|
||||
}
|
||||
)
|
||||
const songInfo = await this.createGetListDetail2Task(id, info.songcount)
|
||||
let list = await this.getMusicInfos(songInfo)
|
||||
const list = await this.getMusicInfos(songInfo)
|
||||
// console.log(info, songInfo, list)
|
||||
return {
|
||||
list,
|
||||
@@ -534,7 +534,7 @@ export default {
|
||||
},
|
||||
|
||||
async getUserListDetailByPcChain(chain) {
|
||||
let key = `${chain}_pc_list`
|
||||
const key = `${chain}_pc_list`
|
||||
if (this.cache.has(key)) return this.cache.get(key)
|
||||
const { body } = await httpFetch(`http://www.kugou.com/share/${chain}.html`, {
|
||||
headers: {
|
||||
@@ -595,7 +595,7 @@ export default {
|
||||
|
||||
async getUserListDetailById(id, page, limit) {
|
||||
const signature = await handleSignature(id, page, limit)
|
||||
let info = await this.createHttp(
|
||||
const info = await this.createHttp(
|
||||
`https://pubsongscdn.kugou.com/v2/get_other_list_file?srcappid=2919&clientver=20000&appid=1058&type=0&module=playlist&page=${page}&pagesize=${limit}&specialid=${id}&signature=${signature}`,
|
||||
{
|
||||
headers: {
|
||||
@@ -608,7 +608,7 @@ export default {
|
||||
)
|
||||
|
||||
// console.log(info)
|
||||
let result = await this.getMusicInfos(info.info)
|
||||
const result = await this.getMusicInfos(info.info)
|
||||
// console.log(info, songInfo)
|
||||
return result
|
||||
},
|
||||
@@ -621,7 +621,7 @@ export default {
|
||||
link.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1')
|
||||
)
|
||||
if (link.includes('gcid_')) {
|
||||
let gcid = link.match(/gcid_\w+/)?.[0]
|
||||
const gcid = link.match(/gcid_\w+/)?.[0]
|
||||
if (gcid) {
|
||||
const global_collection_id = await this.decodeGcid(gcid)
|
||||
if (global_collection_id) return this.getUserListDetail2(global_collection_id)
|
||||
@@ -667,7 +667,7 @@ export default {
|
||||
location.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1')
|
||||
)
|
||||
if (location.includes('gcid_')) {
|
||||
let gcid = link.match(/gcid_\w+/)?.[0]
|
||||
const gcid = link.match(/gcid_\w+/)?.[0]
|
||||
if (gcid) {
|
||||
const global_collection_id = await this.decodeGcid(gcid)
|
||||
if (global_collection_id) return this.getUserListDetail2(global_collection_id)
|
||||
@@ -698,7 +698,7 @@ export default {
|
||||
// console.log('location', location)
|
||||
return this.getUserListDetail(location, page, ++retryNum)
|
||||
}
|
||||
if (typeof body == 'string') {
|
||||
if (typeof body === 'string') {
|
||||
let global_collection_id = body.match(/"global_collection_id":"(\w+)"/)?.[1]
|
||||
if (!global_collection_id) {
|
||||
let gcid = body.match(/"encode_gic":"(\w+)"/)?.[1]
|
||||
@@ -735,7 +735,7 @@ export default {
|
||||
const types = []
|
||||
const _types = {}
|
||||
if (item.filesize !== 0) {
|
||||
let size = sizeFormate(item.filesize)
|
||||
const size = sizeFormate(item.filesize)
|
||||
types.push({ type: '128k', size, hash: item.hash })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
@@ -743,7 +743,7 @@ export default {
|
||||
}
|
||||
}
|
||||
if (item.filesize_320 !== 0) {
|
||||
let size = sizeFormate(item.filesize_320)
|
||||
const size = sizeFormate(item.filesize_320)
|
||||
types.push({ type: '320k', size, hash: item.hash_320 })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
@@ -751,7 +751,7 @@ export default {
|
||||
}
|
||||
}
|
||||
if (item.filesize_ape !== 0) {
|
||||
let size = sizeFormate(item.filesize_ape)
|
||||
const size = sizeFormate(item.filesize_ape)
|
||||
types.push({ type: 'ape', size, hash: item.hash_ape })
|
||||
_types.ape = {
|
||||
size,
|
||||
@@ -759,7 +759,7 @@ export default {
|
||||
}
|
||||
}
|
||||
if (item.filesize_flac !== 0) {
|
||||
let size = sizeFormate(item.filesize_flac)
|
||||
const size = sizeFormate(item.filesize_flac)
|
||||
types.push({ type: 'flac', size, hash: item.hash_flac })
|
||||
_types.flac = {
|
||||
size,
|
||||
@@ -849,8 +849,8 @@ export default {
|
||||
// hash list filter
|
||||
filterData2(rawList) {
|
||||
// console.log(rawList)
|
||||
let ids = new Set()
|
||||
let list = []
|
||||
const ids = new Set()
|
||||
const list = []
|
||||
rawList.forEach((item) => {
|
||||
if (!item) return
|
||||
if (ids.has(item.audio_info.audio_id)) return
|
||||
@@ -858,7 +858,7 @@ export default {
|
||||
const types = []
|
||||
const _types = {}
|
||||
if (item.audio_info.filesize !== '0') {
|
||||
let size = sizeFormate(parseInt(item.audio_info.filesize))
|
||||
const size = sizeFormate(parseInt(item.audio_info.filesize))
|
||||
types.push({ type: '128k', size, hash: item.audio_info.hash })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
@@ -866,7 +866,7 @@ export default {
|
||||
}
|
||||
}
|
||||
if (item.audio_info.filesize_320 !== '0') {
|
||||
let size = sizeFormate(parseInt(item.audio_info.filesize_320))
|
||||
const size = sizeFormate(parseInt(item.audio_info.filesize_320))
|
||||
types.push({ type: '320k', size, hash: item.audio_info.hash_320 })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
@@ -874,7 +874,7 @@ export default {
|
||||
}
|
||||
}
|
||||
if (item.audio_info.filesize_flac !== '0') {
|
||||
let size = sizeFormate(parseInt(item.audio_info.filesize_flac))
|
||||
const size = sizeFormate(parseInt(item.audio_info.filesize_flac))
|
||||
types.push({ type: 'flac', size, hash: item.audio_info.hash_flac })
|
||||
_types.flac = {
|
||||
size,
|
||||
@@ -882,7 +882,7 @@ export default {
|
||||
}
|
||||
}
|
||||
if (item.audio_info.filesize_high !== '0') {
|
||||
let size = sizeFormate(parseInt(item.audio_info.filesize_high))
|
||||
const size = sizeFormate(parseInt(item.audio_info.filesize_high))
|
||||
types.push({ type: 'flac24bit', size, hash: item.audio_info.hash_high })
|
||||
_types.flac24bit = {
|
||||
size,
|
||||
@@ -927,7 +927,7 @@ export default {
|
||||
|
||||
// 获取列表数据
|
||||
getList(sortId, tagId, page) {
|
||||
let tasks = [this.getSongList(sortId, tagId, page)]
|
||||
const tasks = [this.getSongList(sortId, tagId, page)]
|
||||
tasks.push(
|
||||
this.currentTagInfo.id === tagId
|
||||
? Promise.resolve(this.currentTagInfo.info)
|
||||
@@ -964,7 +964,7 @@ export default {
|
||||
},
|
||||
|
||||
getDetailPageUrl(id) {
|
||||
if (typeof id == 'string') {
|
||||
if (typeof id === 'string') {
|
||||
if (/^https?:\/\//.test(id)) return id
|
||||
id = id.replace('id_', '')
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ import { httpFetch } from '../../request'
|
||||
export const signatureParams = (params, platform = 'android', body = '') => {
|
||||
let keyparam = 'OIlwieks28dk2k092lksi2UIkp'
|
||||
if (platform === 'web') keyparam = 'NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt'
|
||||
let param_list = params.split('&')
|
||||
const param_list = params.split('&')
|
||||
param_list.sort()
|
||||
let sign_params = `${keyparam}${param_list.join('')}${body}${keyparam}`
|
||||
const sign_params = `${keyparam}${param_list.join('')}${body}${keyparam}`
|
||||
return toMD5(sign_params)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ export default {
|
||||
// console.log(rawList)
|
||||
// console.log(rawList.length, rawList2.length)
|
||||
return rawList.map((item, inedx) => {
|
||||
let formats = item.formats.split('|')
|
||||
let types = []
|
||||
let _types = {}
|
||||
const formats = item.formats.split('|')
|
||||
const types = []
|
||||
const _types = {}
|
||||
if (formats.includes('MP3128')) {
|
||||
types.push({ type: '128k', size: null })
|
||||
_types['128k'] = {
|
||||
|
||||
@@ -68,13 +68,13 @@ const kw = {
|
||||
},
|
||||
|
||||
getMusicUrls(musicInfo, cb) {
|
||||
let tasks = []
|
||||
let songId = musicInfo.songmid
|
||||
const tasks = []
|
||||
const songId = musicInfo.songmid
|
||||
musicInfo.types.forEach((type) => {
|
||||
tasks.push(kw.getMusicUrl(songId, type.type).promise)
|
||||
})
|
||||
Promise.all(tasks).then((urlInfo) => {
|
||||
let typeUrl = {}
|
||||
const typeUrl = {}
|
||||
urlInfo.forEach((info) => {
|
||||
typeUrl[info.type] = info.url
|
||||
})
|
||||
|
||||
@@ -206,7 +206,7 @@ export default {
|
||||
|
||||
filterBoardsData(rawList) {
|
||||
// console.log(rawList)
|
||||
let list = []
|
||||
const list = []
|
||||
for (const board of rawList) {
|
||||
if (board.source != '1') continue
|
||||
list.push({
|
||||
|
||||