Compare commits

...

62 Commits

Author SHA1 Message Date
sqj
34fb0f7c2f fix:qqLyric 2025-09-22 03:41:08 +08:00
sqj
191ba1e199 feat:点击搜索框的 源图标实现快速切换 兼容多平台歌单导入 fix:列表删除按钮冒泡 2025-09-21 18:36:18 +08:00
sqj
324e81c0dc feat:点击搜索框的 源图标实现快速切换 兼容多平台歌单导入 fix:列表删除按钮冒泡 2025-09-21 18:23:54 +08:00
sqj
7ec269e0cb fix:build 2025-09-21 03:42:16 +08:00
sqj
6f10aae535 fix:修复了一些已知问题 2025-09-21 03:08:05 +08:00
sqj
0c54a852ba fix:优化项目结构 2025-09-19 22:46:52 +08:00
sqj
bc53203bfa Merge branch 'main' of https://github.com/timeshiftsauce/CeruMusic 2025-09-18 18:09:07 +08:00
sqj
c149e5c904 add:docs and fix SMTC 2025-09-18 18:08:42 +08:00
时迁酱
d983abd3d5 Update README.md 2025-09-18 18:03:42 +08:00
sqj
f48369e1a2 add:docs 2025-09-17 00:58:34 +08:00
sqj
2af9a4ea9f feat:缓存路径支持自定义
下载路径支持自定义;fix:播放页面唱针可以拖动问题
播放按钮加载中 因为自动下一曲 导致动画变形问题
SMTC 功能 系统显示未知应用问题
播放页歌词字体粗细偶现丢失问题
2025-09-17 00:55:26 +08:00
sqj
87f69fc782 feat:缓存路径支持自定义
下载路径支持自定义;fix:播放页面唱针可以拖动问题
播放按钮加载中 因为自动下一曲 导致动画变形问题
SMTC 功能 系统显示未知应用问题
播放页歌词字体粗细偶现丢失问题
2025-09-17 00:39:44 +08:00
sqj
59d3b0c65c add:docs 2025-09-16 19:20:37 +08:00
sqj
e861ea8f78 add:docs 2025-09-16 13:52:28 +08:00
sqj
6692751c62 feat:播放列表拖拽排序;播放界面ui优化;插件页滚动问题;歌曲加载状提示;接入window系统的 AMTC 控制 2025-09-15 20:43:44 +08:00
sqj
65e876a2e9 feat:播放列表拖动排序 2025-09-14 19:22:40 +08:00
sqj
496c88a629 updata:v1.2.8 2025-09-13 21:03:06 +08:00
sqj
2086bd1663 fix:修复macos,linux打包没有图标问题 2025-09-13 20:27:07 +08:00
sqj
d6f8d0e63c fix:修复macos,linux打包没有图标问题 2025-09-13 20:13:54 +08:00
sqj
cc4dd8284f fix:修复macos,linux打包没有图标问题 2025-09-13 20:10:01 +08:00
sqj
6f688cbbb3 fix:下载站下载文件匹配错误问题 2025-09-13 19:49:19 +08:00
sqj
e0a1e0af39 updata:v1.2.7 2025-09-13 05:37:57 +08:00
sqj
c1d3a61f9f feat:歌单导入体验优化&封面更新逻辑优化 2025-09-13 05:37:04 +08:00
sqj
d6d806c96e feat:歌单名编辑 2025-09-13 05:14:26 +08:00
sqj
089406464b fix:优化设置页面ui 修复播放界面问题 2025-09-13 04:56:27 +08:00
sqj
c28d5d6ad0 fix:docs 2025-09-11 01:06:46 +08:00
sqj
471147ac82 fix:docs 2025-09-11 01:04:55 +08:00
sqj
7558a67df3 fix:docs 2025-09-11 00:41:32 +08:00
sqj
4a3f0ee124 fix:docs 2025-09-11 00:27:09 +08:00
sqj
5fe6d93d5e feat:支持本地歌单功能&测试阶段网络歌单导入功能 2025-09-10 23:10:19 +08:00
sqj
30fd2ebb9f Merge branch 'main' of https://github.com/timeshiftsauce/CeruMusic
# the commit.
2025-09-09 20:37:59 +08:00
sqj
0f78f117d0 fix: 禁止打开多实例,添加默认封面图,修复已知bug 2025-09-09 20:37:14 +08:00
时迁酱
c79b6951d6 Update sync-releases-to-webdav.yml 2025-09-07 02:02:55 +08:00
时迁酱
5118874712 Update sync-releases-to-webdav.yml 2025-09-07 01:54:52 +08:00
时迁酱
7e5baba969 Update sync-releases-to-webdav.yml 2025-09-07 01:45:13 +08:00
时迁酱
a7af89e35d Update sync-releases-to-webdav.yml 2025-09-07 01:42:39 +08:00
时迁酱
d511efdfce Update sync-releases-to-webdav.yml 2025-09-07 01:35:22 +08:00
时迁酱
9b6050be7a Update sync-releases-to-webdav.yml 2025-09-07 01:34:06 +08:00
时迁酱
57736e60f3 Update sync-releases-to-webdav.yml 2025-09-07 01:23:35 +08:00
时迁酱
6165a2619e Update sync-releases-to-webdav.yml 2025-09-07 01:21:58 +08:00
时迁酱
c933b6e0b4 Update sync-releases-to-webdav.yml 2025-09-07 01:19:35 +08:00
时迁酱
e0e01cbdca Update sync-releases-to-webdav.yml 2025-09-07 01:16:44 +08:00
时迁酱
9b34ecbed9 Update sync-releases-to-webdav.yml 2025-09-07 01:14:28 +08:00
时迁酱
1dda213013 Update sync-releases-to-webdav.yml 2025-09-07 01:03:33 +08:00
sqj
94c2dc740f feat:修改了软件下载源&添加版本上传 2025-09-06 19:32:54 +08:00
sqj
61f062455e feat:修改了软件下载源&添加版本上传 2025-09-06 19:08:45 +08:00
sqj
79827f14f7 feat:修改了软件下载源 2025-09-06 18:57:26 +08:00
sqj
394bdd573c feat:修改了软件下载源 2025-09-06 18:56:35 +08:00
时迁酱
d03d62c8d4 Update uploadpan.yml 2025-09-06 17:25:38 +08:00
时迁酱
be0b0b0390 Update uploadpan.yml 2025-09-06 17:07:01 +08:00
时迁酱
c1d2f3dc8d Update uploadpan.yml 2025-09-06 16:56:55 +08:00
时迁酱
e590c33c66 Update uploadpan.yml 2025-09-06 16:53:37 +08:00
时迁酱
604ac7b553 Update uploadpan.yml 2025-09-06 16:51:33 +08:00
时迁酱
18e233ae10 Update uploadpan.yml 2025-09-06 16:43:40 +08:00
时迁酱
61699c4853 Update uploadpan.yml 2025-09-06 16:41:20 +08:00
时迁酱
6e69920a5d Update uploadpan.yml 2025-09-06 16:31:20 +08:00
时迁酱
a767b008a0 Update uploadpan.yml 2025-09-06 10:08:38 +08:00
时迁酱
41b104e96d Update uploadpan.yml 2025-09-06 10:06:22 +08:00
时迁酱
8562b7c954 Update uploadpan.yml 2025-09-06 10:04:02 +08:00
时迁酱
b61e88b7d9 Create uploadpan.yml 2025-09-06 09:14:55 +08:00
sqj
cc1dbcaf3f 优化整体界面ui效果 2025-09-05 20:58:20 +08:00
sqj
941af10830 新增音频可视化 2025-09-04 21:19:59 +08:00
209 changed files with 132543 additions and 33249 deletions

10
.eslintrc.backup.json Normal file
View File

@@ -0,0 +1,10 @@
{
"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
View 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

View File

@@ -70,3 +70,146 @@ jobs:
uses: softprops/action-gh-release@v1
with:
files: 'dist/**' # 将dist目录下所有文件添加到release
# 新增:自动同步到 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

View 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
View 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 同步完成"

1
.gitignore vendored
View File

@@ -18,3 +18,4 @@ temp/log.txt
/.idea/
docs/.vitepress/dist
docs/.vitepress/cache
yarn.lock

275
.vitepress/cache/deps/@theme_index.js vendored Normal file
View File

@@ -0,0 +1,275 @@
import {
useMediaQuery
} from "./chunk-B6YPYVPP.js";
import {
computed,
ref,
shallowRef,
watch
} from "./chunk-I4O5PVBA.js";
// node_modules/vitepress/dist/client/theme-default/index.js
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/fonts.css";
// node_modules/vitepress/dist/client/theme-default/without-fonts.js
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/vars.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/base.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/icons.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/utils.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/components/custom-block.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/components/vp-code.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/components/vp-code-group.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/components/vp-doc.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/components/vp-sponsor.css";
import VPBadge from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPBadge.vue";
import Layout from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/Layout.vue";
import { default as default2 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPBadge.vue";
import { default as default3 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPButton.vue";
import { default as default4 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPDocAsideSponsors.vue";
import { default as default5 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPFeatures.vue";
import { default as default6 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPHomeContent.vue";
import { default as default7 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPHomeFeatures.vue";
import { default as default8 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPHomeHero.vue";
import { default as default9 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPHomeSponsors.vue";
import { default as default10 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPImage.vue";
import { default as default11 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPLink.vue";
import { default as default12 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPNavBarSearch.vue";
import { default as default13 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPSocialLink.vue";
import { default as default14 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPSocialLinks.vue";
import { default as default15 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPSponsors.vue";
import { default as default16 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPTeamMembers.vue";
import { default as default17 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPTeamPage.vue";
import { default as default18 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPTeamPageSection.vue";
import { default as default19 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPTeamPageTitle.vue";
// node_modules/vitepress/dist/client/theme-default/composables/local-nav.js
import { onContentUpdated } from "vitepress";
// node_modules/vitepress/dist/client/theme-default/composables/outline.js
import { getScrollOffset } from "vitepress";
// node_modules/vitepress/dist/client/theme-default/support/utils.js
import { withBase } from "vitepress";
// node_modules/vitepress/dist/client/theme-default/composables/data.js
import { useData as useData$ } from "vitepress";
var useData = useData$;
// node_modules/vitepress/dist/client/theme-default/support/utils.js
function ensureStartingSlash(path) {
return path.startsWith("/") ? path : `/${path}`;
}
// node_modules/vitepress/dist/client/theme-default/support/sidebar.js
function getSidebar(_sidebar, path) {
if (Array.isArray(_sidebar))
return addBase(_sidebar);
if (_sidebar == null)
return [];
path = ensureStartingSlash(path);
const dir = Object.keys(_sidebar).sort((a, b) => {
return b.split("/").length - a.split("/").length;
}).find((dir2) => {
return path.startsWith(ensureStartingSlash(dir2));
});
const sidebar = dir ? _sidebar[dir] : [];
return Array.isArray(sidebar) ? addBase(sidebar) : addBase(sidebar.items, sidebar.base);
}
function getSidebarGroups(sidebar) {
const groups = [];
let lastGroupIndex = 0;
for (const index in sidebar) {
const item = sidebar[index];
if (item.items) {
lastGroupIndex = groups.push(item);
continue;
}
if (!groups[lastGroupIndex]) {
groups.push({ items: [] });
}
groups[lastGroupIndex].items.push(item);
}
return groups;
}
function addBase(items, _base) {
return [...items].map((_item) => {
const item = { ..._item };
const base = item.base || _base;
if (base && item.link)
item.link = base + item.link;
if (item.items)
item.items = addBase(item.items, base);
return item;
});
}
// node_modules/vitepress/dist/client/theme-default/composables/sidebar.js
function useSidebar() {
const { frontmatter, page, theme: theme2 } = useData();
const is960 = useMediaQuery("(min-width: 960px)");
const isOpen = ref(false);
const _sidebar = computed(() => {
const sidebarConfig = theme2.value.sidebar;
const relativePath = page.value.relativePath;
return sidebarConfig ? getSidebar(sidebarConfig, relativePath) : [];
});
const sidebar = ref(_sidebar.value);
watch(_sidebar, (next, prev) => {
if (JSON.stringify(next) !== JSON.stringify(prev))
sidebar.value = _sidebar.value;
});
const hasSidebar = computed(() => {
return frontmatter.value.sidebar !== false && sidebar.value.length > 0 && frontmatter.value.layout !== "home";
});
const leftAside = computed(() => {
if (hasAside)
return frontmatter.value.aside == null ? theme2.value.aside === "left" : frontmatter.value.aside === "left";
return false;
});
const hasAside = computed(() => {
if (frontmatter.value.layout === "home")
return false;
if (frontmatter.value.aside != null)
return !!frontmatter.value.aside;
return theme2.value.aside !== false;
});
const isSidebarEnabled = computed(() => hasSidebar.value && is960.value);
const sidebarGroups = computed(() => {
return hasSidebar.value ? getSidebarGroups(sidebar.value) : [];
});
function open() {
isOpen.value = true;
}
function close() {
isOpen.value = false;
}
function toggle() {
isOpen.value ? close() : open();
}
return {
isOpen,
sidebar,
sidebarGroups,
hasSidebar,
hasAside,
leftAside,
isSidebarEnabled,
open,
close,
toggle
};
}
// node_modules/vitepress/dist/client/theme-default/composables/outline.js
var ignoreRE = /\b(?:VPBadge|header-anchor|footnote-ref|ignore-header)\b/;
var resolvedHeaders = [];
function getHeaders(range) {
const headers = [
...document.querySelectorAll(".VPDoc :where(h1,h2,h3,h4,h5,h6)")
].filter((el) => el.id && el.hasChildNodes()).map((el) => {
const level = Number(el.tagName[1]);
return {
element: el,
title: serializeHeader(el),
link: "#" + el.id,
level
};
});
return resolveHeaders(headers, range);
}
function serializeHeader(h) {
let ret = "";
for (const node of h.childNodes) {
if (node.nodeType === 1) {
if (ignoreRE.test(node.className))
continue;
ret += node.textContent;
} else if (node.nodeType === 3) {
ret += node.textContent;
}
}
return ret.trim();
}
function resolveHeaders(headers, range) {
if (range === false) {
return [];
}
const levelsRange = (typeof range === "object" && !Array.isArray(range) ? range.level : range) || 2;
const [high, low] = typeof levelsRange === "number" ? [levelsRange, levelsRange] : levelsRange === "deep" ? [2, 6] : levelsRange;
return buildTree(headers, high, low);
}
function buildTree(data, min, max) {
resolvedHeaders.length = 0;
const result = [];
const stack = [];
data.forEach((item) => {
const node = { ...item, children: [] };
let parent = stack[stack.length - 1];
while (parent && parent.level >= node.level) {
stack.pop();
parent = stack[stack.length - 1];
}
if (node.element.classList.contains("ignore-header") || parent && "shouldIgnore" in parent) {
stack.push({ level: node.level, shouldIgnore: true });
return;
}
if (node.level > max || node.level < min)
return;
resolvedHeaders.push({ element: node.element, link: node.link });
if (parent)
parent.children.push(node);
else
result.push(node);
stack.push(node);
});
return result;
}
// node_modules/vitepress/dist/client/theme-default/composables/local-nav.js
function useLocalNav() {
const { theme: theme2, frontmatter } = useData();
const headers = shallowRef([]);
const hasLocalNav = computed(() => {
return headers.value.length > 0;
});
onContentUpdated(() => {
headers.value = getHeaders(frontmatter.value.outline ?? theme2.value.outline);
});
return {
headers,
hasLocalNav
};
}
// node_modules/vitepress/dist/client/theme-default/without-fonts.js
var theme = {
Layout,
enhanceApp: ({ app }) => {
app.component("Badge", VPBadge);
}
};
var without_fonts_default = theme;
export {
default2 as VPBadge,
default3 as VPButton,
default4 as VPDocAsideSponsors,
default5 as VPFeatures,
default6 as VPHomeContent,
default7 as VPHomeFeatures,
default8 as VPHomeHero,
default9 as VPHomeSponsors,
default10 as VPImage,
default11 as VPLink,
default12 as VPNavBarSearch,
default13 as VPSocialLink,
default14 as VPSocialLinks,
default15 as VPSponsors,
default16 as VPTeamMembers,
default17 as VPTeamPage,
default18 as VPTeamPageSection,
default19 as VPTeamPageTitle,
without_fonts_default as default,
useLocalNav,
useSidebar
};
//# sourceMappingURL=@theme_index.js.map

File diff suppressed because one or more lines are too long

40
.vitepress/cache/deps/_metadata.json vendored Normal file
View File

@@ -0,0 +1,40 @@
{
"hash": "99cf66da",
"configHash": "acc3a95b",
"lockfileHash": "6f0f9736",
"browserHash": "6e863def",
"optimized": {
"vue": {
"src": "../../../node_modules/vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "4f939392",
"needsInterop": false
},
"vitepress > @vue/devtools-api": {
"src": "../../../node_modules/@vue/devtools-api/dist/index.js",
"file": "vitepress___@vue_devtools-api.js",
"fileHash": "fcdf6679",
"needsInterop": false
},
"vitepress > @vueuse/core": {
"src": "../../../node_modules/@vueuse/core/index.mjs",
"file": "vitepress___@vueuse_core.js",
"fileHash": "f6cccf57",
"needsInterop": false
},
"@theme/index": {
"src": "../../../node_modules/vitepress/dist/client/theme-default/index.js",
"file": "@theme_index.js",
"fileHash": "1995bc33",
"needsInterop": false
}
},
"chunks": {
"chunk-B6YPYVPP": {
"file": "chunk-B6YPYVPP.js"
},
"chunk-I4O5PVBA": {
"file": "chunk-I4O5PVBA.js"
}
}
}

9719
.vitepress/cache/deps/chunk-B6YPYVPP.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

12683
.vitepress/cache/deps/chunk-I4O5PVBA.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

3
.vitepress/cache/deps/package.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,583 @@
import {
DefaultMagicKeysAliasMap,
StorageSerializers,
TransitionPresets,
assert,
breakpointsAntDesign,
breakpointsBootstrapV5,
breakpointsElement,
breakpointsMasterCss,
breakpointsPrimeFlex,
breakpointsQuasar,
breakpointsSematic,
breakpointsTailwind,
breakpointsVuetify,
breakpointsVuetifyV2,
breakpointsVuetifyV3,
bypassFilter,
camelize,
clamp,
cloneFnJSON,
computedAsync,
computedEager,
computedInject,
computedWithControl,
containsProp,
controlledRef,
createEventHook,
createFetch,
createFilterWrapper,
createGlobalState,
createInjectionState,
createRef,
createReusableTemplate,
createSharedComposable,
createSingletonPromise,
createTemplatePromise,
createUnrefFn,
customStorageEventName,
debounceFilter,
defaultDocument,
defaultLocation,
defaultNavigator,
defaultWindow,
executeTransition,
extendRef,
formatDate,
formatTimeAgo,
get,
getLifeCycleTarget,
getSSRHandler,
hasOwn,
hyphenate,
identity,
increaseWithUnit,
injectLocal,
invoke,
isClient,
isDef,
isDefined,
isIOS,
isObject,
isWorker,
makeDestructurable,
mapGamepadToXbox360Controller,
noop,
normalizeDate,
notNullish,
now,
objectEntries,
objectOmit,
objectPick,
onClickOutside,
onElementRemoval,
onKeyDown,
onKeyPressed,
onKeyStroke,
onKeyUp,
onLongPress,
onStartTyping,
pausableFilter,
promiseTimeout,
provideLocal,
provideSSRWidth,
pxValue,
rand,
reactify,
reactifyObject,
reactiveComputed,
reactiveOmit,
reactivePick,
refAutoReset,
refDebounced,
refDefault,
refThrottled,
refWithControl,
resolveRef,
resolveUnref,
set,
setSSRHandler,
syncRef,
syncRefs,
templateRef,
throttleFilter,
timestamp,
toArray,
toReactive,
toRef,
toRefs,
toValue,
tryOnBeforeMount,
tryOnBeforeUnmount,
tryOnMounted,
tryOnScopeDispose,
tryOnUnmounted,
unrefElement,
until,
useActiveElement,
useAnimate,
useArrayDifference,
useArrayEvery,
useArrayFilter,
useArrayFind,
useArrayFindIndex,
useArrayFindLast,
useArrayIncludes,
useArrayJoin,
useArrayMap,
useArrayReduce,
useArraySome,
useArrayUnique,
useAsyncQueue,
useAsyncState,
useBase64,
useBattery,
useBluetooth,
useBreakpoints,
useBroadcastChannel,
useBrowserLocation,
useCached,
useClipboard,
useClipboardItems,
useCloned,
useColorMode,
useConfirmDialog,
useCountdown,
useCounter,
useCssVar,
useCurrentElement,
useCycleList,
useDark,
useDateFormat,
useDebounceFn,
useDebouncedRefHistory,
useDeviceMotion,
useDeviceOrientation,
useDevicePixelRatio,
useDevicesList,
useDisplayMedia,
useDocumentVisibility,
useDraggable,
useDropZone,
useElementBounding,
useElementByPoint,
useElementHover,
useElementSize,
useElementVisibility,
useEventBus,
useEventListener,
useEventSource,
useEyeDropper,
useFavicon,
useFetch,
useFileDialog,
useFileSystemAccess,
useFocus,
useFocusWithin,
useFps,
useFullscreen,
useGamepad,
useGeolocation,
useIdle,
useImage,
useInfiniteScroll,
useIntersectionObserver,
useInterval,
useIntervalFn,
useKeyModifier,
useLastChanged,
useLocalStorage,
useMagicKeys,
useManualRefHistory,
useMediaControls,
useMediaQuery,
useMemoize,
useMemory,
useMounted,
useMouse,
useMouseInElement,
useMousePressed,
useMutationObserver,
useNavigatorLanguage,
useNetwork,
useNow,
useObjectUrl,
useOffsetPagination,
useOnline,
usePageLeave,
useParallax,
useParentElement,
usePerformanceObserver,
usePermission,
usePointer,
usePointerLock,
usePointerSwipe,
usePreferredColorScheme,
usePreferredContrast,
usePreferredDark,
usePreferredLanguages,
usePreferredReducedMotion,
usePreferredReducedTransparency,
usePrevious,
useRafFn,
useRefHistory,
useResizeObserver,
useSSRWidth,
useScreenOrientation,
useScreenSafeArea,
useScriptTag,
useScroll,
useScrollLock,
useSessionStorage,
useShare,
useSorted,
useSpeechRecognition,
useSpeechSynthesis,
useStepper,
useStorage,
useStorageAsync,
useStyleTag,
useSupported,
useSwipe,
useTemplateRefsList,
useTextDirection,
useTextSelection,
useTextareaAutosize,
useThrottleFn,
useThrottledRefHistory,
useTimeAgo,
useTimeout,
useTimeoutFn,
useTimeoutPoll,
useTimestamp,
useTitle,
useToNumber,
useToString,
useToggle,
useTransition,
useUrlSearchParams,
useUserMedia,
useVModel,
useVModels,
useVibrate,
useVirtualList,
useWakeLock,
useWebNotification,
useWebSocket,
useWebWorker,
useWebWorkerFn,
useWindowFocus,
useWindowScroll,
useWindowSize,
watchArray,
watchAtMost,
watchDebounced,
watchDeep,
watchIgnorable,
watchImmediate,
watchOnce,
watchPausable,
watchThrottled,
watchTriggerable,
watchWithFilter,
whenever
} from "./chunk-B6YPYVPP.js";
import "./chunk-I4O5PVBA.js";
export {
DefaultMagicKeysAliasMap,
StorageSerializers,
TransitionPresets,
assert,
computedAsync as asyncComputed,
refAutoReset as autoResetRef,
breakpointsAntDesign,
breakpointsBootstrapV5,
breakpointsElement,
breakpointsMasterCss,
breakpointsPrimeFlex,
breakpointsQuasar,
breakpointsSematic,
breakpointsTailwind,
breakpointsVuetify,
breakpointsVuetifyV2,
breakpointsVuetifyV3,
bypassFilter,
camelize,
clamp,
cloneFnJSON,
computedAsync,
computedEager,
computedInject,
computedWithControl,
containsProp,
computedWithControl as controlledComputed,
controlledRef,
createEventHook,
createFetch,
createFilterWrapper,
createGlobalState,
createInjectionState,
reactify as createReactiveFn,
createRef,
createReusableTemplate,
createSharedComposable,
createSingletonPromise,
createTemplatePromise,
createUnrefFn,
customStorageEventName,
debounceFilter,
refDebounced as debouncedRef,
watchDebounced as debouncedWatch,
defaultDocument,
defaultLocation,
defaultNavigator,
defaultWindow,
computedEager as eagerComputed,
executeTransition,
extendRef,
formatDate,
formatTimeAgo,
get,
getLifeCycleTarget,
getSSRHandler,
hasOwn,
hyphenate,
identity,
watchIgnorable as ignorableWatch,
increaseWithUnit,
injectLocal,
invoke,
isClient,
isDef,
isDefined,
isIOS,
isObject,
isWorker,
makeDestructurable,
mapGamepadToXbox360Controller,
noop,
normalizeDate,
notNullish,
now,
objectEntries,
objectOmit,
objectPick,
onClickOutside,
onElementRemoval,
onKeyDown,
onKeyPressed,
onKeyStroke,
onKeyUp,
onLongPress,
onStartTyping,
pausableFilter,
watchPausable as pausableWatch,
promiseTimeout,
provideLocal,
provideSSRWidth,
pxValue,
rand,
reactify,
reactifyObject,
reactiveComputed,
reactiveOmit,
reactivePick,
refAutoReset,
refDebounced,
refDefault,
refThrottled,
refWithControl,
resolveRef,
resolveUnref,
set,
setSSRHandler,
syncRef,
syncRefs,
templateRef,
throttleFilter,
refThrottled as throttledRef,
watchThrottled as throttledWatch,
timestamp,
toArray,
toReactive,
toRef,
toRefs,
toValue,
tryOnBeforeMount,
tryOnBeforeUnmount,
tryOnMounted,
tryOnScopeDispose,
tryOnUnmounted,
unrefElement,
until,
useActiveElement,
useAnimate,
useArrayDifference,
useArrayEvery,
useArrayFilter,
useArrayFind,
useArrayFindIndex,
useArrayFindLast,
useArrayIncludes,
useArrayJoin,
useArrayMap,
useArrayReduce,
useArraySome,
useArrayUnique,
useAsyncQueue,
useAsyncState,
useBase64,
useBattery,
useBluetooth,
useBreakpoints,
useBroadcastChannel,
useBrowserLocation,
useCached,
useClipboard,
useClipboardItems,
useCloned,
useColorMode,
useConfirmDialog,
useCountdown,
useCounter,
useCssVar,
useCurrentElement,
useCycleList,
useDark,
useDateFormat,
refDebounced as useDebounce,
useDebounceFn,
useDebouncedRefHistory,
useDeviceMotion,
useDeviceOrientation,
useDevicePixelRatio,
useDevicesList,
useDisplayMedia,
useDocumentVisibility,
useDraggable,
useDropZone,
useElementBounding,
useElementByPoint,
useElementHover,
useElementSize,
useElementVisibility,
useEventBus,
useEventListener,
useEventSource,
useEyeDropper,
useFavicon,
useFetch,
useFileDialog,
useFileSystemAccess,
useFocus,
useFocusWithin,
useFps,
useFullscreen,
useGamepad,
useGeolocation,
useIdle,
useImage,
useInfiniteScroll,
useIntersectionObserver,
useInterval,
useIntervalFn,
useKeyModifier,
useLastChanged,
useLocalStorage,
useMagicKeys,
useManualRefHistory,
useMediaControls,
useMediaQuery,
useMemoize,
useMemory,
useMounted,
useMouse,
useMouseInElement,
useMousePressed,
useMutationObserver,
useNavigatorLanguage,
useNetwork,
useNow,
useObjectUrl,
useOffsetPagination,
useOnline,
usePageLeave,
useParallax,
useParentElement,
usePerformanceObserver,
usePermission,
usePointer,
usePointerLock,
usePointerSwipe,
usePreferredColorScheme,
usePreferredContrast,
usePreferredDark,
usePreferredLanguages,
usePreferredReducedMotion,
usePreferredReducedTransparency,
usePrevious,
useRafFn,
useRefHistory,
useResizeObserver,
useSSRWidth,
useScreenOrientation,
useScreenSafeArea,
useScriptTag,
useScroll,
useScrollLock,
useSessionStorage,
useShare,
useSorted,
useSpeechRecognition,
useSpeechSynthesis,
useStepper,
useStorage,
useStorageAsync,
useStyleTag,
useSupported,
useSwipe,
useTemplateRefsList,
useTextDirection,
useTextSelection,
useTextareaAutosize,
refThrottled as useThrottle,
useThrottleFn,
useThrottledRefHistory,
useTimeAgo,
useTimeout,
useTimeoutFn,
useTimeoutPoll,
useTimestamp,
useTitle,
useToNumber,
useToString,
useToggle,
useTransition,
useUrlSearchParams,
useUserMedia,
useVModel,
useVModels,
useVibrate,
useVirtualList,
useWakeLock,
useWebNotification,
useWebSocket,
useWebWorker,
useWebWorkerFn,
useWindowFocus,
useWindowScroll,
useWindowSize,
watchArray,
watchAtMost,
watchDebounced,
watchDeep,
watchIgnorable,
watchImmediate,
watchOnce,
watchPausable,
watchThrottled,
watchTriggerable,
watchWithFilter,
whenever
};
//# sourceMappingURL=vitepress___@vueuse_core.js.map

View File

@@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

343
.vitepress/cache/deps/vue.js vendored Normal file
View File

@@ -0,0 +1,343 @@
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-I4O5PVBA.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
};
//# sourceMappingURL=vue.js.map

7
.vitepress/cache/deps/vue.js.map vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

285
README.md
View File

@@ -1,15 +1,16 @@
# 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%;" />
<img src="assets/image-20250827175023917.png" alt="image-20250827175023917" style="zoom: 33%;" /><img src="assets/image-20250827175109430.png" alt="image-20250827175109430" style="zoom:33%;" />
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=timeshiftsauce/CeruMusic&type=Date)](https://www.star-history.com/#timeshiftsauce/CeruMusic&Date)
## 技术栈
- **Electron**:用于构建跨平台桌面应用
@@ -20,10 +21,276 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工
- **CeruPlugins**:音乐插件运行环境(仅提供框架,不包含默认插件)
- **AMLL**:音乐生态辅助模块(仅提供功能接口,不关联具体音乐数据源)
## 项目结构
```ast
CeruMuisc/
├── .github/
│ └── workflows/
│ ├── auto-sync-release.yml
│ ├── deploydocs.yml
│ ├── main.yml
│ ├── sync-releases-to-webdav.yml
│ └── uploadpan.yml
├── scripts/
│ ├── auth-test.js
│ ├── genAst.js
│ └── test-alist.js
├── src/
│ ├── common/
│ │ ├── types/
│ │ │ ├── playList.ts
│ │ │ └── songList.ts
│ │ ├── utils/
│ │ │ ├── lyricUtils/
│ │ │ │ ├── kg.js
│ │ │ │ └── util.ts
│ │ │ ├── common.ts
│ │ │ ├── nodejs.ts
│ │ │ └── renderer.ts
│ │ └── index.ts
│ ├── main/
│ │ ├── events/
│ │ │ ├── ai.ts
│ │ │ ├── autoUpdate.ts
│ │ │ ├── directorySettings.ts
│ │ │ ├── musicCache.ts
│ │ │ └── songList.ts
│ │ ├── services/
│ │ │ ├── music/
│ │ │ │ ├── index.ts
│ │ │ │ ├── net-ease-service.ts
│ │ │ │ └── service-base.ts
│ │ │ ├── musicCache/
│ │ │ │ └── index.ts
│ │ │ ├── musicSdk/
│ │ │ │ ├── index.ts
│ │ │ │ ├── service.ts
│ │ │ │ └── type.ts
│ │ │ ├── plugin/
│ │ │ │ ├── manager/
│ │ │ │ │ ├── CeruMusicPluginHost.ts
│ │ │ │ │ └── converter-event-driven.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── logger.ts
│ │ │ ├── songList/
│ │ │ │ ├── ManageSongList.ts
│ │ │ │ └── PlayListSongs.ts
│ │ │ └── ai-service.ts
│ │ ├── utils/
│ │ │ ├── musicSdk/
│ │ │ │ ├── kg/
│ │ │ │ │ ├── temp/
│ │ │ │ │ │ ├── musicSearch-new.js
│ │ │ │ │ │ └── songList-new.js
│ │ │ │ │ ├── vendors/
│ │ │ │ │ │ └── infSign.min.js
│ │ │ │ │ ├── album.js
│ │ │ │ │ ├── api-test.js
│ │ │ │ │ ├── comment.js
│ │ │ │ │ ├── hotSearch.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── leaderboard.js
│ │ │ │ │ ├── lyric.js
│ │ │ │ │ ├── musicInfo.js
│ │ │ │ │ ├── musicSearch.js
│ │ │ │ │ ├── pic.js
│ │ │ │ │ ├── singer.js
│ │ │ │ │ ├── songList.js
│ │ │ │ │ ├── tipSearch.js
│ │ │ │ │ └── util.js
│ │ │ │ ├── kw/
│ │ │ │ │ ├── album.js
│ │ │ │ │ ├── api-temp.js
│ │ │ │ │ ├── api-test.js
│ │ │ │ │ ├── comment.js
│ │ │ │ │ ├── hotSearch.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── kwdecode.ts
│ │ │ │ │ ├── leaderboard.js
│ │ │ │ │ ├── lyric.js
│ │ │ │ │ ├── musicSearch.js
│ │ │ │ │ ├── pic.js
│ │ │ │ │ ├── songList.js
│ │ │ │ │ ├── tipSearch.js
│ │ │ │ │ └── util.js
│ │ │ │ ├── mg/
│ │ │ │ │ ├── temp/
│ │ │ │ │ │ └── leaderboard-old.js
│ │ │ │ │ ├── utils/
│ │ │ │ │ │ ├── index.js
│ │ │ │ │ │ └── mrc.js
│ │ │ │ │ ├── album.js
│ │ │ │ │ ├── api-test.js
│ │ │ │ │ ├── comment.js
│ │ │ │ │ ├── hotSearch.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── leaderboard.js
│ │ │ │ │ ├── lyric.js
│ │ │ │ │ ├── musicInfo.js
│ │ │ │ │ ├── musicSearch.js
│ │ │ │ │ ├── pic.js
│ │ │ │ │ ├── songId.js
│ │ │ │ │ ├── songList.js
│ │ │ │ │ └── tipSearch.js
│ │ │ │ ├── tx/
│ │ │ │ │ ├── api-test.js
│ │ │ │ │ ├── comment.js
│ │ │ │ │ ├── hotSearch.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── leaderboard.js
│ │ │ │ │ ├── lyric.js
│ │ │ │ │ ├── musicInfo.js
│ │ │ │ │ ├── musicSearch.js
│ │ │ │ │ ├── singer.js
│ │ │ │ │ ├── songList.js
│ │ │ │ │ └── tipSearch.js
│ │ │ │ ├── wy/
│ │ │ │ │ ├── utils/
│ │ │ │ │ │ ├── crypto.js
│ │ │ │ │ │ └── index.js
│ │ │ │ │ ├── api-test.js
│ │ │ │ │ ├── comment.js
│ │ │ │ │ ├── hotSearch.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── leaderboard.js
│ │ │ │ │ ├── lyric.js
│ │ │ │ │ ├── musicDetail.js
│ │ │ │ │ ├── musicInfo.js
│ │ │ │ │ ├── musicSearch.js
│ │ │ │ │ ├── singer.js
│ │ │ │ │ ├── songList.js
│ │ │ │ │ └── tipSearch.js
│ │ │ │ ├── api-source-info.ts
│ │ │ │ ├── index.js
│ │ │ │ ├── options.js
│ │ │ │ └── utils.js
│ │ │ ├── array.ts
│ │ │ ├── index.ts
│ │ │ ├── object.ts
│ │ │ ├── path.ts
│ │ │ ├── request.js
│ │ │ └── utils.ts
│ │ ├── autoUpdate.ts
│ │ └── index.ts
│ ├── preload/
│ │ ├── index.d.ts
│ │ └── index.ts
│ ├── renderer/
│ │ ├── public/
│ │ │ ├── default-cover.png
│ │ │ ├── head.jpg
│ │ │ ├── logo.svg
│ │ │ ├── star.png
│ │ │ └── wldss.png
│ │ ├── src/
│ │ │ ├── api/
│ │ │ │ └── songList.ts
│ │ │ ├── components/
│ │ │ │ ├── AI/
│ │ │ │ │ └── FloatBall.vue
│ │ │ │ ├── Music/
│ │ │ │ │ └── SongVirtualList.vue
│ │ │ │ ├── Play/
│ │ │ │ │ ├── AudioVisualizer.vue
│ │ │ │ │ ├── FullPlay.vue
│ │ │ │ │ ├── GlobalAudio.vue
│ │ │ │ │ ├── PlaylistActions.vue
│ │ │ │ │ ├── PlaylistDrawer.vue
│ │ │ │ │ ├── PlayMusic.vue
│ │ │ │ │ └── ShaderBackground.vue
│ │ │ │ ├── Search/
│ │ │ │ │ └── SearchComponent.vue
│ │ │ │ ├── Settings/
│ │ │ │ │ ├── AIFloatBallSettings.vue
│ │ │ │ │ ├── DirectorySettings.vue
│ │ │ │ │ ├── MusicCache.vue
│ │ │ │ │ ├── PlaylistSettings.vue
│ │ │ │ │ └── UpdateSettings.vue
│ │ │ │ ├── ThemeSelector.vue
│ │ │ │ ├── TitleBarControls.vue
│ │ │ │ ├── UpdateExample.vue
│ │ │ │ ├── UpdateProgress.vue
│ │ │ │ └── Versions.vue
│ │ │ ├── composables/
│ │ │ │ └── useAutoUpdate.ts
│ │ │ ├── layout/
│ │ │ │ └── index.vue
│ │ │ ├── router/
│ │ │ │ └── index.ts
│ │ │ ├── services/
│ │ │ │ ├── music/
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── service-base.ts
│ │ │ │ └── autoUpdateService.ts
│ │ │ ├── store/
│ │ │ │ ├── ControlAudio.ts
│ │ │ │ ├── LocalUserDetail.ts
│ │ │ │ ├── search.ts
│ │ │ │ └── Settings.ts
│ │ │ ├── types/
│ │ │ │ ├── audio.ts
│ │ │ │ ├── Sources.ts
│ │ │ │ └── userInfo.ts
│ │ │ ├── utils/
│ │ │ │ ├── audio/
│ │ │ │ │ ├── audioManager.ts
│ │ │ │ │ ├── download.ts
│ │ │ │ │ ├── useSmtc.ts
│ │ │ │ │ └── volume.ts
│ │ │ │ ├── color/
│ │ │ │ │ ├── colorExtractor.ts
│ │ │ │ │ └── contrastColor.ts
│ │ │ │ └── playlist/
│ │ │ │ ├── playlistExportImport.ts
│ │ │ │ └── playlistManager.ts
│ │ │ ├── views/
│ │ │ │ ├── home/
│ │ │ │ │ └── index.vue
│ │ │ │ ├── music/
│ │ │ │ │ ├── find.vue
│ │ │ │ │ ├── list.vue
│ │ │ │ │ ├── local.vue
│ │ │ │ │ ├── recent.vue
│ │ │ │ │ └── search.vue
│ │ │ │ ├── settings/
│ │ │ │ │ ├── index.vue
│ │ │ │ │ └── plugins.vue
│ │ │ │ └── welcome/
│ │ │ │ └── index.vue
│ │ │ ├── App.vue
│ │ │ ├── env.d.ts
│ │ │ └── main.ts
│ │ ├── auto-imports.d.ts
│ │ ├── components.d.ts
│ │ └── index.html
│ └── types/
│ ├── musicCache.ts
│ └── songList.ts
├── website/
│ ├── CeruUse.html
│ ├── design.html
│ ├── index.html
│ ├── pluginDev.html
│ ├── script.js
│ └── styles.css
├── electron-builder.yml
├── electron.vite.config.ts
├── eslint.config.js
├── LICENSE
├── package-lock.json
├── package.json
├── qodana.sarif.json
├── qodana.yaml
├── README.md
├── tsconfig.json
├── tsconfig.node.json
├── tsconfig.web.json
└── yarn.lock
```
## 主要功能
- 提供插件加载与管理功能,支持通过合规插件获取公开音乐信息
- 支持通过插件获取歌词、专辑封面等公开元数据
- 支持虚拟滚动列表,优化大量数据渲染性能
@@ -36,16 +303,12 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工
### 推荐开发环境
- **IDE**: VS Code 或 WebStorm
- **Node.js 版本**: 22 及以上
- **包管理器**: **yarn**
### 项目设置
1. 安装依赖:
```bash
@@ -66,8 +329,6 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工
### 平台构建指令
- Windows
```bash
@@ -86,8 +347,6 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工
yarn build:linux
```
> 提示:构建后的应用仅包含播放器框架,需用户自行配置合规插件方可获取音乐数据。
## 文档与资源

View File

@@ -1,3 +0,0 @@
provider: generic
url: https://update.ceru.shiqianjiang.cn
updaterCacheDirName: ceru-music-updater

View File

@@ -1,12 +1,19 @@
import { defineConfig } from 'vitepress'
import note from 'markdown-it-footnote'
// https://vitepress.dev/reference/site-config
export default defineConfig({
lang: 'zh-CN',
title: "Ceru Music",
base: process.env.BASE_URL ?? '/CeruMusic/',
description: "Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,一个跨平台的音乐播放器应用,支持基于合规插件获取公开音乐信息与播放功能。",
title: 'Ceru Music',
base: '/',
description:
'Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,一个跨平台的音乐播放器应用,支持基于合规插件获取公开音乐信息与播放功能。',
markdown:{
config(md){
md.use(note)
}
},
themeConfig: {
returnToTopLabel: '返回顶部',
// https://vitepress.dev/reference/default-theme-config
logo: '/logo.svg',
nav: [
@@ -18,8 +25,16 @@ export default defineConfig({
{
text: 'CeruMusic',
items: [
{ text: '使用教程', link: '/guide/' },
{ text: '软件设计文档', link: '/guide/design' }
{ text: '安装教程', link: '/guide/' },
{
text: '使用教程',
items: [
{ text: '音乐播放列表', link: '/guide/used/playList' },
]
},
{ text: '软件设计文档', link: '/guide/design' },
{ text: '更新日志', link: '/guide/updateLog' },
{ text: '更新计划', link: '/guide/update'}
]
},
{
@@ -37,7 +52,6 @@ export default defineConfig({
{ 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.',
@@ -48,9 +62,23 @@ export default defineConfig({
},
search: {
provider: 'local'
}
},
outline: {
level: [2,4],
label: '文章导航'
},
docFooter: {
next: '下一篇',
prev: '上一篇'
},
lastUpdatedText: '上次更新',
},
sitemap: {
hostname: 'https://ceru.docs.shiqianjiang.cn'
},
lastUpdated: true,
head: [['link', { rel: 'icon', href: (process.env.BASE_URL ?? '/CeruMusic/') + 'logo.svg' }]]
head: [['link', { rel: 'icon', href: '/logo.svg' }]],
})
console.log(process.env.BASE_URL_DOCS)
// Smooth scrolling functions

View File

@@ -3,7 +3,7 @@
}
::view-transition-new(*) {
animation: globalDark .5s ease-in;
animation: globalDark 0.5s ease-in;
}
@keyframes globalDark {

View File

@@ -1,7 +1,10 @@
import { nextTick, provide } from 'vue'
// 判断是否能使用 startViewTransition
const enableTransitions = () => {
return 'startViewTransition' in document && window.matchMedia('(prefers-reduced-motion: no-preference)').matches
return (
'startViewTransition' in document &&
window.matchMedia('(prefers-reduced-motion: no-preference)').matches
)
}
// 切换动画
export const toggleDark = (isDark: any) => {

View File

@@ -1,7 +1,7 @@
import DefaultTheme from 'vitepress/theme'
import './style.css'
import './style.scss'
import './dark.css'
import MyLayout from './MyLayout.vue';
import MyLayout from './MyLayout.vue'
// history.scrollRestoration = 'manual'
export default {
@@ -11,6 +11,3 @@ export default {
// ...
}
}

View File

@@ -157,26 +157,26 @@ html.dark #app {
no-repeat center;
/* 是否开启网格背景1 是0 否 */
--bg-grid: 0;
--bg-grid: 1;
/* 已完成的代办事项是否显示删除线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) ". "; */
// --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) ". "; */
// --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) ". ";
/* 主题颜色 */
@@ -245,9 +245,7 @@ html.dark #app {
opacity: 1;
}
}
li {
position: relative;
}
.vp-doc li div[class*='language-'] {
margin: 12px;
}
@@ -264,3 +262,26 @@ html .vp-doc div[class*='language-'] pre {
overflow: hidden;
padding: 0.4em;
}
.vp-doc {
h1,
h2,
h3,
h4,
h5,
h6,
p {
margin-top: 1rem;
margin-bottom: 1rem;
}
}
// .VPDoc,
// .VPDoc .container > .content {
// padding: 0 !important;
// }
.vp-doc li {
position: relative;
}
.VPDoc.has-aside .content-container {
max-width: none !important;
}

View File

@@ -1,105 +1,122 @@
---
layout: doc
---
# CeruMusic 插件开发文档
# CeruMusic 插件开发指南
## 概述
本文档介绍如何为 CeruMusic 开发音乐源插件。CeruMusic 插件是运行在沙箱环境中的 JavaScript 模块,用于从各种音乐平台获取音乐资源。
CeruMusic 支持两种类型的插件:
1. **CeruMusic 原生插件**:基于 CeruMusic API 的插件格式
2. **LX 兼容插件**:兼容 LX Music 的事件驱动插件格式
## 插件结构
本文档将详细介绍如何开发这两种类型的插件。
### 基本结构
## 文件要求
每个 CeruMusic 插件必须导出以下三个核心组件:
- **编码格式**UTF-8
- **编程语言**JavaScript (支持 ES6+ 语法)
- **文件扩展名**`.js`
```javascript
module.exports = {
pluginInfo, // 插件信息
sources, // 支持的音源
musicUrl // 获取音乐链接的函数
}
```
## 插件信息注释
# 完整示例
所有插件文件的开头必须包含以下注释格式:
```javascript
/**
* 示例音乐插件
* @author 开发者名称
* @name 插件名称
* @description 插件描述
* @version 1.0.0
* @author 作者名称
* @homepage https://example.com
*/
```
### 注释字段说明
- `@name`:插件名称,建议不超过 24 个字符
- `@description`:插件描述,建议不超过 36 个字符(可选)
- `@version`:版本号(可选)
- `@author`:作者名称(可选)
- `@homepage`:主页地址(可选)
---
## CeruMusic 原生插件开发
首先 `澜音` 插件是面向 方法的 这意味着你直接导出方法即可为播放器提供音源
### 基本结构
```javascript
/**
* @name 示例音乐源
* @description CeruMusic 原生插件示例
* @version 1.0.0
* @author CeruMusic Team
*/
// 1. 插件信息
// 插件信息
const pluginInfo = {
name: '示例音源插件',
version: '1.0.0',
author: '开发者名称',
description: '这是一个示例音乐源插件'
}
name: "示例音乐源",
version: "1.0.0",
author: "CeruMusic Team",
description: "这是一个示例插件"
};
// 2. 支持的音源配置
// 支持的音源配置
const sources = {
demo: {
name: '示例音源',
type: 'music',
qualitys: ['128k', '320k', 'flac']
kw:{
name: "酷我音乐",
qualities: ['128k', '320k', 'flac', 'flac24bit']
},
demo2: {
name: '示例音源2',
type: 'music',
qualitys: ['128k', '320k']
tx:{
name: "QQ音乐",
qualities: ['128k', '320k', 'flac']
}
}
};
// 3. 获取音乐URL的核心函数
// 获取音乐链接的主要方法
async function musicUrl(source, musicInfo, quality) {
// 从 cerumusic 对象获取 API
const { request, env, version } = cerumusic
try {
// 使用 cerumusic API 发送 HTTP 请求
const result = await cerumusic.request('https://api.example.com/music', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
...你的其他参数 可以 是密钥或者其他...
},
body: JSON.stringify({
id: musicInfo.id,
quality: quality
})
});
// 构建请求参数
const songId = musicInfo.hash ?? musicInfo.songmid
const apiUrl = `https://api.example.com/music/${source}/${songId}/${quality}`
console.log(`[${pluginInfo.name}] 请求音乐链接: ${apiUrl}`)
// 发起网络请求
const { body, statusCode } = await request(apiUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'User-Agent': `cerumusic-${env}/${version}`
if (result.statusCode === 200 && result.body.url) {
return result.body.url;
} else {
throw new Error('获取音乐链接失败');
}
})
// 处理响应
if (statusCode !== 200 || body.code !== 200) {
const errorMessage = body.msg || `接口错误 (HTTP: ${statusCode})`
console.error(`[${pluginInfo.name}] Error: ${errorMessage}`)
throw new Error(errorMessage)
} catch (error) {
console.error('获取音乐链接时发生错误:', error);
throw error;
}
console.log(`[${pluginInfo.name}] 获取成功: ${body.url}`)
return body.url
}
// 4. 可选:获取封面图片
// 获取歌曲封面(可选)
async function getPic(source, musicInfo) {
const { request } = cerumusic
const songId = musicInfo.hash ?? musicInfo.songmid
const { body } = await request(`https://api.example.com/pic/${source}/${songId}`)
return body.picUrl
try {
const result = await cerumusic.request(`https://api.example.com/pic/${musicInfo.id}`);
return result.body.picUrl;
} catch (error) {
throw new Error('获取封面失败: ' + error.message);
}
}
// 5. 可选:获取歌词
// 获取歌词(可选)
async function getLyric(source, musicInfo) {
const { request } = cerumusic
const songId = musicInfo.hash ?? musicInfo.songmid
const { body } = await request(`https://api.example.com/lyric/${source}/${songId}`)
return body.lyric
try {
const result = await cerumusic.request(`https://api.example.com/lyric/${musicInfo.id}`);
return result.body.lyric;
} catch (error) {
throw new Error('获取歌词失败: ' + error.message);
}
}
// 导出插件
@@ -107,279 +124,549 @@ module.exports = {
pluginInfo,
sources,
musicUrl,
getPic, // 可选
getLyric // 可选
getPic, // 可选
getLyric // 可选
};
```
> #### PS:
>
> - `sources key` 取值
>
> - wy 网易云音乐 |
> - tx QQ音乐 |
> - kg 酷狗音乐 |
> - mg 咪咕音乐 |
> - kw 酷我音乐
>
> - 导出
>
> ```javascript
> module.exports = {
> sources, // 你的音源支持
> };
> ```
>
> - 支持的音质 ` sources.qualities: ['128k', '320k', 'flac']`
>
> - `128k`: 128kbps
> - `320k`: 320kbps
> - `flac`: FLAC 无损
> - `flac24bit`: 24bit FLAC
> - `hires`: Hi-Res 高解析度
> - `atmos`: 杜比全景声
> - `master`: 母带音质
### CeruMusic API 参考
#### cerumusic.request(url, options)
HTTP 请求方法,返回 Promise。
**参数:**
- `url` (string): 请求地址
- `options` (object): 请求选项
- `method`: 请求方法 (GET, POST, PUT, DELETE 等)
- `headers`: 请求头对象
- `body`: 请求体
- `timeout`: 超时时间(毫秒)
**返回值:**
```javascript
{
statusCode: 200,
headers: {...},
body: {...} // 自动解析的响应体
}
```
## 详细说明
#### cerumusic.utils
### 1. pluginInfo 对象
插件的基本信息,必须包含以下字段:
工具方法集合:
```javascript
const pluginInfo = {
name: '插件名称', // 必需:插件显示名称
version: '1.0.0', // 必需:版本号
author: '作者名', // 必需:作者信息
description: '插件描述' // 必需:功能描述
}
// Buffer 操作
cerumusic.utils.buffer.from(data, encoding)
cerumusic.utils.buffer.bufToString(buffer, encoding)
// 加密工具
cerumusic.utils.crypto.md5(str)
cerumusic.utils.crypto.randomBytes(size)
cerumusic.utils.crypto.aesEncrypt(data, mode, key, iv)
cerumusic.utils.crypto.rsaEncrypt(data, key)
```
### 2. sources 对象
#### cerumusic.NoticeCenter(type, data)
定义插件支持的音源,键为音源标识,值为音源配置
发送通知到用户界面
```javascript
const sources = {
// 音源标识用于API调用
source_id: {
name: '音源显示名称', // 必需:用户看到的名称
type: 'music', // 必需:固定为 'music'
qualitys: [
// 必需:支持的音质列表
'128k', // 标准音质
'320k', // 高音质
'flac', // 无损音质
'flac24bit', // 24位无损
'hires' // 高解析度
]
cerumusic.NoticeCenter('info', {
title: '通知标题',
content: '通知内容',
url: 'https://example.com', // 可选 当通知为update 版本跟新可传
version: '版本号', // 当通知为update 版本跟新可传
pluginInfo: {
name: '插件名称',
type: 'cr', // 固定唯一标识
}// 当通知为update 版本跟新可传
});
```
**通知类型:**
- `'info'`: 信息通知
- `'success'`: 成功通知
- `'warn'`: 警告通知
- `'error'`: 错误通知
- `'update'`: 更新通知
---
## LX 兼容插件开发 引用于落雪官网改编
CeruMusic 完全兼容 LX Music 的插件格式,支持事件驱动的开发模式。
### 基本结构
```javascript
/**
* @name 测试音乐源
* @description 我只是一个测试音乐源哦
* @version 1.0.0
* @author xxx
* @homepage http://xxx
*/
const { EVENT_NAMES, request, on, send } = globalThis.lx
// 音质配置
const qualitys = {
kw: {
'128k': '128',
'320k': '320',
flac: 'flac',
flac24bit: 'flac24bit',
},
local: {},
}
// HTTP 请求封装
const httpRequest = (url, options) => new Promise((resolve, reject) => {
request(url, options, (err, resp) => {
if (err) return reject(err)
resolve(resp.body)
})
})
// API 实现
const apis = {
kw: {
musicUrl({ songmid }, quality) {
return httpRequest('http://xxx').then(data => {
return data.url
})
},
},
local: {
musicUrl(info) {
return httpRequest('http://xxx').then(data => {
return data.url
})
},
pic(info) {
return httpRequest('http://xxx').then(data => {
return data.url
})
},
lyric(info) {
return httpRequest('http://xxx').then(data => {
return {
lyric: '...', // 歌曲歌词
tlyric: '...', // 翻译歌词,没有可为 null
rlyric: '...', // 罗马音歌词,没有可为 null
lxlyric: '...', // lx 逐字歌词,没有可为 null
}
})
}
}
}
```
### 3. musicUrl 函数
获取音乐播放链接的核心函数:
```javascript
async function musicUrl(source, musicInfo, quality) {
// source: 音源标识sources 对象的键)
// musicInfo: 歌曲信息对象
// quality: 请求的音质
// 返回: Promise<string> - 音乐播放链接
}
```
#### musicInfo 对象结构
```javascript
const musicInfo = {
songmid: '歌曲ID', // 歌曲标识符
hash: '歌曲哈希', // 备用标识符
title: '歌曲标题', // 歌曲名称
artist: '艺术家', // 演唱者
album: '专辑名' // 专辑信息
// ... 其他可能的字段
}
```
## 可用 API
### cerumusic 对象
插件运行时可以访问 `cerumusic` 全局对象:
```javascript
const { request, env, version, utils } = cerumusic
```
#### request 函数
用于发起 HTTP 请求:
```javascript
// Promise 模式
const response = await request(url, options)
// Callback 模式
request(url, options, (error, response) => {
if (error) {
console.error('请求失败:', error)
return
// 注册 API 请求事件
on(EVENT_NAMES.request, ({ source, action, info }) => {
switch (action) {
case 'musicUrl':
return apis[source].musicUrl(info.musicInfo, qualitys[source][info.type])
case 'lyric':
return apis[source].lyric(info.musicInfo)
case 'pic':
return apis[source].pic(info.musicInfo)
}
console.log('响应:', response)
})
// 发送初始化完成事件
send(EVENT_NAMES.inited, {
openDevTools: false, // 是否打开开发者工具
sources: {
kw: {
name: '酷我音乐',
type: 'music',
actions: ['musicUrl'],
qualitys: ['128k', '320k', 'flac', 'flac24bit'],
},
local: {
name: '本地音乐',
type: 'music',
actions: ['musicUrl', 'lyric', 'pic'],
qualitys: [],
},
},
})
```
**参数说明:**
### LX API 参考
- `url` (string): 请求地址
- `options` (Object): 请求选项
- `method`: HTTP 方法 ('GET', 'POST', 等)
- `headers`: 请求头对象
- `body`: 请求体POST 请求时)
#### globalThis.lx.EVENT_NAMES
**响应格式:**
事件名称常量:
- `inited`: 初始化完成事件
- `request`: API 请求事件
- `updateAlert`: 更新提示事件
#### globalThis.lx.on(eventName, handler)
注册事件监听器:
```javascript
{
body: {}, // 解析后的响应体
statusCode: 200, // HTTP 状态码
headers: {} // 响应头
}
lx.on(lx.EVENT_NAMES.request, ({ source, action, info }) => {
// 必须返回 Promise
return Promise.resolve(result);
});
```
#### utils 对象
#### globalThis.lx.send(eventName, data)
提供实用工具函数
发送事件
```javascript
const { utils } = cerumusic
// 发送初始化事件
lx.send(lx.EVENT_NAMES.inited, {
openDevTools: false,
sources: {...}
});
// 发送更新提示
lx.send(lx.EVENT_NAMES.updateAlert, {
log: '更新日志\n修复了一些问题',
updateUrl: 'https://example.com/update'
});
```
#### globalThis.lx.request(url, options, callback)
HTTP 请求方法:
```javascript
lx.request('https://api.example.com', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
timeout: 10000
}, (err, resp) => {
if (err) {
console.error('请求失败:', err);
return;
}
console.log('响应:', resp.body);
});
```
#### globalThis.lx.utils
工具方法:
```javascript
// Buffer 操作
const buffer = utils.buffer.from('hello', 'utf8')
const string = utils.buffer.bufToString(buffer, 'utf8')
lx.utils.buffer.from(data, encoding)
lx.utils.buffer.bufToString(buffer, encoding)
// 加密工具
lx.utils.crypto.md5(str)
lx.utils.crypto.aesEncrypt(buffer, mode, key, iv)
lx.utils.crypto.randomBytes(size)
lx.utils.crypto.rsaEncrypt(buffer, key)
```
---
## 音源配置
### 支持的音源 ID
- `kw`: 酷我音乐
- `kg`: 酷狗音乐
- `tx`: QQ音乐
- `wy`: 网易云音乐
- `mg`: 咪咕音乐
- `local`: 本地音乐
### 支持的音质
- `128k`: 128kbps
- `320k`: 320kbps
- `flac`: FLAC 无损
- `flac24bit`: 24bit FLAC
- `hires`: Hi-Res 高解析度
- `atmos`: 杜比全景声
- `master`: 母带音质
---
## 错误处理
### 最佳实践
1. **总是检查 API 响应状态**
```javascript
async function musicUrl(source, musicInfo, quality) {
try {
// 参数验证
if (!musicInfo || !musicInfo.id) {
throw new Error('音乐信息不完整');
}
```javascript
if (statusCode !== 200 || body.code !== 200) {
throw new Error(`请求失败: ${body.msg || '未知错误'}`)
}
```
// API 调用
const result = await cerumusic.request(url, options);
// 结果验证
if (!result || result.statusCode !== 200) {
throw new Error(`API 请求失败: ${result?.statusCode || 'Unknown'}`);
}
2. **提供有意义的错误信息**
if (!result.body || !result.body.url) {
throw new Error('返回数据格式错误');
}
```javascript
console.error(`[${pluginInfo.name}] Error: ${errorMessage}`)
throw new Error(errorMessage)
```
3. **处理网络异常**
```javascript
try {
const response = await request(url, options)
// 处理响应
} catch (error) {
console.error(`[${pluginInfo.name}] 网络请求失败:`, error.message)
throw new Error(`网络错误: ${error.message}`)
}
```
return result.body.url;
} catch (error) {
// 记录错误日志
console.error(`[${source}] 获取音乐链接失败:`, error.message);
// 重新抛出错误供上层处理
throw new Error(`获取 ${source} 音乐链接失败: ${error.message}`);
}
}
```
### 常见错误类型
- **网络错误**: 无法连接到 API 服务器
- **认证错误**: API 密钥无效或过期
- **参数错误**: 请求参数格式不正确
- **资源不存在**: 请求的歌曲不存在
- **限流错误**: 请求过于频繁
1. **网络错误**: 请求超时、连接失败
2. **API 错误**: 接口返回错误状态码
3. **数据错误**: 返回数据格式不正确
4. **参数错误**: 传入参数不完整或格式错误
## 事件驱动插件
对于使用 `lx.on(EVENT_NAMES.request)` 模式的插件,可以使用转换器:
```javascript
// 使用转换器转换事件驱动插件
node converter-event-driven.js input-plugin.js output-plugin.js
```
转换后的插件将兼容 CeruMusicPluginHost。
---
## 调试技巧
### 1. 使用 console.log
```javascript
console.log(`[${pluginInfo.name}] 调试信息:`, data)
console.error(`[${pluginInfo.name}] 错误:`, error)
console.log('[插件名] 调试信息:', data);
console.warn('[插件名] 警告信息:', warning);
console.error('[插件名] 错误信息:', error);
```
### 2. 检查请求和响应
### 2. LX 插件开发者工具
```javascript
console.log('请求URL:', url)
console.log('请求选项:', options)
console.log('响应状态:', statusCode)
console.log('响应内容:', body)
send(EVENT_NAMES.inited, {
openDevTools: true, // 开启开发者工具
sources: {...}
});
```
### 3. 测试插件
创建测试文件:
### 3. 错误捕获
```javascript
const CeruMusicPluginHost = require('./CeruMusicPluginHost')
process.on('unhandledRejection', (reason, promise) => {
console.error('未处理的 Promise 拒绝:', reason);
});
```
async function testPlugin() {
const host = new CeruMusicPluginHost()
await host.loadPlugin('./my-plugin.js')
---
const musicInfo = {
songmid: 'test123',
title: '测试歌曲'
## 性能优化
### 1. 请求缓存
```javascript
const cache = new Map();
async function getCachedData(key, fetcher, ttl = 300000) {
const cached = cache.get(key);
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.data;
}
const data = await fetcher();
cache.set(key, { data, timestamp: Date.now() });
return data;
}
```
### 2. 请求超时控制
```javascript
const result = await cerumusic.request(url, {
timeout: 10000 // 10秒超时
});
```
### 3. 并发控制
```javascript
// 限制并发请求数量
const semaphore = new Semaphore(3); // 最多3个并发请求
async function limitedRequest(url, options) {
await semaphore.acquire();
try {
const url = await host.getMusicUrl('demo', musicInfo, '320k')
console.log('成功获取URL:', url)
} catch (error) {
console.error('测试失败:', error.message)
return await cerumusic.request(url, options);
} finally {
semaphore.release();
}
}
testPlugin()
```
## 发布和分发
---
### 文件结构
## 安全注意事项
```
my-plugin/
├── plugin.js # 主插件文件
├── package.json # 包信息(可选)
├── README.md # 说明文档
└── test.js # 测试文件(可选)
### 1. 输入验证
```javascript
function validateMusicInfo(musicInfo) {
if (!musicInfo || typeof musicInfo !== 'object') {
throw new Error('音乐信息格式错误');
}
if (!musicInfo.id || typeof musicInfo.id !== 'string') {
throw new Error('音乐 ID 无效');
}
return true;
}
```
### 版本管理
### 2. URL 验证
遵循语义化版本规范:
```javascript
function isValidUrl(url) {
try {
const urlObj = new URL(url);
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
} catch {
return false;
}
}
```
- `1.0.0` - 主版本.次版本.修订版本
### 3. 敏感信息保护
```javascript
// 不要在日志中输出敏感信息
console.log('请求参数:', {
...params,
token: '***', // 隐藏敏感信息
password: '***'
});
```
---
## 插件发布
### 1. 代码检查清单
- [ ] 插件信息注释完整
- [ ] 错误处理完善
- [ ] 性能优化合理
- [ ] 安全验证到位
- [ ] 测试覆盖充分
### 2. 测试建议
```javascript
// 单元测试示例
async function testMusicUrl() {
const testMusicInfo = {
id: 'test123',
name: '测试歌曲',
artist: '测试歌手'
};
try {
const url = await musicUrl('kw', testMusicInfo, '320k');
console.log('测试通过:', url);
} catch (error) {
console.error('测试失败:', error);
}
}
```
### 3. 版本管理
使用语义化版本号:
- `1.0.0`: 主版本.次版本.修订版本
- 主版本:不兼容的 API 修改
- 次版本:向下兼容的功能性新增
- 修订版本:向下兼容的问题修正
## 示例插件
查看项目中的示例:
- `example-plugin.js` - 基础插件示例
- `plugin.js` - 事件驱动插件示例
- `fm.js` - 复杂插件示例
---
## 常见问题
**Q: 如何处理需要登录的 API**
### Q: 插件加载失败怎么办?
A: 在请求头中添加认证信息,或使用 Cookie。
A: 检查以下几点:
1. 文件编码是否为 UTF-8
2. 插件信息注释格式是否正确
3. JavaScript 语法是否有错误
4. 是否正确导出了必需的方法
**Q: 如何处理加密的 API 响应?**
### Q: 如何处理跨域请求?
A: 在插件中实现解密逻辑,使用 `utils` 对象提供的工具函数。
A: CeruMusic 的请求方法不受浏览器跨域限制,可以直接请求任何域名的 API
**Q: 插件可以访问文件系统吗?**
### Q: 插件如何更新?
A: 不可以,插件运行在受限的沙箱环境中,无法直接访问文件系统。
A: 使用 `cerumusic.NoticeCenter` 事件通知用户更新:
**Q: 如何优化插件性能?**
```javascript
cerumusic.NoticeCenter('update',{
title:'新版本更新',
content:'xxxx',
version: 'v1.0.3',
url:'https://shiqianjiang.cn',
pluginInfo:{
type:'cr'
}
})
```
A: 减少不必要的网络请求,使用适当的缓存策略,避免阻塞操作。
### Q: 如何调试插件?
## 贡献指南
A:
1. 使用 `console.log` 输出调试信息 可在设置—>插件管理—>日志 查看调试
2. LX 插件可以设置 `openDevTools: true` 打开开发者工具
3. 查看 CeruMusic 的插件日志
1. Fork 项目仓库
2. 创建功能分支
3. 编写插件代码和测试
4. 提交 Pull Request
5. 等待代码审查
---
欢迎贡献新的音源插件!
## 技术支持
如有问题或建议,请通过以下方式联系:
- GitHub Issues: [CeruMusic Issues](https://github.com/timeshiftsauce/CeruMusic/issues)
- Blog (最好登录,否则需要审核): [CeruMusic Blog](https://shiqianjiang.cn/blog/4966904626407280640)

0
docs/guide/analyze.md Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

12
docs/guide/update.md Normal file
View File

@@ -0,0 +1,12 @@
# 我的-更新计划-欢迎issue
- [ ] 搜索框可以弄对应的音乐源的排行榜 搜索联想
- [ ] 导航上面这几个按钮可以稍微优化一下
- [ ] 大熊好暖: 09-21 12:52:25 在搜索歌曲出来后 大熊好暖: 09-21 12:52:34 一般都是双击播放 大熊好暖: 09-21 12:54:16 这个是双击加入播放列表 可以优化 或者可以把这个双击做一个选项,让人选择
- [ ] 播放页 样式模板选择 歌词背景律动很有沉浸感,但是黑胶唱片的专辑样式不是很喜欢,希望增加一个选项如图二一样只有圆形专辑封面
- [x] 点击搜索框的 源图标实现快速切换
- [ ] ai功能完善
- [ ] 支持歌词隐藏
- [x] 兼容多平台歌单导入
- [ ] 软件能不能记住上次打开的窗口大小,每次都要手动拉

30
docs/guide/updateLog.md Normal file
View File

@@ -0,0 +1,30 @@
# 澜音版本更新日志
## 日志
- ###### 2025-9-17 **(V1.3.2)**
1. 目录结构调整
2. **支持插件更新提示**
**洛雪** 插件请手动重装适配
3. **debug**
- SMTC 问题
- 歌曲缓存播放多次请求和多次缓存问题
- ###### 2025-9-17 **V1.3.1**
1. **设置功能页**
- 缓存路径支持自定义
- 下载路径支持自定义
2. **debug**
- 播放页面唱针可以拖动问题
- 播放按钮加载中 因为自动下一曲 导致动画变形问题
- **SMTC** 功能 系统显示**未知应用**问题
- 播放页歌词**字体粗细**偶现丢失问题

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

View File

@@ -0,0 +1,28 @@
# 音乐播放列表-机制分析
## 基础使用
1. 默认情况下,播放 **「搜索」「歌单」** 双击中的歌曲时,会自动将该歌曲添加到 **「列表」** 中的末尾后并不会播放,这与手动将歌曲添加到 **「列表」** 等价,亦或是通过点击歌曲前面小三角播放<img src="./assets/image-20250916132248046.png" alt="image-20250916132248046" style="float:right" />可以添加到歌曲开头也可进行该歌曲添加到 **「列表」** 中的开头并播放。歌曲会按照你选择的播放顺序进行播放。
2. 对于 **「列表」** 歌曲的顺序可通过展开长按 **1.5s** 后可以进行拖拽排序,歌曲排序实时保存到本地 **LocalStorage**<img src="./assets/image-20250916133531421.png" alt="image-20250916133531421" style="zoom: 20%;" />
## 歌曲列表的导出和分享
3. 可进入设置
![image-20250916134511291](assets/image-20250916134511291.png)
点击 **[播放列表]** =>
![image-20250916134615679](assets/image-20250916134615679.png)
即可操作你想要的功能
4. 播放列表还可以导出为歌单
![image-20250916134820742](assets/image-20250916134820742.png)
歌单将自动选取第一首 **有效封面**[^1] 为歌单
[^1]: url正确的歌曲封面

View File

@@ -0,0 +1,84 @@
// 测试插件通知功能的示例插件
// 这个文件可以用来测试 NoticeCenter 功能
const pluginInfo = {
name: "测试通知插件",
version: "1.0.0",
author: "CeruMusic Team",
description: "用于测试插件通知功能的示例插件",
type: "cr"
}
const sources = [
{
name: "test",
qualities: ["128k", "320k"]
}
]
// 模拟音乐URL获取函数
async function musicUrl(source, musicInfo, quality) {
console.log('测试插件获取音乐URL')
// 测试不同类型的通知
setTimeout(() => {
// 测试信息通知
this.cerumusic.NoticeCenter('info', {
title: '信息通知',
message: '这是一个信息通知测试',
content: '插件正在正常工作'
})
}, 1000)
setTimeout(() => {
// 测试警告通知
this.cerumusic.NoticeCenter('warning', {
title: '警告通知',
message: '这是一个警告通知测试',
content: '请注意某些设置'
})
}, 2000)
setTimeout(() => {
// 测试成功通知
this.cerumusic.NoticeCenter('success', {
title: '成功通知',
message: '操作已成功完成',
content: '音乐URL获取成功'
})
}, 3000)
setTimeout(() => {
// 测试更新通知
this.cerumusic.NoticeCenter('update', {
title: '插件更新',
message: '发现新版本 v2.0.0,是否立即更新?',
url: 'https://example.com/plugin-update.js',
version: '2.0.0',
pluginInfo: {
name: '测试通知插件',
type: 'cr',
forcedUpdate: false
}
})
}, 4000)
setTimeout(() => {
// 测试错误通知
this.cerumusic.NoticeCenter('error', {
title: '错误通知',
message: '这是一个错误通知测试',
error: '模拟的错误信息'
})
}, 5000)
// 返回一个测试URL
return 'https://example.com/test-music.mp3'
}
// 导出插件
module.exports = {
pluginInfo,
sources,
musicUrl
}

215
docs/plugin-notice-usage.md Normal file
View File

@@ -0,0 +1,215 @@
# 插件通知系统使用说明
## 概述
CeruMusic 插件通知系统允许插件向用户显示各种类型的通知对话框,包括信息、警告、错误、成功和更新通知。
## 功能特性
### 🎯 支持的通知类型
1. **信息通知 (info)** - 显示一般信息
2. **警告通知 (warning)** - 显示警告信息
3. **错误通知 (error)** - 显示错误信息
4. **成功通知 (success)** - 显示成功信息
5. **更新通知 (update)** - 显示插件更新信息,支持一键更新
### 🎨 界面特性
- 使用 TDesign 组件库,界面美观统一
- 支持深色主题适配
- 响应式设计,移动端友好
- 不同通知类型有对应的图标和颜色
### ⚡ 技术特性
- 基于 Electron IPC 通信
- TypeScript 类型安全
- 异步操作支持
- 错误处理完善
## 使用方法
### 在插件中调用通知
```javascript
// 基本用法
this.cerumusic.NoticeCenter(type, data)
// 信息通知
this.cerumusic.NoticeCenter('info', {
title: '插件信息',
message: '这是一条信息通知',
content: '详细的信息内容'
})
// 警告通知
this.cerumusic.NoticeCenter('warning', {
title: '注意',
message: '这是一条警告信息',
content: '请检查相关设置'
})
// 错误通知
this.cerumusic.NoticeCenter('error', {
title: '错误',
message: '操作失败',
error: '具体的错误信息'
})
// 成功通知
this.cerumusic.NoticeCenter('success', {
title: '成功',
message: '操作已成功完成'
})
// 更新通知(特殊)
this.cerumusic.NoticeCenter('update', {
title: '插件更新',
message: '发现新版本,是否立即更新?',
url: 'https://example.com/plugin-update.js',
version: '2.0.0',
pluginInfo: {
name: '插件名称',
type: 'cr', // 'cr' 或 'lx'
forcedUpdate: false
}
})
```
### 参数说明
#### 通用参数 (data 对象)
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| title | string | 否 | 通知标题,不提供时使用默认标题 |
| message | string | 否 | 通知消息内容 |
| content | string | 否 | 详细内容(与 message 二选一) |
#### 更新通知特有参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| url | string | 是 | 插件更新下载链接 |
| version | string | 否 | 新版本号 |
| pluginInfo.name | string | 否 | 插件名称 |
| pluginInfo.type | 'cr' \| 'lx' | 否 | 插件类型 |
| pluginInfo.forcedUpdate | boolean | 否 | 是否强制更新 |
#### 错误通知特有参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| error | string | 否 | 具体错误信息 |
## 实现原理
### 架构图
```
插件代码
↓ (调用 NoticeCenter)
CeruMusicPluginHost
↓ (sendPluginNotice)
pluginNotice.ts (主进程)
↓ (IPC 通信)
PluginNoticeDialog.vue (渲染进程)
↓ (显示对话框)
用户界面
```
### 文件结构
```
src/
├── main/
│ ├── events/
│ │ └── pluginNotice.ts # 主进程通知处理
│ └── services/plugin/manager/
│ └── CeruMusicPluginHost.ts # 插件主机
├── renderer/src/
│ ├── components/
│ │ └── PluginNoticeDialog.vue # 通知对话框组件
│ └── App.vue # 主应用(注册组件)
└── preload/
└── index.ts # IPC API 定义
```
## 测试
### 使用测试插件
1.`docs/plugin-notice-test.js` 作为插件加载
2. 调用插件的 `musicUrl` 方法
3. 观察不同类型的通知是否正确显示
### 测试场景
- [x] 信息通知显示
- [x] 警告通知显示
- [x] 错误通知显示
- [x] 成功通知显示
- [x] 更新通知显示(带更新按钮)
- [x] 更新按钮功能
- [x] 对话框关闭功能
- [x] 响应式布局
- [x] 深色主题适配
## 注意事项
1. **URL 验证**: 更新通知的 URL 必须是有效的 HTTP/HTTPS 链接
2. **错误处理**: 所有通知操作都有完善的错误处理机制
3. **性能考虑**: 避免频繁发送通知,可能影响用户体验
4. **类型安全**: 使用 TypeScript 确保参数类型正确
## 扩展功能
### 未来可能的增强
- [ ] 通知历史记录
- [ ] 通知优先级系统
- [ ] 批量通知管理
- [ ] 自定义通知样式
- [ ] 通知声音提醒
- [ ] 通知位置自定义
## 故障排除
### 常见问题
1. **通知不显示**
- 检查主窗口是否存在
- 确认 IPC 通信是否正常
- 查看控制台错误信息
2. **更新按钮无响应**
- 确认更新 URL 是否有效
- 检查网络连接
- 查看主进程日志
3. **样式显示异常**
- 确认 TDesign 组件库已正确加载
- 检查 CSS 样式是否冲突
- 验证主题配置
### 调试方法
```javascript
// 在插件中添加调试日志
console.log('[Plugin] 发送通知:', type, data)
// 在渲染进程中监听通知
window.api.on('plugin-notice', (_, notice) => {
console.log('[Renderer] 收到通知:', notice)
})
```
## 更新日志
### v1.0.0 (2025-09-20)
- ✨ 初始版本发布
- ✨ 支持 5 种通知类型
- ✨ 完整的 TypeScript 类型定义
- ✨ 响应式设计和深色主题支持
- ✨ 完善的错误处理机制

444
docs/songlist-api.md Normal file
View 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。

View File

@@ -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

View File

@@ -36,14 +36,16 @@ export default defineConfig({
TDesignResolver({
library: 'vue-next'
})
]
],
dts: true
}),
Components({
resolvers: [
TDesignResolver({
library: 'vue-next'
})
]
],
dts: true
})
],
base: './',

235
eslint.config.js Normal file
View 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
]

View File

@@ -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']
}
}

5228
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "ceru-music",
"version": "1.1.10",
"version": "1.3.4",
"description": "一款简洁优雅的音乐播放器",
"main": "./out/main/index.js",
"author": "sqj,wldss,star",
@@ -8,7 +8,7 @@
"homepage": "https://ceru.docs.shiqianjiang.cn",
"scripts": {
"format": "prettier --write .",
"lint": "eslint --cache . --fix",
"lint": "eslint --cache . --fix && yarn typecheck",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "yarn run typecheck:node && yarn run typecheck:web",
@@ -18,7 +18,8 @@
"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",
@@ -57,6 +58,7 @@
"jss": "^10.10.0",
"jss-preset-default": "^10.10.0",
"lodash": "^4.17.21",
"markdown-it-footnote": "^4.0.0",
"marked": "^16.1.2",
"mitt": "^3.0.1",
"needle": "^3.3.1",
@@ -74,10 +76,11 @@
"@electron-toolkit/tsconfig": "^1.0.1",
"@tdesign-vue-next/auto-import-resolver": "^0.1.1",
"@types/crypto-js": "^4.2.2",
"@types/markdown-it-footnote": "^3.0.4",
"@types/node": "^22.16.5",
"@types/node-fetch": "^2.6.13",
"@vitejs/plugin-vue": "^6.0.0",
"electron": "^37.3.1",
"electron": "^38.1.0",
"electron-builder": "^25.1.8",
"electron-icon-builder": "^2.0.1",
"electron-vite": "^4.0.0",
@@ -94,7 +97,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": "^1.6.4",
"vue": "^3.5.21",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.3"
}

10491
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

76922
qodana.sarif.json Normal file

File diff suppressed because one or more lines are too long

3
qodana.yaml Normal file
View File

@@ -0,0 +1,3 @@
version: "1.0"
profile:
name: qodana.starter

BIN
resources/default-cover.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 KiB

66
scripts/auth-test.js Normal file
View 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)

55
scripts/genAst.js Normal file
View File

@@ -0,0 +1,55 @@
const fs = require('fs');
const path = require('path');
function generateTree(dir, prefix = '', isLast = true, excludeDirs = ['node_modules', 'dist', 'out', '.git','.kiro','.idea','.codebuddy','.vscode','.workflow','assets','resources','docs']) {
const basename = path.basename(dir);
// 跳过排除的目录和隐藏文件
if (basename.startsWith('.') && basename !== '.' && basename !== '..' && !['.github', '.workflow'].includes(basename)) {
return;
}
if (excludeDirs.includes(basename)) {
return;
}
// 当前项目显示
if (prefix === '') {
console.log(`${basename}/`);
} else {
const connector = isLast ? '└── ' : '├── ';
const displayName = fs.statSync(dir).isDirectory() ? `${basename}/` : basename;
console.log(prefix + connector + displayName);
}
if (!fs.statSync(dir).isDirectory()) {
return;
}
try {
const items = fs.readdirSync(dir)
.filter(item => !item.startsWith('.') || ['.github', '.workflow'].includes(item))
.filter(item => !excludeDirs.includes(item))
.sort((a, b) => {
// 目录排在前面,文件排在后面
const aIsDir = fs.statSync(path.join(dir, a)).isDirectory();
const bIsDir = fs.statSync(path.join(dir, b)).isDirectory();
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
return a.localeCompare(b);
});
const newPrefix = prefix + (isLast ? ' ' : '│ ');
items.forEach((item, index) => {
const isLastItem = index === items.length - 1;
generateTree(path.join(dir, item), newPrefix, isLastItem, excludeDirs);
});
} catch (error) {
console.error(`Error reading directory: ${dir}`, error.message);
}
}
// 使用示例
const targetDir = process.argv[2] || '.';
console.log('项目文件结构:');
generateTree(targetDir);

148
scripts/test-alist.js Normal file
View 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()

View File

@@ -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]

View 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>
}

View 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' // 来源
}

View File

@@ -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:

View File

@@ -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})`
})

View File

@@ -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

View File

@@ -1,150 +0,0 @@
// 业务工具方法
import { LX } from "../../types/global"
export const toNewMusicInfo = (oldMusicInfo: any): LX.Music.MusicInfo => {
const meta: Record<string, any> = {
songId: oldMusicInfo.songmid, // 歌曲IDlocal为文件路径
albumName: oldMusicInfo.albumName, // 歌曲专辑名称
picUrl: oldMusicInfo.img // 歌曲图片链接
}
const newInfo = {
id: `${oldMusicInfo.source}_${oldMusicInfo.songmid}`,
name: oldMusicInfo.name,
singer: oldMusicInfo.singer,
source: oldMusicInfo.source,
interval: oldMusicInfo.interval,
meta: meta as LX.Music.MusicInfoOnline['meta']
}
if (oldMusicInfo.source == 'local') {
meta.filePath = oldMusicInfo.filePath ?? oldMusicInfo.songmid ?? ''
meta.ext = oldMusicInfo.ext ?? /\.(\w+)$/.exec(meta.filePath)?.[1] ?? ''
} else {
meta.qualitys = oldMusicInfo.types
meta._qualitys = oldMusicInfo._types
meta.albumId = oldMusicInfo.albumId
if (meta._qualitys.flac32bit && !meta._qualitys.flac24bit) {
meta._qualitys.flac24bit = meta._qualitys.flac32bit
delete meta._qualitys.flac32bit
meta.qualitys = (meta.qualitys as any[]).map((quality) => {
if (quality.type == 'flac32bit') quality.type = 'flac24bit'
return quality
})
}
switch (oldMusicInfo.source) {
case 'kg':
meta.hash = oldMusicInfo.hash
newInfo.id = oldMusicInfo.songmid + '_' + oldMusicInfo.hash
break
case 'tx':
meta.strMediaMid = oldMusicInfo.strMediaMid
meta.id = oldMusicInfo.songId
meta.albumMid = oldMusicInfo.albumMid
break
case 'mg':
meta.copyrightId = oldMusicInfo.copyrightId
meta.lrcUrl = oldMusicInfo.lrcUrl
meta.mrcUrl = oldMusicInfo.mrcUrl
meta.trcUrl = oldMusicInfo.trcUrl
break
}
}
return newInfo
}
export const toOldMusicInfo = (minfo: LX.Music.MusicInfo) => {
const oInfo: Record<string, any> = {
name: minfo.name,
singer: minfo.singer,
source: minfo.source,
songmid: minfo.meta.songId,
interval: minfo.interval,
albumName: minfo.meta.albumName,
img: minfo.meta.picUrl ?? '',
typeUrl: {}
}
if (minfo.source == 'local') {
oInfo.filePath = minfo.meta.filePath
oInfo.ext = minfo.meta.ext
oInfo.albumId = ''
oInfo.types = []
oInfo._types = {}
} else {
oInfo.albumId = minfo.meta.albumId
oInfo.types = minfo.meta.qualitys
oInfo._types = minfo.meta._qualitys
switch (minfo.source) {
case 'kg':
oInfo.hash = minfo.meta.hash
break
case 'tx':
oInfo.strMediaMid = minfo.meta.strMediaMid
oInfo.albumMid = minfo.meta.albumMid
oInfo.songId = minfo.meta.id
break
case 'mg':
oInfo.copyrightId = minfo.meta.copyrightId
oInfo.lrcUrl = minfo.meta.lrcUrl
oInfo.mrcUrl = minfo.meta.mrcUrl
oInfo.trcUrl = minfo.meta.trcUrl
break
}
}
return oInfo
}
/**
* 修复2.0.0-dev.8之前的新列表数据音质
* @param musicInfo
*/
export const fixNewMusicInfoQuality = (musicInfo: LX.Music.MusicInfo) => {
if (musicInfo.source == 'local') return musicInfo
// @ts-expect-error
if (musicInfo.meta._qualitys.flac32bit && !musicInfo.meta._qualitys.flac24bit) {
// @ts-expect-error
musicInfo.meta._qualitys.flac24bit = musicInfo.meta._qualitys.flac32bit
// @ts-expect-error
delete musicInfo.meta._qualitys.flac32bit
musicInfo.meta.qualitys = musicInfo.meta.qualitys.map((quality) => {
// @ts-expect-error
if (quality.type == 'flac32bit') quality.type = 'flac24bit'
return quality
})
}
return musicInfo
}
export const filterMusicList = <T extends LX.Music.MusicInfo>(list: T[]): T[] => {
const ids = new Set<string>()
return list.filter((s) => {
if (!s.id || ids.has(s.id) || !s.name) return false
if (s.singer == null) s.singer = ''
ids.add(s.id)
return true
})
}
const MAX_NAME_LENGTH = 80
const MAX_FILE_NAME_LENGTH = 150
export const clipNameLength = (name: string) => {
if (name.length <= MAX_NAME_LENGTH || !name.includes('、')) return name
const names = name.split('、')
let newName = names.shift()!
for (const name of names) {
if (newName.length + name.length > MAX_NAME_LENGTH) break
newName = newName + '、' + name
}
return newName
}
export const clipFileNameLength = (name: string) => {
return name.length > MAX_FILE_NAME_LENGTH ? name.substring(0, MAX_FILE_NAME_LENGTH) : name
}

View File

@@ -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)))
}
}

View File

@@ -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)
}

View File

@@ -0,0 +1,206 @@
import { ipcMain, dialog, app } from 'electron'
import { join } from 'path'
import fs from 'fs'
import { promisify } from 'util'
const mkdir = promisify(fs.mkdir)
const access = promisify(fs.access)
export const CONFIG_NAME = 'sqj_config.json'
// 默认目录配置
const getDefaultDirectories = () => {
const userDataPath = app.getPath('userData')
return {
cacheDir: join(userDataPath, 'music-cache'),
downloadDir: join(app.getPath('music'), 'CeruMusic/songs')
}
}
// 确保目录存在
const ensureDirectoryExists = async (dirPath: string) => {
try {
await access(dirPath)
} catch {
await mkdir(dirPath, { recursive: true })
}
}
// 获取当前目录配置
ipcMain.handle('directory-settings:get-directories', async () => {
try {
const defaults = getDefaultDirectories()
// 从配置文件读取用户设置的目录
const configPath = join(app.getPath('userData'), CONFIG_NAME)
let userConfig: any = {}
try {
const configData = fs.readFileSync(configPath, 'utf-8')
userConfig = JSON.parse(configData)
} catch {
// 配置文件不存在或读取失败,使用默认配置
}
const directories = {
cacheDir: userConfig.cacheDir || defaults.cacheDir,
downloadDir: userConfig.downloadDir || defaults.downloadDir
}
// 确保目录存在
await ensureDirectoryExists(directories.cacheDir)
await ensureDirectoryExists(directories.downloadDir)
return directories
} catch (error) {
console.error('获取目录配置失败:', error)
const defaults = getDefaultDirectories()
return defaults
}
})
// 选择缓存目录
ipcMain.handle('directory-settings:select-cache-dir', async () => {
try {
const result = await dialog.showOpenDialog({
title: '选择缓存目录',
properties: ['openDirectory', 'createDirectory'],
buttonLabel: '选择目录'
})
if (!result.canceled && result.filePaths.length > 0) {
const selectedPath = result.filePaths[0]
await ensureDirectoryExists(selectedPath)
return { success: true, path: selectedPath }
}
return { success: false, message: '用户取消选择' }
} catch (error) {
console.error('选择缓存目录失败:', error)
return { success: false, message: '选择目录失败' }
}
})
// 选择下载目录
ipcMain.handle('directory-settings:select-download-dir', async () => {
try {
const result = await dialog.showOpenDialog({
title: '选择下载目录',
properties: ['openDirectory', 'createDirectory'],
buttonLabel: '选择目录'
})
if (!result.canceled && result.filePaths.length > 0) {
const selectedPath = result.filePaths[0]
await ensureDirectoryExists(selectedPath)
return { success: true, path: selectedPath }
}
return { success: false, message: '用户取消选择' }
} catch (error) {
console.error('选择下载目录失败:', error)
return { success: false, message: '选择目录失败' }
}
})
// 保存目录配置
ipcMain.handle('directory-settings:save-directories', async (_, directories) => {
try {
const configPath = join(app.getPath('userData'), CONFIG_NAME)
// 确保目录存在
await ensureDirectoryExists(directories.cacheDir)
await ensureDirectoryExists(directories.downloadDir)
// 保存配置
fs.writeFileSync(configPath, JSON.stringify(directories, null, 2))
return { success: true, message: '目录配置已保存' }
} catch (error) {
console.error('保存目录配置失败:', error)
return { success: false, message: '保存配置失败' }
}
})
// 重置为默认目录
ipcMain.handle('directory-settings:reset-directories', async () => {
try {
const defaults = getDefaultDirectories()
const configPath = join(app.getPath('userData'), CONFIG_NAME)
// 删除配置文件
try {
fs.unlinkSync(configPath)
} catch {
// 文件不存在,忽略错误
}
// 确保默认目录存在
await ensureDirectoryExists(defaults.cacheDir)
await ensureDirectoryExists(defaults.downloadDir)
return { success: true, directories: defaults }
} catch (error) {
console.error('重置目录配置失败:', error)
return { success: false, message: '重置配置失败' }
}
})
// 打开目录
ipcMain.handle('directory-settings:open-directory', async (_, dirPath) => {
try {
const { shell } = require('electron')
await shell.openPath(dirPath)
return { success: true }
} catch (error) {
console.error('打开目录失败:', error)
return { success: false, message: '打开目录失败' }
}
})
// 获取目录大小
ipcMain.handle('directory-settings:get-directory-size', async (_, dirPath) => {
try {
const getDirectorySize = (dirPath: string): number => {
let totalSize = 0
try {
const items = fs.readdirSync(dirPath)
for (const item of items) {
const itemPath = join(dirPath, item)
const stats = fs.statSync(itemPath)
if (stats.isDirectory()) {
totalSize += getDirectorySize(itemPath)
} else {
totalSize += stats.size
}
}
} catch {
// 忽略无法访问的文件/目录
}
return totalSize
}
const size = getDirectorySize(dirPath)
// 格式化大小
const formatSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
return {
size,
formatted: formatSize(size)
}
} catch (error) {
console.error('获取目录大小失败:', error)
return { size: 0, formatted: '0 B' }
}
})

View File

@@ -14,20 +14,23 @@ ipcMain.handle('music-cache:get-info', async () => {
// 清空缓存
ipcMain.handle('music-cache:clear', async () => {
try {
console.log('收到清空缓存请求')
await musicCacheService.clearCache()
console.log('缓存清空完成')
return { success: true, message: '缓存已清空' }
} catch (error) {
} catch (error: any) {
console.error('清空缓存失败:', error)
return { success: false, message: '清空缓存失败' }
return { success: false, message: `清空缓存失败: ${error.message}` }
}
})
// 获取缓存大小
ipcMain.handle('music-cache:get-size', async () => {
try {
return await musicCacheService.getCacheSize()
const info = await musicCacheService.getCacheInfo()
return info.size
} catch (error) {
console.error('获取缓存大小失败:', error)
return 0
}
})
})

View File

@@ -0,0 +1,161 @@
/*
* Copyright (c) 2025. 时迁酱 Inc. All rights reserved.
*
* This software is the confidential and proprietary information of 时迁酱.
* Unauthorized copying of this file, via any medium is strictly prohibited.
*
* @author 时迁酱无聊的霜霜Star
* @since 2025-9-20
* @version 1.0
*/
import { BrowserWindow } from 'electron'
export interface PluginNoticeData {
type: 'error' | 'info' | 'success' | 'warn' | 'update'
data: {
title?: string
content?: string
message?: string
url?: string
version?: string
pluginInfo?: {
name?: string
type?: 'lx' | 'cr'
forcedUpdate?: boolean
}
}
currentVersion?: string
timestamp?: number
pluginName?: string
}
export interface DialogNotice {
type: string
data: any
timestamp: number
pluginName: string
dialogType: 'update' | 'info' | 'error' | 'warning' | 'success'
title: string
message: string
updateUrl?: string
pluginType?: 'lx' | 'cr'
currentVersion?: string
newVersion?: string
actions: Array<{
text: string
type: 'cancel' | 'update' | 'confirm'
primary?: boolean
}>
}
/**
* 验证 URL 是否有效
*/
function isValidUrl(url: string): boolean {
try {
const urlObj = new URL(url)
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'
} catch {
return false
}
}
/**
* 根据通知类型获取标题
*/
function getNoticeTitle(type: string): string {
const titleMap: Record<string, string> = {
update: '插件更新',
error: '插件错误',
warning: '插件警告',
info: '插件信息',
success: '操作成功'
}
return titleMap[type] || '插件通知'
}
/**
* 根据通知类型获取默认消息
*/
function getDefaultMessage(type: string, data: any, pluginName: string): string {
switch (type) {
case 'error':
return `插件 "${pluginName}" 发生错误: ${data?.error || data?.message || '未知错误'}`
case 'warning':
return `插件 "${pluginName}" 警告: ${data?.warning || data?.message || '需要注意'}`
case 'success':
return `插件 "${pluginName}" 操作成功: ${data?.message || ''}`
case 'info':
default:
return data?.message || `插件 "${pluginName}" 信息: ${JSON.stringify(data)}`
}
}
/**
* 发送插件通知到渲染进程
*/
export function sendPluginNotice(noticeData: PluginNoticeData, pluginName?: string): void {
try {
console.log(`[CeruMusic] 插件通知: ${noticeData.type}`, noticeData.data)
// 获取主窗口实例
const mainWindow = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed())
if (!mainWindow) {
console.warn('[CeruMusic] 未找到主窗口,无法发送通知')
return
}
// 构建通知数据
const baseNoticeData = {
type: noticeData.type,
data: noticeData.data,
timestamp: noticeData.timestamp || Date.now(),
pluginName: pluginName || noticeData.pluginName || 'Unknown Plugin'
}
// 根据通知类型处理不同的逻辑
if (noticeData.type === 'update' && noticeData.data?.url && isValidUrl(noticeData.data.url)) {
// 更新通知 - 显示带更新按钮的对话框
const updateNotice: DialogNotice = {
...baseNoticeData,
dialogType: 'update',
title: noticeData.data.title || '插件更新',
message: noticeData.data.content || `插件 "${baseNoticeData.pluginName}" 有新版本可用`,
updateUrl: noticeData.data.url,
pluginType: noticeData.data.pluginInfo?.type,
currentVersion: noticeData.currentVersion || '未知', // 这个需要从插件实例获取
newVersion: noticeData.data.version,
actions: [
{ text: '稍后更新', type: 'cancel' },
{ text: '立即更新', type: 'update', primary: true }
]
}
mainWindow.webContents.send('plugin-notice', updateNotice)
} else {
// 普通通知 - 显示信息对话框
const infoNotice: DialogNotice = {
...baseNoticeData,
dialogType:
noticeData.type === 'error'
? 'error'
: noticeData.type === 'warn'
? 'warning'
: noticeData.type === 'success'
? 'success'
: 'info',
title: noticeData.data.title || getNoticeTitle(noticeData.type),
message:
noticeData.data.message ||
noticeData.data.content ||
getDefaultMessage(noticeData.type, noticeData.data, baseNoticeData.pluginName),
actions: [{ text: '我知道了', type: 'confirm', primary: true }]
}
mainWindow.webContents.send('plugin-notice', infoNotice)
}
} catch (error: any) {
console.error('[CeruMusic] 发送插件通知失败:', error.message)
}
}

361
src/main/events/songList.ts Normal file
View 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'
}
}
})

View File

@@ -7,6 +7,23 @@ import musicService from './services/music'
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'
@@ -76,7 +93,7 @@ function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1100,
height: 750,
minWidth: 970,
minWidth: 1100,
minHeight: 670,
show: false,
center: true,
@@ -95,7 +112,6 @@ function createWindow(): void {
contextIsolation: false,
backgroundThrottling: false
}
})
if (process.platform == 'darwin') mainWindow.setWindowButtonVisibility(false)
@@ -200,15 +216,24 @@ ipcMain.handle('get-app-version', () => {
aiEvents(mainWindow)
import './events/musicCache'
import './events/songList'
import './events/directorySettings'
import './events/pluginNotice'
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(() => {
// Set app user model id for windows
// Set app user model id for windows - 确保与 electron-builder.yml 中的 appId 一致
electronApp.setAppUserModelId('com.cerumusic.app')
electronApp.setAppUserModelId('com.cerulean.music')
// 在 Windows 上设置应用程序名称,帮助 SMTC 识别
if (process.platform === 'win32') {
app.setAppUserModelId('com.cerumusic.app')
// 设置应用程序名称
app.setName('澜音')
}
setTimeout(async () => {
// 初始化插件系统
@@ -218,7 +243,7 @@ app.whenReady().then(() => {
} catch (error) {
console.error('插件系统初始化失败:', error)
}
},1000)
}, 1000)
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.

View File

@@ -3,24 +3,48 @@ import * as path from 'path'
import * as fs from 'fs/promises'
import * as crypto from 'crypto'
import axios from 'axios'
import { CONFIG_NAME } from '../../events/directorySettings'
export class MusicCacheService {
private cacheDir: string
private cacheIndex: Map<string, string> = new Map()
private indexFilePath: string
constructor() {
this.cacheDir = path.join(app.getPath('userData'), 'music-cache')
this.indexFilePath = path.join(this.cacheDir, 'cache-index.json')
this.initCache()
}
private getCacheDirectory(): string {
try {
// 尝试从配置文件读取自定义缓存目录
const configPath = path.join(app.getPath('userData'), CONFIG_NAME)
const configData = require('fs').readFileSync(configPath, 'utf-8')
const config = JSON.parse(configData)
if (config.cacheDir && typeof config.cacheDir === 'string') {
return config.cacheDir
}
} catch {
// 配置文件不存在或读取失败,使用默认目录
}
// 默认缓存目录
return path.join(app.getPath('userData'), 'music-cache')
}
// 动态获取缓存目录
public get cacheDir(): string {
return this.getCacheDirectory()
}
// 动态获取索引文件路径
public get indexFilePath(): string {
return path.join(this.cacheDir, 'cache-index.json')
}
private async initCache() {
try {
// 确保缓存目录存在
await fs.mkdir(this.cacheDir, { recursive: true })
// 加载缓存索引
await this.loadCacheIndex()
} catch (error) {
@@ -58,14 +82,14 @@ export class MusicCacheService {
return path.join(this.cacheDir, `${cacheKey}${ext}`)
}
async getCachedMusicUrl(songId: string, originalUrlPromise: Promise<string>): Promise<string> {
async getCachedMusicUrl(songId: string): Promise<string | null> {
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)
@@ -73,20 +97,35 @@ export class MusicCacheService {
return `file://${cachedFilePath}`
} catch (error) {
// 文件不存在,从缓存索引中移除
console.warn(`缓存文件不存在,移除索引: ${cachedFilePath}`)
this.cacheIndex.delete(cacheKey)
await this.saveCacheIndex()
}
}
// 下载并缓存文件 先返回源链接不等待结果优化体验
this.downloadAndCache(songId, await originalUrlPromise, cacheKey)
return await originalUrlPromise
return null
}
async cacheMusic(songId: string, url: string): Promise<void> {
const cacheKey = this.generateCacheKey(songId)
// 如果已经缓存,跳过
if (this.cacheIndex.has(cacheKey)) {
return
}
try {
await this.downloadAndCache(songId, url, cacheKey)
} catch (error) {
console.error(`缓存歌曲失败: ${songId}`, error)
throw error
}
}
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 +147,7 @@ export class MusicCacheService {
// 更新缓存索引
this.cacheIndex.set(cacheKey, cacheFilePath)
await this.saveCacheIndex()
console.log(`歌曲缓存完成: ${cacheFilePath}`)
resolve(`file://${cacheFilePath}`)
} catch (error) {
@@ -131,44 +170,118 @@ export class MusicCacheService {
async clearCache(): Promise<void> {
try {
// 删除所有缓存文件
console.log('开始清空缓存目录:', this.cacheDir)
// 先重新加载缓存索引,确保获取最新的文件列表
await this.loadCacheIndex()
// 删除索引中记录的所有缓存文件
let deletedFromIndex = 0
for (const filePath of this.cacheIndex.values()) {
try {
await fs.unlink(filePath)
} catch (error) {
// 忽略文件不存在的错误
deletedFromIndex++
console.log('删除缓存文件:', filePath)
} catch (error: any) {
console.warn('删除文件失败:', filePath, error.message)
}
}
// 删除缓存目录中的所有其他文件(包括可能遗漏的文件)
let deletedFromDir = 0
try {
const files = await fs.readdir(this.cacheDir)
for (const file of files) {
const filePath = path.join(this.cacheDir, file)
try {
const stats = await fs.stat(filePath)
if (stats.isFile() && file !== 'cache-index.json') {
await fs.unlink(filePath)
deletedFromDir++
console.log('删除目录文件:', filePath)
}
} catch (error: any) {
console.warn('删除目录文件失败:', filePath, error.message)
}
}
} catch (error: any) {
console.warn('读取缓存目录失败:', error.message)
}
// 清空缓存索引
this.cacheIndex.clear()
await this.saveCacheIndex()
console.log('音乐缓存已清空')
console.log(
`音乐缓存已清空 - 从索引删除: ${deletedFromIndex}个文件, 从目录删除: ${deletedFromDir}个文件`
)
} catch (error) {
console.error('清空缓存失败:', error)
throw error
}
}
async getCacheSize(): Promise<number> {
getDirectorySize = async (dirPath: string): Promise<number> => {
let totalSize = 0
for (const filePath of this.cacheIndex.values()) {
try {
const stats = await fs.stat(filePath)
totalSize += stats.size
} catch (error) {
// 文件不存在,忽略
try {
const items = await fs.readdir(dirPath)
for (const item of items) {
const itemPath = path.join(dirPath, item)
const stats = await fs.stat(itemPath)
if (stats.isDirectory()) {
totalSize += await this.getDirectorySize(itemPath)
} else {
totalSize += stats.size
}
}
} catch {
// 忽略无法访问的文件/目录
}
return totalSize
}
async getCacheInfo(): Promise<{ count: number; size: number; sizeFormatted: string }> {
const size = await this.getCacheSize()
const count = this.cacheIndex.size
// 重新加载缓存索引以确保数据准确
await this.loadCacheIndex()
// 统计实际的缓存文件数量和大小
let actualCount = 0
let totalSize = 0
try {
const items = await fs.readdir(this.cacheDir)
for (const item of items) {
const itemPath = path.join(this.cacheDir, item)
try {
const stats = await fs.stat(itemPath)
if (stats.isFile() && item !== 'cache-index.json') {
// 检查是否是音频文件
const ext = path.extname(item).toLowerCase()
const audioExts = ['.mp3', '.flac', '.wav', '.aac', '.ogg', '.m4a', '.wma']
if (audioExts.includes(ext)) {
actualCount++
totalSize += stats.size
}
}
} catch (error: any) {
// 忽略无法访问的文件
console.warn('无法访问文件:', itemPath, error.message)
}
}
} catch (error: any) {
console.warn('读取缓存目录失败:', error.message)
// 如果无法读取目录,使用索引数据作为备选
totalSize = await this.getDirectorySize(this.cacheDir)
actualCount = this.cacheIndex.size
}
const formatSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
@@ -177,13 +290,15 @@ export class MusicCacheService {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
console.log(`缓存信息 - 文件数量: ${actualCount}, 总大小: ${totalSize} bytes`)
return {
count,
size,
sizeFormatted: formatSize(size)
count: actualCount,
size: totalSize,
sizeFormatted: formatSize(totalSize)
}
}
}
// 单例实例
export const musicCacheService = new MusicCacheService()
export const musicCacheService = new MusicCacheService()

View File

@@ -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)
}
}

View File

@@ -11,7 +11,6 @@ import {
} from './type'
import pluginService from '../plugin/index'
import musicSdk from '../../utils/musicSdk/index'
import { getAppDirPath } from '../../utils/path'
import { musicCacheService } from '../musicCache'
import path from 'node:path'
import fs from 'fs'
@@ -19,10 +18,11 @@ import fsPromise from 'fs/promises'
import axios from 'axios'
import { pipeline } from 'node:stream/promises'
import { fileURLToPath } from 'url'
import { app } from 'electron'
import { CONFIG_NAME } from '../../events/directorySettings'
const fileLock: Record<string, boolean> = {}
function main(source: string) {
const Api = musicSdk[source]
return {
@@ -35,22 +35,23 @@ function main(source: string) {
const usePlugin = pluginService.getPluginById(pluginId)
if (!pluginId || !usePlugin) return { error: '请配置音源来播放歌曲' }
// 获取原始URL
const originalUrlPromise = usePlugin.getMusicUrl(source, songInfo, quality)
// 生成歌曲唯一标识
const songId = `${songInfo.name}-${songInfo.singer}-${source}-${quality}`
// 尝试获取缓存的URL
try {
const cachedUrl = await musicCacheService.getCachedMusicUrl(songId, originalUrlPromise)
// 先检查缓存
const cachedUrl = await musicCacheService.getCachedMusicUrl(songId)
if (cachedUrl) {
return cachedUrl
} catch (cacheError) {
console.warn('缓存获取失败使用原始URL:', cacheError)
const originalUrl = await originalUrlPromise
return originalUrl
}
// 没有缓存时才发起网络请求
const originalUrl = await usePlugin.getMusicUrl(source, songInfo, quality)
// 异步缓存,不阻塞返回
musicCacheService.cacheMusic(songId, originalUrl).catch((error) => {
console.warn('缓存歌曲失败:', error)
})
return originalUrl
} catch (e: any) {
return {
error: '获取歌曲失败 ' + e.error || e
@@ -84,6 +85,10 @@ function main(source: string) {
},
async getPlaylistDetail({ id, page }: GetSongListDetailsArg) {
// 酷狗音乐特殊处理直接调用getUserListDetail
if (source === 'kg' && /https?:\/\//.test(id)) {
return (await Api.songList.getUserListDetail(id, page)) as PlaylistDetailResult
}
return (await Api.songList.getListDetail(id, page)) as PlaylistDetailResult
},
@@ -91,6 +96,24 @@ function main(source: string) {
const url = await this.getMusicUrl({ pluginId, songInfo, quality })
if (typeof url === 'object') throw new Error('无法获取歌曲链接')
// 获取自定义下载目录
const getDownloadDirectory = (): string => {
try {
const configPath = path.join(app.getPath('userData'), CONFIG_NAME)
const configData = fs.readFileSync(configPath, 'utf-8')
const config = JSON.parse(configData)
if (config.downloadDir && typeof config.downloadDir === 'string') {
return config.downloadDir
}
} catch {
// 配置文件不存在或读取失败,使用默认目录
}
// 默认下载目录
return path.join(app.getPath('music'), 'CeruMusic/songs')
}
// 从URL中提取文件扩展名如果没有则默认为mp3
const getFileExtension = (url: string): string => {
try {
@@ -112,11 +135,10 @@ function main(source: string) {
}
const fileExtension = getFileExtension(url)
const downloadDir = getDownloadDirectory()
const songPath = path.join(
getAppDirPath('music'),
'CeruMusic',
'songs',
`${songInfo.name}-${songInfo.singer}-${source}.${fileExtension}`
downloadDir,
`${songInfo.name}-${songInfo.singer}-${songInfo.source}.${fileExtension}`
.replace(/[/\\:*?"<>|]/g, '')
.replace(/^\.+/, '')
.replace(/\.+$/, '')
@@ -161,6 +183,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)
}
}
}
}
}

View File

@@ -92,4 +92,4 @@ export interface PlaylistDetailResult {
export interface DownloadSingleSongArgs extends GetMusicUrlArg {
path?: string
}
}

View File

@@ -1,9 +1,30 @@
/*
* Copyright (c) 2025. 时迁酱 Inc. All rights reserved.
*
* This software is the confidential and proprietary information of 时迁酱.
* Unauthorized copying of this file, via any medium is strictly prohibited.
*
* @author 时迁酱无聊的霜霜Star
* @since 2025-9-19
* @version 1.0
*/
import * as vm from 'vm'
import fetch from 'node-fetch'
import * as fs from 'fs'
import { MusicItem } from '../../musicSdk/type'
import { sendPluginNotice } from '../../../events/pluginNotice'
// 定义插件结构接口
// ==================== 常量定义 ====================
const CONSTANTS = {
DEFAULT_TIMEOUT: 10000, // 10秒超时
API_VERSION: '1.0.3',
ENVIRONMENT: 'nodejs',
NOTICE_DELAY: 100, // 通知延迟时间
LOG_PREFIX: '[CeruMusic]'
} as const
// ==================== 类型定义 ====================
export interface PluginInfo {
name: string
version: string
@@ -33,7 +54,7 @@ interface MusicInfo extends MusicItem {
interface RequestResult {
body: any
statusCode: number
headers: Record<string, string[]>
headers: Record<string, string>
}
interface CeruMusicApiUtils {
@@ -52,12 +73,26 @@ interface CeruMusicApi {
options?: RequestOptions | RequestCallback,
callback?: RequestCallback
) => Promise<RequestResult> | void
NoticeCenter: (
type: 'error' | 'info' | 'success' | 'warn' | 'update',
data: {
title: string
content?: string
url?: string
version?: string
pluginInfo: {
name?: string // 插件名
type: 'lx' | 'cr' //插件类型
}
}
) => void
}
type RequestOptions = {
method?: string
headers?: Record<string, string>
body?: any
timeout?: number
[key: string]: any
}
@@ -66,8 +101,21 @@ type RequestCallback = (error: Error | null, result: RequestResult | null) => vo
type Logger = {
log: (...args: any[]) => void
error: (...args: any[]) => void
warn?: (...args: any[]) => void
info?: (...args: any[]) => void
warn: (...args: any[]) => void
info: (...args: any[]) => void
}
type PluginMethodName = 'musicUrl' | 'getPic' | 'getLyric'
// ==================== 错误类定义 ====================
class PluginError extends Error {
constructor(
message: string,
public readonly method?: string
) {
super(message)
this.name = 'PluginError'
}
}
/**
@@ -85,160 +133,27 @@ class CeruMusicPluginHost {
*/
constructor(pluginCode: string | null = null, logger: Logger = console) {
this.pluginCode = pluginCode
this.plugin = null // 存储插件导出的对象
this.plugin = null
if (pluginCode) {
this._initialize(logger)
}
}
// ==================== 公共方法 ====================
/**
* 从文件加载插件
* @param pluginPath 插件文件路径
* @param logger 日志记录器
*/
async loadPlugin(pluginPath: string, logger: Logger = console): Promise<CeruMusicPlugin> {
this.pluginCode = fs.readFileSync(pluginPath, 'utf-8')
this._initialize(logger)
return this.plugin as CeruMusicPlugin
}
/**
* 初始化沙箱环境,加载并验证插件
* @private
*/
_initialize(console: Logger): void {
// 提供给插件的API
const cerumusicApi: CeruMusicApi = {
env: 'nodejs',
version: '1.0.0',
utils: {
buffer: {
from: (data: string | Buffer | ArrayBuffer, encoding?: BufferEncoding) => {
if (typeof data === 'string') {
return Buffer.from(data, encoding)
} else if (data instanceof Buffer) {
return data
} else if (data instanceof ArrayBuffer) {
return Buffer.from(new Uint8Array(data))
} else {
return Buffer.from(data as any)
}
},
bufToString: (buffer: Buffer, encoding?: BufferEncoding) => buffer.toString(encoding)
}
},
request: (url, options, callback) => {
// 支持 Promise 和 callback 两种调用方式
if (typeof options === 'function') {
callback = options as RequestCallback
options = { method: 'GET' }
}
const makeRequest = async (): Promise<RequestResult> => {
try {
console.log(`[CeruMusic] 发起请求: ${url}`)
// 添加超时设置
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000) // 10秒超时
const requestOptions = {
method: 'GET',
...(options as RequestOptions),
signal: controller.signal
}
const response = await fetch(url, requestOptions)
clearTimeout(timeoutId)
console.log(`[CeruMusic] 请求响应状态: ${response.status}`)
// 尝试解析JSON如果失败则返回文本
let body: any
try {
body = await response.json()
} catch (parseError: any) {
console.error(`[CeruMusic] 解析响应失败: ${parseError.message}`)
// 解析失败时创建错误body
body = {
code: response.status,
msg: `Failed to parse response: ${parseError.message}`
}
}
console.log(`[CeruMusic] 请求响应内容:`, body)
const result: RequestResult = {
body,
statusCode: response.status,
headers: response.headers.raw()
}
if (callback) {
callback(null, result)
}
return result
} catch (error: any) {
console.error(`[CeruMusic] Request failed: ${error.message}`)
if (callback) {
// 网络错误时,调用 callback(error, null)
callback(error, null)
// 需要返回一个值以满足 Promise<RequestResult> 类型
return {
body: { error: error.message },
statusCode: 500,
headers: {}
}
} else {
throw error
}
}
}
if (callback) {
makeRequest().catch((error) => {
console.error(`[CeruMusic] Unhandled request error in callback mode: ${error.message}`)
}) // 确保错误被正确处理
return undefined
} else {
return makeRequest()
}
}
}
const sandbox = {
module: { exports: {} },
cerumusic: cerumusicApi,
console: console,
setTimeout: setTimeout,
clearTimeout: clearTimeout,
setInterval: setInterval,
clearInterval: clearInterval,
Buffer: Buffer,
JSON: JSON,
require: () => ({}),
global: {},
process: { env: {} }
}
try {
// 在沙箱中执行插件代码
if (this.pluginCode) {
vm.runInNewContext(this.pluginCode, sandbox)
this.plugin = sandbox.module.exports as CeruMusicPlugin
console.log(`[CeruMusic] Plugin "${this.plugin.pluginInfo.name}" loaded successfully.`)
} else {
throw new Error('No plugin code provided.')
}
} catch (e: any) {
console.error('[CeruMusic] Error executing plugin code:', e)
throw new Error('Failed to initialize plugin.')
}
// 验证插件结构
if (!this.plugin?.pluginInfo || !this.plugin.sources || !this.plugin.musicUrl) {
throw new Error('Invalid plugin structure. Required fields: pluginInfo, sources, musicUrl.')
this.pluginCode = fs.readFileSync(pluginPath, 'utf-8')
this._initialize(logger)
return this.plugin as CeruMusicPlugin
} catch (error: any) {
throw new PluginError(`无法加载插件 ${pluginPath}: ${error.message}`)
}
}
@@ -246,10 +161,8 @@ class CeruMusicPluginHost {
* 获取插件信息
*/
getPluginInfo(): PluginInfo {
if (!this.plugin) {
throw new Error('Plugin not initialized')
}
return this.plugin.pluginInfo
this._ensurePluginInitialized()
return this.plugin!.pluginInfo
}
/**
@@ -263,10 +176,8 @@ class CeruMusicPluginHost {
* 获取支持的音源和音质信息
*/
getSupportedSources(): PluginSource[] {
if (!this.plugin) {
throw new Error('Plugin not initialized')
}
return this.plugin.sources
this._ensurePluginInitialized()
return this.plugin!.sources
}
/**
@@ -276,148 +187,7 @@ class CeruMusicPluginHost {
* @param quality 音质
*/
async getMusicUrl(source: string, musicInfo: MusicInfo, quality: string): Promise<string> {
try {
if (!this.plugin || typeof this.plugin.musicUrl !== 'function') {
throw new Error(`Action "musicUrl" is not implemented in plugin.`)
}
console.log(`[CeruMusic] 开始调用插件的 musicUrl 方法...`)
// 将 cerumusic API 绑定到函数调用的 this 上下文
const result = await this.plugin.musicUrl.call(
{ cerumusic: this._getCerumusicApi() },
source,
musicInfo,
quality
)
console.log(`[CeruMusic] 插件 musicUrl 方法调用成功`)
return result
} catch (error: any) {
console.error(`[CeruMusic] getMusicUrl 方法执行失败:`, error.message)
console.error(`[CeruMusic] 错误堆栈:`, error.stack)
// 重新抛出错误,确保外部可以捕获
throw new Error(`Plugin getMusicUrl failed: ${error.message}`)
}
}
/**
* 获取 cerumusic API 对象
* @private
*/
_getCerumusicApi(): CeruMusicApi {
return {
env: 'nodejs',
version: '1.0.0',
utils: {
buffer: {
from: (data: string | Buffer | ArrayBuffer, encoding?: BufferEncoding) => {
if (typeof data === 'string') {
return Buffer.from(data, encoding)
} else if (data instanceof Buffer) {
return data
} else if (data instanceof ArrayBuffer) {
return Buffer.from(new Uint8Array(data))
} else {
return Buffer.from(data as any)
}
},
bufToString: (buffer: Buffer, encoding?: BufferEncoding) => buffer.toString(encoding)
}
},
request: (url, options, callback) => {
// 支持 Promise 和 callback 两种调用方式
if (typeof options === 'function') {
callback = options as RequestCallback
options = { method: 'GET' }
}
const makeRequest = async (): Promise<RequestResult> => {
try {
console.log(`[CeruMusic] 发起请求: ${url}`)
// 添加超时设置
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000) // 10秒超时
const requestOptions = {
method: 'GET',
...(options as RequestOptions),
signal: controller.signal
}
const response = await fetch(url, requestOptions)
clearTimeout(timeoutId)
console.log(`[CeruMusic] 请求响应状态: ${response.status}`)
// 尝试解析JSON如果失败则返回文本
let body: any
const contentType = response.headers.get('content-type')
try {
if (contentType && contentType.includes('application/json')) {
body = await response.json()
} else {
const text = await response.text()
console.log(`[CeruMusic] 响应不是JSON格式内容: ${text.substring(0, 200)}...`)
// 对于非JSON响应创建一个错误状态的body
body = {
code: response.status,
msg: `Expected JSON response but got: ${contentType || 'unknown content type'}`,
data: text
}
}
} catch (parseError: any) {
console.error(`[CeruMusic] 解析响应失败: ${parseError.message}`)
// 解析失败时创建错误body
body = {
code: response.status,
msg: `Failed to parse response: ${parseError.message}`
}
}
console.log(`[CeruMusic] 请求响应内容:`, body)
const result: RequestResult = {
body,
statusCode: response.status,
headers: response.headers.raw()
}
if (callback) {
callback(null, result)
}
return result
} catch (error: any) {
console.error(`[CeruMusic] Request failed: ${error.message}`)
if (callback) {
// 网络错误时,调用 callback(error, null)
callback(error, null)
// 需要返回一个值以满足 Promise<RequestResult> 类型
return {
body: { error: error.message },
statusCode: 500,
headers: {}
}
} else {
throw error
}
}
}
if (callback) {
makeRequest().catch((error) => {
console.error(`[CeruMusic] Unhandled request error in callback mode: ${error.message}`)
}) // 确保错误被正确处理
return undefined
} else {
return makeRequest()
}
}
}
return this._callPluginMethod('musicUrl', source, musicInfo, quality)
}
/**
@@ -426,25 +196,7 @@ class CeruMusicPluginHost {
* @param musicInfo 音乐信息
*/
async getPic(source: string, musicInfo: MusicInfo): Promise<string> {
try {
if (!this.plugin || typeof this.plugin.getPic !== 'function') {
throw new Error(`Action "getPic" is not implemented in plugin.`)
}
console.log(`[CeruMusic] 开始调用插件的 getPic 方法...`)
const result = await this.plugin.getPic.call(
{ cerumusic: this._getCerumusicApi() },
source,
musicInfo
)
console.log(`[CeruMusic] 插件 getPic 方法调用成功`)
return result
} catch (error: any) {
console.error(`[CeruMusic] getPic 方法执行失败:`, error.message)
throw new Error(`Plugin getPic failed: ${error.message}`)
}
return this._callPluginMethod('getPic', source, musicInfo)
}
/**
@@ -453,24 +205,364 @@ class CeruMusicPluginHost {
* @param musicInfo 音乐信息
*/
async getLyric(source: string, musicInfo: MusicInfo): Promise<string> {
return this._callPluginMethod('getLyric', source, musicInfo)
}
// ==================== 私有方法 ====================
/**
* 初始化沙箱环境,加载并验证插件
* @private
*/
private _initialize(logger: Logger): void {
if (!this.pluginCode) {
throw new PluginError('No plugin code provided.')
}
const sandbox = this._createSandbox(logger)
try {
if (!this.plugin || typeof this.plugin.getLyric !== 'function') {
throw new Error(`Action "getLyric" is not implemented in plugin.`)
}
vm.runInNewContext(this.pluginCode, sandbox)
this.plugin = sandbox.module.exports as CeruMusicPlugin
console.log(`[CeruMusic] 开始调用插件的 getLyric 方法...`)
this._validatePlugin()
const result = await this.plugin.getLyric.call(
{ cerumusic: this._getCerumusicApi() },
source,
musicInfo
logger.log(
`${CONSTANTS.LOG_PREFIX} Plugin "${this.plugin.pluginInfo.name}" loaded successfully.`
)
} catch (error: any) {
logger.error(`${CONSTANTS.LOG_PREFIX} Error executing plugin code:`, error)
throw new PluginError('Failed to initialize plugin.')
}
}
console.log(`[CeruMusic] 插件 getLyric 方法调用成功`)
/**
* 创建沙箱环境
* @private
*/
private _createSandbox(logger: Logger): any {
return {
module: { exports: {} },
cerumusic: this._getCerumusicApi(),
console: logger,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
Buffer,
JSON,
require: () => ({}),
global: {},
process: { env: {} }
}
}
/**
* 验证插件结构
* @private
*/
private _validatePlugin(): void {
if (!this.plugin?.pluginInfo || !this.plugin.sources || !this.plugin.musicUrl) {
throw new PluginError(
'Invalid plugin structure. Required fields: pluginInfo, sources, musicUrl.'
)
}
}
/**
* 确保插件已初始化
* @private
*/
private _ensurePluginInitialized(): void {
if (!this.plugin) {
throw new PluginError('Plugin not initialized')
}
}
/**
* 统一的插件方法调用逻辑
* @private
*/
private async _callPluginMethod(
methodName: PluginMethodName,
...args: readonly any[]
): Promise<string> {
this._ensurePluginInitialized()
const method = this.plugin![methodName] as any
if (typeof method !== 'function') {
throw new PluginError(`Action "${methodName}" is not implemented in plugin.`, methodName)
}
try {
console.log(`${CONSTANTS.LOG_PREFIX} 开始调用插件的 ${methodName} 方法...`)
const result = await method.call(...[{ cerumusic: this._getCerumusicApi() }], ...args)
console.log(`${CONSTANTS.LOG_PREFIX} 插件 ${methodName} 方法调用成功`)
return result
} catch (error: any) {
console.error(`[CeruMusic] getLyric 方法执行失败:`, error.message)
throw new Error(`Plugin getLyric failed: ${error.message}`)
console.error(`${CONSTANTS.LOG_PREFIX} ${methodName} 方法执行失败:`, error.message)
if (methodName === 'musicUrl') {
console.error(`${CONSTANTS.LOG_PREFIX} 错误堆栈:`, error.stack)
}
throw new PluginError(`Plugin ${methodName} failed: ${error.message}`, methodName)
}
}
// ==================== 工具方法 ====================
// /**
// * 验证 URL 是否有效
// * @private
// */
// private _isValidUrl(url: string): boolean {
// try {
// const urlObj = new URL(url)
// return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'
// } catch {
// return false
// }
// }
// /**
// * 根据通知类型获取标题
// * @private
// */
// private _getNoticeTitle(type: string): string {
// const titleMap: Record<string, string> = {
// update: '插件更新',
// error: '插件错误',
// warning: '插件警告',
// info: '插件信息',
// success: '操作成功'
// }
// return titleMap[type] || '插件通知'
// }
// /**
// * 根据通知类型获取默认消息
// * @private
// */
// private _getDefaultMessage(type: string, data: any): string {
// const pluginName = this.plugin?.pluginInfo?.name || '未知插件'
// switch (type) {
// case 'error':
// return `插件 "${pluginName}" 发生错误: ${data?.error || '未知错误'}`
// case 'warning':
// return `插件 "${pluginName}" 警告: ${data?.warning || '需要注意'}`
// case 'success':
// return `插件 "${pluginName}" 操作成功`
// case 'info':
// default:
// return `插件 "${pluginName}" 信息: ${JSON.stringify(data)}`
// }
// }
/**
* 解析响应体
* @private
*/
private async _parseResponseBody(response: any): Promise<any> {
const contentType = response.headers.get('content-type') || ''
try {
if (contentType.includes('application/json')) {
return await response.json()
} else if (contentType.includes('text/')) {
return await response.text()
} else {
// 对于其他类型,尝试解析为 JSON失败则返回文本
const text = await response.text()
try {
return JSON.parse(text)
} catch {
return text
}
}
} catch (parseError: any) {
console.error(`${CONSTANTS.LOG_PREFIX} 解析响应失败: ${parseError.message}`)
return {
error: 'Parse failed',
message: parseError.message,
statusCode: response.status
}
}
}
/**
* 创建错误结果
* @private
*/
private _createErrorResult(error: any, url: string): RequestResult {
const isTimeout = error.name === 'AbortError'
return {
body: {
error: error.name || 'RequestError',
message: error.message,
url
},
statusCode: isTimeout ? 408 : 500,
headers: {}
}
}
// ==================== API 构建方法 ====================
/**
* 获取 cerumusic API 对象
* @private
*/
private _getCerumusicApi(): CeruMusicApi {
return {
env: CONSTANTS.ENVIRONMENT,
version: CONSTANTS.API_VERSION,
utils: this._createApiUtils(),
request: this._createRequestFunction(),
NoticeCenter: this._createNoticeCenter()
}
}
/**
* 创建 API 工具对象
* @private
*/
private _createApiUtils(): CeruMusicApiUtils {
return {
buffer: {
from: (data: string | Buffer | ArrayBuffer, encoding?: BufferEncoding) => {
if (typeof data === 'string') {
return Buffer.from(data, encoding)
} else if (data instanceof Buffer) {
return data
} else if (data instanceof ArrayBuffer) {
return Buffer.from(new Uint8Array(data))
} else {
return Buffer.from(data as any)
}
},
bufToString: (buffer: Buffer, encoding?: BufferEncoding) => buffer.toString(encoding)
}
}
}
/**
* 创建请求函数
* @private
*/
private _createRequestFunction() {
return (
url: string,
options?: RequestOptions | RequestCallback,
callback?: RequestCallback
) => {
// 支持 Promise 和 callback 两种调用方式
if (typeof options === 'function') {
callback = options as RequestCallback
options = { method: 'GET' }
}
const requestOptions = options as RequestOptions
const makeRequest = () => this._makeHttpRequest(url, requestOptions)
// 执行请求
if (callback) {
makeRequest()
.then((result) => callback(null, result))
.catch((error) => {
const errorResult = this._createErrorResult(error, url)
callback(error, errorResult)
})
return undefined
} else {
return makeRequest()
}
}
}
/**
* 执行 HTTP 请求
* @private
*/
private async _makeHttpRequest(url: string, options: RequestOptions): Promise<RequestResult> {
const controller = new AbortController()
const timeout = options.timeout || CONSTANTS.DEFAULT_TIMEOUT
const timeoutId = setTimeout(() => {
controller.abort()
console.warn(`${CONSTANTS.LOG_PREFIX} 请求超时: ${url}`)
}, timeout)
try {
console.log(`${CONSTANTS.LOG_PREFIX} 发起请求: ${options.method || 'GET'} ${url}`)
const fetchOptions = {
method: 'GET',
...options,
signal: controller.signal
}
const response = await fetch(url, fetchOptions)
clearTimeout(timeoutId)
console.log(`${CONSTANTS.LOG_PREFIX} 请求响应: ${response.status} ${response.statusText}`)
const body = await this._parseResponseBody(response)
const headers = this._extractHeaders(response)
const result: RequestResult = {
body,
statusCode: response.status,
headers
}
console.log(`${CONSTANTS.LOG_PREFIX} 请求完成:`, {
url,
status: response.status,
bodyType: typeof body
})
return result
} catch (error: any) {
clearTimeout(timeoutId)
const errorMessage =
error.name === 'AbortError' ? `请求超时: ${url}` : `请求失败: ${error.message}`
console.error(`${CONSTANTS.LOG_PREFIX} ${errorMessage}`)
throw error
}
}
/**
* 提取响应头
* @private
*/
private _extractHeaders(response: any): Record<string, string> {
const headers: Record<string, string> = {}
response.headers.forEach((value: string, key: string) => {
headers[key] = value
})
return headers
}
/**
* 创建通知中心
* @private
*/
private _createNoticeCenter() {
return (type: string, data: any) => {
const sendNotice = () => {
if (this.plugin?.pluginInfo) {
sendPluginNotice(
{ type: type as any, data, currentVersion: this.plugin.pluginInfo.version },
this.plugin.pluginInfo.name
)
} else {
// 如果插件还未初始化,延迟执行
setTimeout(sendNotice, CONSTANTS.NOTICE_DELAY)
}
}
sendNotice()
}
}
}

View File

@@ -68,7 +68,6 @@ function extractDefaultSources() {
};
});
console.log('提取的音源配置:', extractedSources);
return extractedSources;
} catch (e) {
console.log('解析 MUSIC_QUALITY 失败:', e.message);
@@ -94,6 +93,70 @@ sources = extractDefaultSources();
let isInitialized = false;
let pluginSources = {};
let requestHandler = null;
let updateAlertSent = false; // 防止重复发送更新提示
// 处理更新提示事件
function handleUpdateAlert(data, cerumusicApi) {
// 每次运行脚本只能调用一次
if (updateAlertSent) {
console.warn(\`[${pluginName}] updateAlert 事件每次运行脚本只能调用一次,忽略重复调用\`);
return;
}
if (!data || !data.log) {
console.error(\`[${pluginName}] updateAlert 事件缺少必需的 log 参数\`);
return;
}
// 验证和处理参数
let log = String(data.log);
let updateUrl = data.updateUrl ? String(data.updateUrl) : undefined;
// 限制 log 长度为 1024 字符
if (log.length > 1024) {
log = log.substring(0, 1024);
console.warn(\`[${pluginName}] 更新日志超过 1024 字符,已截断\`);
}
// 验证 updateUrl 格式
if (updateUrl) {
if (updateUrl.length > 1024) {
updateUrl = updateUrl.substring(0, 1024);
console.warn(\`[${pluginName}] 更新地址超过 1024 字符,已截断\`);
}
if (!updateUrl.startsWith('http://') && !updateUrl.startsWith('https://')) {
console.error(\`[${pluginName}] updateUrl 必须是 HTTP 协议的 URL 地址\`);
updateUrl = undefined;
}
}
// 标记已发送
updateAlertSent = true;
// 通过 CeruMusic 的通知系统发送更新提示
try {
// 使用传入的 cerumusic API 对象发送通知
if (cerumusicApi && cerumusicApi.NoticeCenter) {
cerumusicApi.NoticeCenter('update', {
title: \`${pluginName} 有新版本可用\`,
content: log,
url: updateUrl,
pluginInfo: {
name: '${pluginName}',
type: 'lx',
forcedUpdate: false
}
});
console.log(\`[${pluginName}] 更新提示已发送\`, { log: log.substring(0, 100) + '...', updateUrl });
} else {
console.error(\`[${pluginName}] CeruMusic API 不可用,无法发送更新提示\`);
}
} catch (error) {
console.error(\`[${pluginName}] 发送更新提示失败:\`, error.message);
}
}
initializePlugin()
function initializePlugin() {
if (isInitialized) return;
@@ -133,9 +196,9 @@ function initializePlugin() {
qualitys: sourceInfo.qualitys || originalQualitys || ['128k', '320k']
};
});
console.log('[${pluginName + ' by Ceru插件' || 'ceru插件'}] 音源注册完成:', Object.keys(pluginSources));
console.log('[${pluginName + ' by Ceru插件' || 'ceru插件'}] 动态音源信息已更新:', sources);
} else if (event === 'updateAlert' && data) {
// 处理更新提示事件,传入 cerumusic API
handleUpdateAlert(data, cerumusic);
}
},
request: request,

View 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 }

View 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 }

View File

@@ -1,10 +1,9 @@
// 导入通用工具函数
import { dateFormat } from '../../common/utils/common'
import { dateFormat } from '@common/utils/common'
// 导出通用工具函数
export * from '../../common/utils/nodejs'
export * from '../../common/utils/common'
export * from '../../common/utils/tools'
/**
* 格式化播放数量

View File

@@ -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()

View File

@@ -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)

View File

@@ -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) || ''

View File

@@ -24,5 +24,4 @@ const kg = {
return `https://www.kugou.com/song/#hash=${songInfo.hash}&album_id=${songInfo.albumId}`
}
}
export default kg

View File

@@ -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`
}
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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)
@@ -407,11 +407,10 @@ export default {
return result.list[0].global_collection_id
},
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
@@ -449,7 +448,7 @@ export default {
}
},
createGetListDetail2Task(id, total) {
let tasks = []
const tasks = []
let page = 0
while (total) {
const limit = total > 300 ? 300 : total
@@ -482,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: {
@@ -502,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,
@@ -535,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: {
@@ -596,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: {
@@ -609,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
},
@@ -622,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)
@@ -668,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)
@@ -699,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]
@@ -736,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,
@@ -744,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,
@@ -752,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,
@@ -760,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,
@@ -850,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
@@ -859,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,
@@ -867,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,
@@ -875,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,
@@ -883,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,
@@ -928,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)
@@ -965,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_', '')
}

View File

@@ -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)
}

View File

@@ -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'] = {

View File

@@ -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
})

View File

@@ -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({

View File

@@ -148,8 +148,8 @@ export default {
}, */
sortLrcArr(arr) {
const lrcSet = new Set()
let lrc = []
let lrcT = []
const lrc = []
const lrcT = []
let isLyricx = false
for (const item of arr) {
@@ -192,11 +192,11 @@ export default {
},
parseLrc(lrc) {
const lines = lrc.split(/\r\n|\r|\n/)
let tags = []
let lrcArr = []
const tags = []
const lrcArr = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim()
let result = timeExp.exec(line)
const result = timeExp.exec(line)
if (result) {
const text = line.replace(timeExp, '').trim()
let time = RegExp.$1

View File

@@ -32,7 +32,7 @@ export default {
// console.log(rawData)
for (let i = 0; i < rawData.length; i++) {
const info = rawData[i]
let songId = info.MUSICRID.replace('MUSIC_', '')
const songId = info.MUSICRID.replace('MUSIC_', '')
// const format = (info.FORMATS || info.formats).split('|')
if (!info.N_MINFO) {
@@ -43,7 +43,7 @@ export default {
const types = []
const _types = {}
let infoArr = info.N_MINFO.split(';')
const infoArr = info.N_MINFO.split(';')
for (let info of infoArr) {
info = info.match(this.regExps.mInfo)
if (info) {
@@ -77,7 +77,7 @@ export default {
}
types.reverse()
let interval = parseInt(info.DURATION)
const interval = parseInt(info.DURATION)
result.push({
name: decodeName(info.SONGNAME),
@@ -109,7 +109,7 @@ export default {
// console.log(result)
if (!result || (result.TOTAL !== '0' && result.SHOW === '0'))
return this.search(str, page, limit, ++retryNum)
let list = this.handleResult(result.abslist)
const list = this.handleResult(result.abslist)
if (list == null) return this.search(str, page, limit, ++retryNum)

View File

@@ -95,7 +95,7 @@ export default {
let id
let type
if (tagId) {
let arr = tagId.split('-')
const arr = tagId.split('-')
id = arr[0]
type = arr[1]
} else {
@@ -235,9 +235,9 @@ export default {
filterBDListDetail(rawList) {
return rawList.map((item) => {
let types = []
let _types = {}
for (let info of item.audios) {
const types = []
const _types = {}
for (const info of item.audios) {
info.size = info.size?.toLocaleUpperCase()
switch (info.bitrate) {
case '4000':
@@ -415,9 +415,9 @@ export default {
filterListDetail(rawData) {
// console.log(rawData)
return rawData.map((item) => {
let infoArr = item.N_MINFO.split(';')
let types = []
let _types = {}
const infoArr = item.N_MINFO.split(';')
const types = []
const _types = {}
for (let info of infoArr) {
info = info.match(this.regExps.mInfo)
if (info) {
@@ -478,7 +478,7 @@ export default {
getDetailPageUrl(id) {
if (/[?&:/]/.test(id)) id = id.replace(this.regExps.listDetailLink, '$1')
else if (/^digest-/.test(id)) {
let result = id.split('__')
const result = id.split('__')
id = result[1]
}
return `http://www.kuwo.cn/playlist_detail/${id}`

View File

@@ -111,8 +111,8 @@ export const lrcTools = {
// 使用原始的酷我音乐时间计算逻辑,但输出绝对时间戳
const offset = parseInt(str)
const offset2 = parseInt(str2)
let startTime = Math.abs((offset + offset2) / (this.offset * 2))
let duration = Math.abs((offset - offset2) / (this.offset2 * 2))
const startTime = Math.abs((offset + offset2) / (this.offset * 2))
const duration = Math.abs((offset - offset2) / (this.offset2 * 2))
// 转换为基于行开始时间的绝对时间戳
const absoluteStartTime = lineStartTime + startTime

View File

@@ -9,7 +9,7 @@ export default {
async getComment(musicInfo, page = 1, limit = 10) {
if (this._requestObj) this._requestObj.cancelHttp()
if (!musicInfo.songId) {
let id = await getSongId(musicInfo)
const id = await getSongId(musicInfo)
if (!id) throw new Error('获取评论失败')
musicInfo.songId = id
}
@@ -40,7 +40,7 @@ export default {
if (this._requestObj2) this._requestObj2.cancelHttp()
if (!musicInfo.songId) {
let id = await getSongId(musicInfo)
const id = await getSongId(musicInfo)
if (!id) throw new Error('获取评论失败')
musicInfo.songId = id
}

View File

@@ -102,7 +102,7 @@ export default {
},
filterBoardsData(rawList) {
// console.log(rawList)
let list = []
const list = []
for (const board of rawList) {
if (board.template != 'group1') continue
for (const item of board.itemList) {
@@ -112,7 +112,7 @@ export default {
)
continue
let data = item.displayLogId.param
const data = item.displayLogId.param
list.push({
id: 'mg__' + data.rankId,
name: data.rankName,
@@ -164,7 +164,7 @@ export default {
},
getDetailPageUrl(id) {
if (typeof id == 'string') id = id.replace('mg__', '')
if (typeof id === 'string') id = id.replace('mg__', '')
for (const item of boardList) {
if (item.bangid == id) {
return `https://music.migu.cn/v3/music/top/${item.webId}`

View File

@@ -16,21 +16,21 @@ const mrcTools = {
for (const line of lines) {
if (line.length < 6) continue
let result = this.rxps.lineTime.exec(line)
const result = this.rxps.lineTime.exec(line)
if (!result) continue
const startTime = parseInt(result[1])
let time = startTime
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}`
let words = line.replace(this.rxps.lineTime, '')
const words = line.replace(this.rxps.lineTime, '')
lrcLines.push(`[${time}]${words.replace(this.rxps.wordTimeAll, '')}`)
@@ -100,11 +100,11 @@ export default {
getLyricWeb(songInfo, tryNum = 0) {
// console.log(songInfo.copyrightId)
if (songInfo.lrcUrl) {
let requestObj = httpFetch(songInfo.lrcUrl)
const requestObj = httpFetch(songInfo.lrcUrl)
requestObj.promise = requestObj.promise.then(({ body, statusCode }) => {
if (statusCode !== 200) {
if (tryNum > 5) return Promise.reject(new Error('歌词获取失败'))
let tryRequestObj = this.getLyricWeb(songInfo, ++tryNum)
const tryRequestObj = this.getLyricWeb(songInfo, ++tryNum)
requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj)
return tryRequestObj.promise
}
@@ -115,7 +115,7 @@ export default {
})
return requestObj
} else {
let requestObj = httpFetch(
const requestObj = httpFetch(
`https://music.migu.cn/v3/api/music/audioPlayer/getLyric?copyrightId=${songInfo.copyrightId}`,
{
headers: {
@@ -126,7 +126,7 @@ export default {
requestObj.promise = requestObj.promise.then(({ body }) => {
if (body.returnCode !== '000000' || !body.lyric) {
if (tryNum > 5) return Promise.reject(new Error('Get lyric failed'))
let tryRequestObj = this.getLyricWeb(songInfo, ++tryNum)
const tryRequestObj = this.getLyricWeb(songInfo, ++tryNum)
requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj)
return tryRequestObj.promise
}
@@ -140,9 +140,9 @@ export default {
},
getLyric(songInfo) {
let requestObj = mrcTools.getLyric(songInfo)
const requestObj = mrcTools.getLyric(songInfo)
requestObj.promise = requestObj.promise.catch(() => {
let webRequestObj = this.getLyricWeb(songInfo)
const webRequestObj = this.getLyricWeb(songInfo)
requestObj.cancelHttp = webRequestObj.cancelHttp.bind(webRequestObj)
return webRequestObj.promise
})

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