Compare commits

...

57 Commits

Author SHA1 Message Date
sqj
0cfc31de31 feat: 优化搜索联想功能,性能优化设置,网络负载优化设置新增播放列表 **tag** 动画 fix: 修复 2 条 接口失效无法获取搜索联想建议,SMTC问题 2025-10-07 18:14:45 +08:00
sqj
2c0c8be2bf feat: 优化搜索联想功能,性能优化设置,网络负载优化设置新增播放列表 **tag** 动画 fix: 修复 2 条 接口失效无法获取搜索联想建议,SMTC问题 2025-10-07 18:13:53 +08:00
sqj
489e920b69 feat: 搜索联想 fix: 网易云歌单导入数量限制1000的问题 2025-10-06 22:54:10 +08:00
star
fdd548972c fix(local.vue): 轮询获取所有歌单歌曲并聚合 2025-10-04 21:42:06 +08:00
sqj
f81b46b1b4 1. 支持暗黑主题 2. 调整插件页面ui 2025-10-03 23:03:26 +08:00
sqj
e1e2d88c67 1. 支持暗黑主题 2. 调整插件页面ui 2025-10-03 22:34:47 +08:00
sqj
3c0be7a20f 1. 支持暗黑主题 2. 调整插件页面ui 2025-10-03 22:11:57 +08:00
sqj
79e05c884d 1. 支持暗黑主题 2. 调整插件页面ui 2025-10-03 17:38:04 +08:00
sqj
970baf081b 1. 支持暗黑主题 2. 调整插件页面ui 2025-10-03 17:37:34 +08:00
sqj
9341c57278 1. 支持暗黑主题 2. 调整插件页面ui 2025-10-03 15:59:20 +08:00
sqj
15a76a5313 1. 支持暗黑主题 2. 调整插件页面ui 2025-10-03 15:42:22 +08:00
时迁酱
6ee7e6dc99 Update README.md 2025-10-01 19:32:10 +08:00
时迁酱
630ec056db Update README.md 2025-10-01 11:23:42 +08:00
时迁酱
d739e60930 Delete docs/guide/design.md 2025-10-01 11:19:05 +08:00
时迁酱
86ea8b6797 Update config.mts 2025-10-01 11:18:13 +08:00
sqj
3d87fa145f docs 2025-09-30 13:46:27 +08:00
sqj
6fa4e21d40 feat: 新增插件在线导入 2025-09-29 21:06:26 +08:00
sqj
4c11c19139 feat: 新增插件在线导入 2025-09-29 20:40:10 +08:00
sqj
f82d4271a7 doc:ship 2025-09-28 23:02:32 +08:00
sqj
42dcf52d59 Merge branch 'main' of https://github.com/timeshiftsauce/CeruMusic 2025-09-28 22:20:41 +08:00
sqj
d9d2bdab93 fix website 2025-09-28 22:19:45 +08:00
sqj
6060aa2ef4 deleted docs 2025-09-28 21:28:15 +08:00
时迁酱
0b2e8eef64 Delete .github/workflows/deploydocs.yml 2025-09-28 21:27:59 +08:00
sqj
57de7b49e8 fix:优化播放列表,单击播放,右键菜单,播放进度调粗细 2025-09-28 21:22:27 +08:00
sqj
42e17e83e7 fix:优化播放列表,单击播放,右键菜单 2025-09-28 21:00:35 +08:00
sqj
380c273329 fix: workflow 2025-09-27 16:36:43 +08:00
sqj
6f56f5e240 fix: flac格式使用ffmpeg 修复高音质下载失效 2025-09-27 08:07:49 +08:00
sqj
7af7779e5c fix: flac格式使用ffmpeg 2025-09-27 07:37:20 +08:00
sqj
669a348218 fix: flac格式使用ffmpeg 2025-09-27 07:35:42 +08:00
sqj
f02264c80c 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-27 00:14:45 +08:00
sqj
d0d5f918bd 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:48:21 +08:00
sqj
761d265d18 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:27:50 +08:00
sqj
204df64535 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:25:22 +08:00
sqj
cc814eddbd 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:08:18 +08:00
sqj
51df14a9e9 1. 歌单
- 新增右键移除歌曲
   - local 页歌单右键操作
   - 歌单页支持修改封面
2. debug:右键菜单二级菜单位置决策
2025-09-25 19:56:45 +08:00
sqj
2473b36928 feat:列表新增右键菜单;fix:播放列表滚动条,搜索页切换源重新加载 2025-09-25 02:43:02 +08:00
sqj
dbba7a3d26 1. 软件启动位置 宽高记忆 限制软件最大宽高
2. debug: 修复歌曲音质支持短缺问题
2025-09-22 19:36:07 +08:00
sqj
a817865bd8 Merge branch 'main' of https://github.com/timeshiftsauce/CeruMusic 2025-09-22 19:34:58 +08:00
sqj
c4a4d26bd8 1. 软件启动位置 宽高记忆 限制软件最大宽高
2. debug: 修复歌曲音质支持短缺问题
2025-09-22 19:34:06 +08:00
时迁酱
dfa36d872e Update README.md 2025-09-22 12:08:35 +08:00
sqj
995859e661 1 2025-09-22 03:54:49 +08:00
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
336 changed files with 88912 additions and 56685 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"
}
}

View File

@@ -1,66 +0,0 @@
# 构建 VitePress 站点并将其部署到 GitHub Pages 的示例工作流程
#
name: Deploy VitePress site to Pages
on:
# 在针对 `main` 分支的推送上运行。如果你
# 使用 `master` 分支作为默认分支,请将其更改为 `master`
push:
branches: [main]
# 允许你从 Actions 选项卡手动运行此工作流程
workflow_dispatch:
# 设置 GITHUB_TOKEN 的权限,以允许部署到 GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# 只允许同时进行一次部署,跳过正在运行和最新队列之间的运行队列
# 但是,不要取消正在进行的运行,因为我们希望允许这些生产部署完成
concurrency:
group: pages
cancel-in-progress: false
jobs:
# 构建工作
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # 如果未启用 lastUpdated则不需要
- uses: pnpm/action-setup@v3 # 如果使用 pnpm请取消此区域注释
with:
version: 9
# - uses: oven-sh/setup-bun@v1 # 如果使用 Bun请取消注释
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm # 或 pnpm / yarn
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Install dependencies
run: pnpm add -D vitepress@next # 或 pnpm install / yarn install / bun install
- name: Build with VitePress
run: pnpm run docs:build # 或 pnpm docs:build / yarn docs:build / bun run docs:build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs/.vitepress/dist
# 部署工作
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
needs: build
runs-on: ubuntu-latest
name: Deploy
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -39,7 +39,7 @@ jobs:
- name: Build Electron App for macos
if: matrix.os == 'macos-latest' # 只在macOS上运行
run: |
yarn run build:mac
yarn run build:mac:universal
- name: Build Electron App for linux
if: matrix.os == 'ubuntu-latest' # 只在Linux上运行
@@ -70,146 +70,4 @@ 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
body: ${{ github.event.head_commit.message }} # 自动将commit message写入release描述

1
.gitignore vendored
View File

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

View File

@@ -1,31 +0,0 @@
{
"hash": "23b978c5",
"configHash": "c96c5ee9",
"lockfileHash": "603038da",
"browserHash": "b1457114",
"optimized": {
"vue": {
"src": "../../../node_modules/vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "7c4217d1",
"needsInterop": false
},
"vitepress > @vue/devtools-api": {
"src": "../../../node_modules/vitepress/node_modules/@vue/devtools-api/dist/index.js",
"file": "vitepress___@vue_devtools-api.js",
"fileHash": "dc8e5ae9",
"needsInterop": false
},
"vitepress > @vueuse/core": {
"src": "../../../node_modules/@vueuse/core/index.mjs",
"file": "vitepress___@vueuse_core.js",
"fileHash": "74c34320",
"needsInterop": false
}
},
"chunks": {
"chunk-TH7GRLUQ": {
"file": "chunk-TH7GRLUQ.js"
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,342 +0,0 @@
import {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBaseVNode,
createBlock,
createCommentVNode,
createElementBlock,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
} from './chunk-TH7GRLUQ.js'
export {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBlock,
createCommentVNode,
createElementBlock,
createBaseVNode as createElementVNode,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
}

View File

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

215
.workflow/main copy.yml Normal file
View File

@@ -0,0 +1,215 @@
name: AutoBuild # 工作流的名称
permissions:
contents: write # 给予写入仓库内容的权限
on:
push:
tags:
- 'v*' # 当推送以v开头的标签时触发此工作流
workflow_dispatch:
jobs:
release:
name: build and release electron app # 任务名称
runs-on: ${{ matrix.os }} # 在matrix.os定义的操作系统上运行
strategy:
fail-fast: false # 如果一个任务失败,其他任务继续运行
matrix:
os: [windows-latest, macos-latest, ubuntu-latest] # 在Windows和macOS上运行任务
steps:
- name: Check out git repository
uses: actions/checkout@v4 # 检出代码仓库
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 22 # 安装Node.js 22 这里node环境是能够运行代码的环境
- name: Install Dependencies
run: |
npm i -g yarn
yarn install # 安装项目依赖
- name: Build Electron App for windows
if: matrix.os == 'windows-latest' # 只在Windows上运行
run: yarn run build:win # 构建Windows版应用
- name: Build Electron App for macos
if: matrix.os == 'macos-latest' # 只在macOS上运行
run: |
yarn run build:mac
- name: Build Electron App for linux
if: matrix.os == 'ubuntu-latest' # 只在Linux上运行
run: yarn run build:linux # 构建Linux版应用
- name: Cleanup Artifacts for Windows
if: matrix.os == 'windows-latest'
run: |
npx del-cli "dist/*" "!dist/*.exe" "!dist/*.zip" "!dist/*.yml" # 清理Windows构建产物,只保留特定文件
- name: Cleanup Artifacts for MacOS
if: matrix.os == 'macos-latest'
run: |
npx del-cli "dist/*" "!dist/(*.dmg|*.zip|latest*.yml)" # 清理macOS构建产物,只保留特定文件
- name: Cleanup Artifacts for Linux
if: matrix.os == 'ubuntu-latest'
run: |
npx del-cli "dist/*" "!dist/(*.AppImage|*.deb|*.snap|latest*.yml)" # 清理Linux构建产物,只保留特定文件
- name: upload artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.os }}
path: dist # 上传构建产物作为工作流artifact
- name: release
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

213
README.md
View File

@@ -6,7 +6,13 @@
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-20251003173109619.png" alt="image-20251003173109619" style="zoom:33%;" />
![image-20251003173654569](assets/image-20251003173654569.png)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=timeshiftsauce/CeruMusic&type=Date)](https://www.star-history.com/#timeshiftsauce/CeruMusic&Date)
## 技术栈
@@ -16,7 +22,199 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工
- **Pinia**:状态管理工具
- **Vite**:快速的前端构建工具
- **CeruPlugins**:音乐插件运行环境(仅提供框架,不包含默认插件)
- **AMLL**:音乐生态辅助模块(仅提供功能接口,不关联具体音乐数据源)
- **AMLL**:音乐生态(歌词渲染等)辅助模块(仅提供功能接口,不关联具体音乐数据源)
## 项目结构
<details>
<summary>点击查看目录结构</summary>
```ast
CeruMuisc/
├── .github/
├── scripts/
├── 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
│ │ │ ├── pluginNotice.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
│ │ │ └── ConfigManager.ts
│ │ ├── utils/
│ │ │ ├── musicSdk/
│ │ │ │ ├── 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
│ │ │ │ ├── ContextMenu/
│ │ │ │ │ ├── composables.ts
│ │ │ │ │ ├── ContextMenu.vue
│ │ │ │ │ ├── demo.vue
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── README.md
│ │ │ │ │ ├── types.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── layout/
│ │ │ │ │ └── HomeLayout.vue
│ │ │ │ ├── Music/
│ │ │ │ │ └── SongVirtualList.vue
│ │ │ │ ├── Play/
│ │ │ │ │ ├── AudioVisualizer.vue
│ │ │ │ │ ├── FullPlay.vue
│ │ │ │ │ ├── GlobalAudio.vue
│ │ │ │ │ ├── PlaylistActions.vue
│ │ │ │ │ ├── PlaylistDrawer.vue
│ │ │ │ │ ├── PlayMusic.vue
│ │ │ │ │ └── ShaderBackground.vue
│ │ │ │ ├── Settings/
│ │ │ │ │ ├── AIFloatBallSettings.vue
│ │ │ │ │ ├── DirectorySettings.vue
│ │ │ │ │ ├── MusicCache.vue
│ │ │ │ │ ├── PlaylistSettings.vue
│ │ │ │ │ ├── plugins.vue
│ │ │ │ │ └── UpdateSettings.vue
│ │ │ │ ├── PluginNoticeDialog.vue
│ │ │ │ ├── ThemeSelector.vue
│ │ │ │ ├── TitleBarControls.vue
│ │ │ │ ├── UpdateExample.vue
│ │ │ │ ├── UpdateProgress.vue
│ │ │ │ └── Versions.vue
│ │ │ ├── composables/
│ │ │ │ └── useAutoUpdate.ts
│ │ │ ├── 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
│ │ │ │ ├── welcome/
│ │ │ │ │ └── index.vue
│ │ │ │ └── ThemeDemo.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
```
</details>
## 主要功能
@@ -80,8 +278,8 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工
## 文档与资源
- [产品设计文档](https://www.doubao.com/thread/docs/design.md):涵盖项目架构、核心功能设计和开发规范(不含任何音乐数据源信息)。
- [插件开发文档](https://www.doubao.com/thread/docs/CeruMusic插件开发文档.md):仅提供插件开发技术规范,**明确要求插件开发者需遵守数据来源平台的用户协议与版权法**,禁止开发、传播获取非公开数据的插件。
- 产品设计文档:涵盖项目架构、核心功能设计和开发规范(不含任何音乐数据源信息)。
- [插件开发文档](https://ceru.docs.shiqianjiang.cn/guide/CeruMusicPluginDev.html):仅提供插件开发技术规范,**明确要求插件开发者需遵守数据来源平台的用户协议与版权法**,禁止开发、传播获取非公开数据的插件。
## 开源许可
@@ -92,7 +290,7 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工
欢迎开发者贡献代码与反馈建议,贡献内容需符合以下要求:
1. 仅涉及播放器框架功能优化、bug 修复、文档完善,不包含任何音乐数据源相关代码。
2. 遵循 [Git 提交规范](https://www.doubao.com/thread/docs/design.md#git提交规范) 并确保代码符合项目风格指南。
2. 遵循 [Git 提交规范](#) 并确保代码符合项目风格指南。
3. 贡献的代码需无第三方版权纠纷,且不违反开源许可协议。
## 联系方式
@@ -159,3 +357,8 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工
若您认可本项目的技术价值,欢迎通过以下方式支持开发者(仅用于项目技术维护与迭代):
<img src="assets/image-20250827175356006.png" alt="赞助方式1" style="zoom:33%;" /><img src="assets/image-20250827175547444.png" alt="赞助方式2" style="zoom: 33%;" />
## 联系
关于项目问题也可联系
邮箱sqj@shiqianjiang.cn

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 870 KiB

View File

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

View File

@@ -1,13 +1,32 @@
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: '/',
head: [
['link', { rel: 'icon', href: '/logo.svg' }],
['meta', { name: 'author', href: '时迁酱无聊的霜霜star' }],
[
'meta',
{
name: 'keywords',
content:
'Ceru Music,音乐播放器,音乐播放器工具,音乐播放器软件,音乐播放器下载,音乐播放器下载地址,澜音播放器,免费的音乐播放器,cerumusic,时迁酱,周晨鹭,无聊的霜霜,star,洛雪音乐,洛雪'
}
],
['meta', { name: 'baidu-site-verification', content: 'codeva-ocKFImCsOO' }]
],
description:
'Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,一个跨平台的音乐播放器应用,支持基于合规插件获取公开音乐信息与播放功能。',
markdown: {
config(md) {
md.use(note)
}
},
themeConfig: {
returnToTopLabel: '返回顶部',
// https://vitepress.dev/reference/default-theme-config
logo: '/logo.svg',
nav: [
@@ -19,8 +38,13 @@ 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/updateLog' },
{ text: '更新计划', link: '/guide/update' }
]
},
{
@@ -29,6 +53,10 @@ export default defineConfig({
{ text: '插件类使用', link: '/guide/CeruMusicPluginHost' },
{ text: '澜音插件开发文档(重点)', link: '/guide/CeruMusicPluginDev' }
]
},
{
text: '鸣谢名单',
link: '/guide/sponsorship'
}
],
@@ -48,10 +76,21 @@ export default defineConfig({
},
search: {
provider: 'local'
}
},
outline: {
level: [2, 4],
label: '文章导航'
},
docFooter: {
next: '下一篇',
prev: '上一篇'
},
lastUpdatedText: '上次更新'
},
lastUpdated: true,
head: [['link', { rel: 'icon', href: '/logo.svg' }]]
sitemap: {
hostname: 'https://ceru.docs.shiqianjiang.cn'
},
lastUpdated: true
})
console.log(process.env.BASE_URL_DOCS)
// Smooth scrolling functions

View File

@@ -1,5 +1,5 @@
import DefaultTheme from 'vitepress/theme'
import './style.css'
import './style.scss'
import './dark.css'
import MyLayout from './MyLayout.vue'
// history.scrollRestoration = 'manual'

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,142 +0,0 @@
# Alist 下载配置说明
## 概述
项目已从 GitHub 下载方式切换到 Alist API 下载方式,包括:
- 桌面应用的自动更新功能 (`src/main/autoUpdate.ts`)
- 官方网站的下载功能 (`website/script.js`)
## 配置步骤
### 1. 修改 Alist 域名
#### 桌面应用配置
`src/main/autoUpdate.ts` 文件中Alist 域名已配置为:
```typescript
const ALIST_BASE_URL = 'http://47.96.72.224:5244'
```
#### 网站配置
`website/script.js` 文件中Alist 域名已配置为:
```javascript
const ALIST_BASE_URL = 'http://47.96.72.224:5244'
```
如需修改域名,请同时更新这两个文件中的 `ALIST_BASE_URL` 配置。
### 2. 认证信息
已配置的认证信息:
- 用户名: `ceruupdata`
- 密码: `123456`
### 3. 文件路径格式
文件在 Alist 中的路径格式为:`/{version}/{文件名}`
例如:
- 版本 `v1.0.0` 的安装包 `app-setup.exe` 路径为:`/v1.0.0/app-setup.exe`
## 工作原理
### 桌面应用自动更新
1. **认证**: 使用配置的用户名和密码向 Alist API 获取认证 token
2. **获取文件信息**: 使用 token 调用 `/api/fs/get` 接口获取文件信息和签名
3. **下载**: 使用带签名的直接下载链接下载文件
4. **备用方案**: 如果 Alist 失败,自动回退到原始 URL 下载
### 网站下载功能
1. **获取版本列表**: 调用 `/api/fs/list` 获取根目录下的版本文件夹
2. **获取文件列表**: 获取最新版本文件夹中的所有文件
3. **平台匹配**: 根据用户平台自动匹配对应的安装包文件
4. **生成下载链接**: 获取文件的直接下载链接
5. **备用方案**: 如果 Alist 失败,自动回退到 GitHub API
## API 接口
### 认证接口
```
POST /api/auth/login
{
"username": "ceruupdata",
"password": "123456"
}
```
### 获取文件信息接口
```
POST /api/fs/get
Headers: Authorization: {token} # 注意:直接使用 token不需要 "Bearer " 前缀
{
"path": "/{version}/{fileName}"
}
```
### 获取文件列表接口
```
POST /api/fs/list
Headers: Authorization: {token} # 注意:直接使用 token不需要 "Bearer " 前缀
{
"path": "/",
"password": "",
"page": 1,
"per_page": 100,
"refresh": false
}
```
### 下载链接格式
```
{ALIST_BASE_URL}/d/{filePath}?sign={sign}
```
## 测试方法
项目包含了一个测试脚本来验证 Alist 连接:
```bash
node scripts/test-alist.js
```
该脚本会:
1. 测试服务器连通性
2. 测试用户认证
3. 测试文件列表获取
4. 测试文件信息获取
## 备用机制
两个组件都实现了备用机制:
### 桌面应用
- 主要:使用 Alist API 下载
- 备用:如果 Alist 失败,使用原始 URL 下载
### 网站
- 主要:使用 Alist API 获取版本和文件信息
- 备用:如果 Alist 失败,回退到 GitHub API
- 最终备用:跳转到 GitHub releases 页面
## 注意事项
1. 确保 Alist 服务器可以正常访问
2. 确保配置的用户名和密码有权限访问相应的文件路径
3. 文件必须按照指定的路径格式存放在 Alist 中
4. 网站会自动检测用户操作系统并推荐对应的下载版本
5. 所有下载都会显示文件大小信息

View File

@@ -1,99 +0,0 @@
# Alist 迁移完成总结
## 修改概述
项目已成功从 GitHub 下载方式迁移到 Alist API 下载方式。
## 修改的文件
### 1. 桌面应用自动更新 (`src/main/autoUpdate.ts`)
- ✅ 添加了 Alist API 配置
- ✅ 实现了 Alist 认证功能
- ✅ 实现了 Alist 文件下载功能
- ✅ 添加了备用机制Alist 失败时回退到原始 URL
- ✅ 修复了 Authorization 头格式(使用直接 token 而非 Bearer 格式)
### 2. 官方网站下载功能 (`website/script.js`)
- ✅ 添加了 Alist API 配置
- ✅ 实现了 Alist 认证功能
- ✅ 实现了版本列表获取功能
- ✅ 实现了文件列表获取功能
- ✅ 实现了平台文件匹配功能
- ✅ 添加了多层备用机制Alist → GitHub API → GitHub 页面)
- ✅ 修复了 Authorization 头格式
### 3. 配置文档
- ✅ 创建了详细的配置说明 (`docs/alist-config.md`)
- ✅ 创建了迁移总结文档 (`docs/alist-migration-summary.md`)
### 4. 测试脚本
- ✅ 创建了 Alist 连接测试脚本 (`scripts/test-alist.js`)
- ✅ 创建了认证格式测试脚本 (`scripts/auth-test.js`)
## 配置信息
### Alist 服务器配置
- **服务器地址**: `http://47.96.72.224:5244`
- **用户名**: `ceruupdate`
- **密码**: `123456`
- **文件路径格式**: `/{version}/{文件名}`
### Authorization 头格式
经过测试确认,正确的格式是:
```
Authorization: {token}
```
**注意**: 不需要 "Bearer " 前缀
## 功能特性
### 桌面应用
1. **智能下载**: 优先使用 Alist API失败时自动回退
2. **进度显示**: 支持下载进度显示和节流
3. **错误处理**: 完善的错误处理和日志记录
### 网站
1. **自动检测**: 自动检测用户操作系统并推荐对应版本
2. **版本信息**: 自动获取最新版本信息和文件大小
3. **多层备用**: Alist → GitHub API → GitHub 页面的三层备用机制
4. **用户体验**: 加载状态、成功通知、错误提示
## 测试结果
**Alist 连接测试**: 通过
**认证测试**: 通过
**文件列表获取**: 通过
**Authorization 头格式**: 已修复并验证
## 可用文件
测试显示 Alist 服务器当前包含以下文件:
- `v1.2.1/` (版本目录)
- `1111`
- `L3YxLjIuMS8tMS4yLjEtYXJtNjQtbWFjLnppcA==`
- `file2.msi`
- `file.msi`
## 后续维护
1. **添加新版本**: 在 Alist 中创建新的版本目录(如 `v1.2.2/`
2. **上传文件**: 将对应平台的安装包上传到版本目录中
3. **文件命名**: 确保文件名包含平台标识(如 `windows`, `mac`, `linux` 等)
## 备注
- 所有修改都保持了向后兼容性
- 实现了完善的错误处理和备用机制
- 用户体验不会因为迁移而受到影响
- 可以随时回退到 GitHub 下载方式

View File

@@ -1,105 +1,123 @@
---
layout: doc
---
# CeruMusic 插件开发文档
# CeruMusic 插件开发指南
## 概述
本文档介绍如何为 CeruMusic 开发音乐源插件。CeruMusic 插件是运行在沙箱环境中的 JavaScript 模块,用于从各种音乐平台获取音乐资源。
CeruMusic 支持两种类型的插件:
## 插件结构
1. **CeruMusic 原生插件**:基于 CeruMusic API 的插件格式
2. **LX 兼容插件**:兼容 LX Music 的事件驱动插件格式
### 基本结构
本文档将详细介绍如何开发这两种类型的插件。
每个 CeruMusic 插件必须导出以下三个核心组件:
## 文件要求
```javascript
module.exports = {
pluginInfo, // 插件信息
sources, // 支持的音源
musicUrl // 获取音乐链接的函数
}
```
- **编码格式**UTF-8
- **编程语言**JavaScript (支持 ES6+ 语法)
- **文件扩展名**`.js`
# 完整示例
## 插件信息注释
所有插件文件的开头必须包含以下注释格式:
```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: "酷我音乐",
qualitys: ['128k', '320k', 'flac', 'flac24bit']
},
demo2: {
name: '示例音源2',
type: 'music',
qualitys: ['128k', '320k']
tx:{
name: "QQ音乐",
qualitys: ['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,
qualitys: 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 +125,556 @@ module.exports = {
pluginInfo,
sources,
musicUrl,
getPic, // 可选
getLyric // 可选
}
getPic, // 可选
getLyric // 可选
};
```
## 详细说明
> #### PS:
>
> - `sources key` 取值
> - wy 网易云音乐 |
> - tx QQ音乐 |
> - kg 酷狗音乐 |
> - mg 咪咕音乐 |
> - kw 酷我音乐
> - 导出
>
> ```javascript
> module.exports = {
> sources // 你的音源支持
> }
> ```
>
> - 支持的音质 ` sources.qualitys: ['128k', '320k', 'flac']`
> - `128k`: 128kbps
> - `320k`: 320kbps
> - `flac`: FLAC 无损
> - `flac24bit`: 24bit FLAC
> - `hires`: Hi-Res 高解析度
> - `atmos`: 杜比全景声
> - `master`: 母带音质
### 1. pluginInfo 对象
### CeruMusic API 参考
插件的基本信息,必须包含以下字段:
#### cerumusic.request(url, options)
```javascript
const pluginInfo = {
name: '插件名称', // 必需:插件显示名称
version: '1.0.0', // 必需:版本号
author: '作者名', // 必需:作者信息
description: '插件描述' // 必需:功能描述
}
```
HTTP 请求方法,返回 Promise。
### 2. sources 对象
定义插件支持的音源,键为音源标识,值为音源配置:
```javascript
const sources = {
// 音源标识用于API调用
source_id: {
name: '音源显示名称', // 必需:用户看到的名称
type: 'music', // 必需:固定为 'music'
qualitys: [
// 必需:支持的音质列表
'128k', // 标准音质
'320k', // 高音质
'flac', // 无损音质
'flac24bit', // 24位无损
'hires' // 高解析度
]
}
}
```
### 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
}
console.log('响应:', response)
})
```
**参数说明:**
**参数:**
- `url` (string): 请求地址
- `options` (Object): 请求选项
- `method`: HTTP 方法 ('GET', 'POST', 等)
- `options` (object): 请求选项
- `method`: 请求方法 (GET, POST, PUT, DELETE 等)
- `headers`: 请求头对象
- `body`: 请求体POST 请求时)
- `body`: 请求体
- `timeout`: 超时时间(毫秒)
**响应格式:**
**返回值:**
```javascript
{
body: {}, // 解析后的响应体
statusCode: 200, // HTTP 状态码
headers: {} // 响应
statusCode: 200,
headers: {...},
body: {...} // 自动解析的响应
}
```
#### utils 对象
#### cerumusic.utils
提供实用工具函数
工具方法集合
```javascript
const { utils } = cerumusic
// Buffer 操作
const buffer = utils.buffer.from('hello', 'utf8')
const string = utils.buffer.bufToString(buffer, 'utf8')
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)
```
#### cerumusic.NoticeCenter(type, data)
发送通知到用户界面:
```javascript
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
}
})
}
}
}
// 注册 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)
}
})
// 发送初始化完成事件
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 参考
#### globalThis.lx.EVENT_NAMES
事件名称常量:
- `inited`: 初始化完成事件
- `request`: API 请求事件
- `updateAlert`: 更新提示事件
#### globalThis.lx.on(eventName, handler)
注册事件监听器:
```javascript
lx.on(lx.EVENT_NAMES.request, ({ source, action, info }) => {
// 必须返回 Promise
return Promise.resolve(result)
})
```
#### globalThis.lx.send(eventName, data)
发送事件:
```javascript
// 发送初始化事件
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 操作
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)
2. **提供有意义的错误信息**
// 结果验证
if (!result || result.statusCode !== 200) {
throw new Error(`API 请求失败: ${result?.statusCode || 'Unknown'}`)
}
```javascript
console.error(`[${pluginInfo.name}] Error: ${errorMessage}`)
throw new Error(errorMessage)
```
if (!result.body || !result.body.url) {
throw new Error('返回数据格式错误')
}
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 {
return await cerumusic.request(url, options)
} finally {
semaphore.release()
}
}
```
---
## 安全注意事项
### 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
}
}
```
### 3. 敏感信息保护
```javascript
// 不要在日志中输出敏感信息
console.log('请求参数:', {
...params,
token: '***', // 隐藏敏感信息
password: '***'
})
```
---
## 插件发布
### 1. 代码检查清单
- [ ] 插件信息注释完整
- [ ] 错误处理完善
- [ ] 性能优化合理
- [ ] 安全验证到位
- [ ] 测试覆盖充分
### 2. 测试建议
```javascript
// 单元测试示例
async function testMusicUrl() {
const testMusicInfo = {
id: 'test123',
name: '测试歌曲',
artist: '测试歌手'
}
try {
const url = await host.getMusicUrl('demo', musicInfo, '320k')
console.log('成功获取URL:', url)
const url = await musicUrl('kw', testMusicInfo, '320k')
console.log('测试通过:', url)
} catch (error) {
console.error('测试失败:', error.message)
console.error('测试失败:', error)
}
}
testPlugin()
```
## 发布和分发
### 3. 版本管理
### 文件结构
使用语义化版本号:
```
my-plugin/
├── plugin.js # 主插件文件
├── package.json # 包信息(可选)
├── README.md # 说明文档
└── test.js # 测试文件(可选)
```
### 版本管理
遵循语义化版本规范:
- `1.0.0` - 主版本.次版本.修订版本
- `1.0.0`: 主版本.次版本.修订版本
- 主版本:不兼容的 API 修改
- 次版本:向下兼容的功能性新增
- 修订版本:向下兼容的问题修正
## 示例插件
查看项目中的示例:
- `example-plugin.js` - 基础插件示例
- `plugin.js` - 事件驱动插件示例
- `fm.js` - 复杂插件示例
---
## 常见问题
**Q: 如何处理需要登录的 API**
### Q: 插件加载失败怎么办?
A: 在请求头中添加认证信息,或使用 Cookie。
A: 检查以下几点:
**Q: 如何处理加密的 API 响应?**
1. 文件编码是否为 UTF-8
2. 插件信息注释格式是否正确
3. JavaScript 语法是否有错误
4. 是否正确导出了必需的方法
A: 在插件中实现解密逻辑,使用 `utils` 对象提供的工具函数。
### Q: 如何处理跨域请求?
**Q: 插件可以访问文件系统吗?**
A: CeruMusic 的请求方法不受浏览器跨域限制,可以直接请求任何域名的 API。
A: 不可以,插件运行在受限的沙箱环境中,无法直接访问文件系统。
### Q: 插件如何更新?
**Q: 如何优化插件性能?**
A: 使用 `cerumusic.NoticeCenter` 事件通知用户更新:
A: 减少不必要的网络请求,使用适当的缓存策略,避免阻塞操作。
```javascript
cerumusic.NoticeCenter('update', {
title: '新版本更新',
content: 'xxxx',
version: 'v1.0.3',
url: 'https://shiqianjiang.cn',
pluginInfo: {
type: 'cr'
}
})
```
## 贡献指南
### Q: 如何调试插件?
1. Fork 项目仓库
2. 创建功能分支
3. 编写插件代码和测试
4. 提交 Pull Request
5. 等待代码审查
A:
欢迎贡献新的音源插件!
1. 使用 `console.log` 输出调试信息 可在设置—>插件管理—>日志 查看调试
2. LX 插件可以设置 `openDevTools: true` 打开开发者工具
3. 查看 CeruMusic 的插件日志
---
## 技术支持
如有问题或建议,请通过以下方式联系:
- 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

View File

@@ -1,197 +0,0 @@
# 音乐API接口文档
## 概述
这是一个基于 Meting 库的音乐API接口支持多个音乐平台的数据获取包括歌曲信息、专辑、歌词、播放链接等。
## 基础信息
- **请求方式**: GET
- **返回格式**: JSON
- **字符编码**: UTF-8
- **跨域支持**: 是
## 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
| ------ | ------ | ---- | ------- | -------------- |
| server | string | 否 | netease | 音乐平台 |
| type | string | 否 | search | 请求类型 |
| id | string | 否 | hello | 查询ID或关键词 |
### 支持的音乐平台 (server)
| 平台代码 | 平台名称 |
| -------- | ---------- |
| netease | 网易云音乐 |
| tencent | QQ音乐 |
| baidu | 百度音乐 |
| xiami | 虾米音乐 |
| kugou | 酷狗音乐 |
| kuwo | 酷我音乐 |
### 支持的请求类型 (type)
| 类型 | 说明 | id参数说明 |
| -------- | ------------ | ---------------- |
| search | 搜索歌曲 | 搜索关键词 |
| song | 获取歌曲详情 | 歌曲ID |
| album | 获取专辑信息 | 专辑ID |
| artist | 获取歌手信息 | 歌手ID |
| playlist | 获取歌单信息 | 歌单ID |
| lrc | 获取歌词 | 歌曲ID |
| url | 获取播放链接 | 歌曲ID |
| pic | 获取封面图片 | 歌曲/专辑/歌手ID |
## 响应格式
### 成功响应
```json
{
"success": true,
"message": {
// 具体数据内容,根据请求类型不同而不同
}
}
```
### 错误响应
```json
{
"success": false,
"message": "错误信息"
}
```
## 请求示例
### 1. 搜索歌曲
```
GET /?server=netease&type=search&id=周杰伦
```
**响应示例**:
```json
{
"success": true,
"message": [
{
"id": "186016",
"name": "青花瓷",
"artist": ["周杰伦"],
"album": "我很忙",
"pic_id": "109951163240682406",
"url_id": "186016",
"lyric_id": "186016"
}
]
}
```
### 2. 获取歌曲详情
```
GET /?server=netease&type=song&id=186016
```
### 3. 获取歌词
```
GET /?server=netease&type=lrc&id=186016
```
**响应示例**:
```json
{
"success": true,
"message": {
"lyric": "[00:00.00] 作词 : 方文山\n[00:01.00] 作曲 : 周杰伦\n[00:22.78]素胚勾勒出青花笔锋浓转淡\n..."
}
}
```
### 4. 获取播放链接
```
GET /?server=netease&type=url&id=186016
```
**响应示例**:
```json
{
"success": true,
"message": [
{
"id": "186016",
"url": "http://music.163.com/song/media/outer/url?id=186016.mp3",
"size": 4729252,
"br": 128
}
]
}
```
### 5. 获取专辑信息
```
GET /?server=netease&type=album&id=18905
```
### 6. 获取歌手信息
```
GET /?server=netease&type=artist&id=6452
```
### 7. 获取歌单信息
```
GET /?server=netease&type=playlist&id=19723756
```
### 8. 获取封面图片
```
GET /?server=netease&type=pic&id=186016
```
## 错误码说明
| 错误信息 | 说明 |
| ------------------- | ---------------- |
| require id. | 缺少必需的id参数 |
| unsupported server. | 不支持的音乐平台 |
| unsupported type. | 不支持的请求类型 |
## 注意事项
1. **代理支持**: 如果设置了环境变量 `METING_PROXY`API会使用代理访问音乐平台
2. **Cookie支持**: API会自动传递请求中的Cookie到音乐平台
3. **跨域访问**: API已配置CORS支持跨域请求
4. **请求频率**: 建议控制请求频率,避免被音乐平台限制
5. **数据时效性**: 音乐平台的数据可能会发生变化,建议适当缓存但不要过度依赖
## 使用建议
1. **错误处理**: 请务必检查响应中的 `success` 字段
2. **数据验证**: 返回的数据结构可能因平台而异,请做好数据验证
3. **备用方案**: 建议支持多个音乐平台作为备用数据源
4. **缓存策略**: 对于不经常变化的数据(如歌词、专辑信息)建议进行缓存
## 技术实现
本API基于以下技术栈
- **PHP**: 后端语言
- **Meting**: 音乐数据获取库
- **Composer**: 依赖管理
## 更新日志
- **v1.0.0**: 初始版本,支持基础的音乐数据获取功能

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,600 +0,0 @@
# Ceru Music 产品设计文档
## 项目概述
Ceru Music 是一个基于 Electron + Vue 3 的跨平台桌面音乐播放器,支持多音乐平台数据源,提供流畅的音乐播放体验。
## 项目架构
### 技术栈
- **前端框架**: Vue 3 + TypeScript + Composition API
- **桌面框架**: Electron (v37.2.3)
- **UI组件库**: TDesign Vue Next (v1.15.2)
- ![image-20250813180317221](..\assets\image-20250813180317221.png)
- **状态管理**: Pinia (v3.0.3)
- **路由管理**: Vue Router (v4.5.1)
- **构建工具**: Vite + electron-vite
- **包管理器**: PNPM
- **Node pnpm 版本**
```bash
PS D:\code\Ceru-Music> node -v
v22.17.0
PS D:\code\Ceru-Music> pnpm -v
10.14.0
```
-
### 架构设计
```asp
Ceru Music
├── 主进程 (Main Process)
│ ├── 应用生命周期管理
│ ├── 窗口管理
│ ├── 系统集成 (托盘、快捷键)
│ └── 文件系统操作
├── 渲染进程 (Renderer Process)
│ ├── Vue 3 应用
│ ├── 用户界面
│ ├── 音乐播放控制
│ └── 数据展示
└── 预加载脚本 (Preload Script)
└── 安全的 IPC 通信桥梁
```
### 目录结构
```
src/
├── main/ # 主进程代码
│ ├── index.ts # 主进程入口
│ ├── window.ts # 窗口管理
│ └── services/ # 主进程服务
├── preload/ # 预加载脚本
│ └── index.ts # IPC 通信接口
└── renderer/ # 渲染进程 (Vue 应用)
├── src/
│ ├── components/ # Vue 组件
│ ├── views/ # 页面视图
│ ├── stores/ # Pinia 状态管理
│ ├── services/ # API 服务
│ ├── utils/ # 工具函数
│ └── types/ # TypeScript 类型定义
└── index.html # 应用入口
```
## 项目开发使用方式
### 开发环境启动
```bash
# 安装依赖
pnpm install
# 启动开发服务器
pnpm dev
# 代码检查
pnpm lint
# 类型检查
pnpm typecheck
```
### 构建打包
```bash
# 构建当前平台
pnpm build
# 构建 Windows 版本
pnpm build:win
# 构建 macOS 版本
pnpm build:mac
# 构建 Linux 版本
pnpm build:linux
```
## 音乐数据源接口设计
### 接口1: 网易云音乐原生接口 (主要数据源)
#### 获取音乐信息
- **请求地址**: `https://music.163.com/api/song/detail`
- **请求参数**: `ids=[ID1,ID2,ID3,...]` 音乐ID列表
- **示例**: `https://music.163.com/api/song/detail?ids=[36270426]`
#### 获取音乐直链
- **请求地址**: `https://music.163.com/song/media/outer/url`
- **请求参数**: `id=123` 音乐ID
- **示例**: `https://music.163.com/song/media/outer/url?id=36270426.mp3`
#### 获取歌词
- **请求地址**: `https://music.163.com/api/song/lyric`
- **请求参数**:
- `id=123` 音乐ID
- `lv=-1` 获取歌词
- `yv=-1` 获取逐字歌词
- `tv=-1` 获取歌词翻译
- **示例**: `https://music.163.com/api/song/lyric?id=36270426&lv=-1&yv=-1&tv=-1`
#### 搜索歌曲
- **请求地址**: `https://music.163.com/api/search/get/web`
- **请求参数**:
- `s` 歌名
- `type=1` 搜索类型
- `offset=0` 偏移量
- `limit=10` 搜索结果数量
- **示例**: `https://music.163.com/api/search/get/web?s=来自天堂的魔鬼&type=1&offset=0&limit=10`
### 接口2: Meting API (备用数据源)
#### 参数说明
- **server**: 数据源
- `netease` 网易云音乐(默认)
- `tencent` QQ音乐
- **type**: 类型
- `name` 歌曲名
- `artist` 歌手
- `url` 链接
- `pic` 封面
- `lrc` 歌词
- `song` 单曲
- `playlist` 歌单
- **id**: 类型ID封面ID/单曲ID/歌单ID
#### 使用示例
```
https://api.qijieya.cn/meting/?type=url&id=1969519579
https://api.qijieya.cn/meting/?type=song&id=591321
https://api.qijieya.cn/meting/?type=playlist&id=2619366284
```
### 接口3: 备选接口
- **地址**: https://doc.vkeys.cn/api-doc/
- **说明**: 不建议使用,延迟较高
### 接口4: 自部署接口 (备用)
- **地址**: `https://music.shiqianjiang.cn?id=你是我的风景&server=netease`
- **说明**: 不支持分页,用于获取歌曲源、歌词源等
- **文档**: [API文档](./api.md)
## 核心功能设计
### 通用请求函数设计
```typescript
// 音乐服务接口定义
interface MusicService {
search({keyword: string, page?: number, limit?: number}): Promise<SearchResult>
getSongDetail({id: string)}: Promise<SongDetail>
getSongUrl({id: string}): Promise<string>
getLyric({id: string}): Promise<LyricData>
getPlaylist({id: string}): Promise<PlaylistData>
}
// 通用请求函数
async function request(method: string, ...args: any{},isLoading=false): Promise<any> {
try {
switch (method) {
case 'search':
return await musicService.search(args)
case 'getSongDetail':
return await musicService.getSongDetail(args)
case 'getSongUrl':
return await musicService.getSongUrl(args)
case 'getLyric':
return await musicService.getLyric(args)
default:
throw new Error(`未知的方法: ${method}`)
}
} catch (error) {
console.error(`请求失败: ${method}`, error)
throw error
}
}
// 使用示例
request('search', '周杰伦', 1, 20).then((result) => {
console.log('搜索结果:', result)
})
```
### 状态管理设计 (Pinia + LocalStorage)
```typescript
// stores/music.ts
import { defineStore } from 'pinia'
export const useMusicStore = defineStore('music', {
state: () => ({
// 当前播放歌曲
currentSong: null as Song | null,
// 播放列表
playlist: [] as Song[],
// 播放状态
isPlaying: false,
// 播放模式 (顺序、随机、单曲循环)
playMode: 'order' as 'order' | 'random' | 'repeat',
// 音量
volume: 0.8,
// 播放进度
currentTime: 0,
duration: 0
}),
actions: {
// 播放歌曲
async playSong(song: Song) {
this.currentSong = song
this.isPlaying = true
this.saveToStorage()
},
// 添加到播放列表
addToPlaylist(songs: Song[]) {
this.playlist.push(...songs)
this.saveToStorage()
},
// 保存到本地存储
saveToStorage() {
localStorage.setItem(
'music-state',
JSON.stringify({
currentSong: this.currentSong,
playlist: this.playlist,
playMode: this.playMode,
volume: this.volume
})
)
},
// 从本地存储恢复
loadFromStorage() {
const saved = localStorage.getItem('music-state')
if (saved) {
const state = JSON.parse(saved)
Object.assign(this, state)
}
}
}
})
```
### 虚拟滚动列表设计
使用 TDesign 的虚拟滚动组件展示大量歌曲数据:
```vue
<template>
<t-virtual-scroll :data="songList" :height="600" :item-height="60" :buffer="10">
<template #default="{ data: song, index }">
<div class="song-item" @click="playSong(song)">
<div class="song-cover">
<img :src="song.pic" :alt="song.name" />
</div>
<div class="song-info">
<div class="song-name">{{ song.name }}</div>
<div class="song-artist">{{ song.artist }}</div>
</div>
<div class="song-duration">{{ formatTime(song.duration) }}</div>
</div>
</template>
</t-virtual-scroll>
</template>
```
### 本地数据存储设计
#### 播放列表存储
```typescript
// 方案1: LocalStorage (简单方案)
class PlaylistStorage {
private key = 'ceru-playlists'
save(playlists: Playlist[]) {
localStorage.setItem(this.key, JSON.stringify(playlists))
}
load(): Playlist[] {
const data = localStorage.getItem(this.key)
return data ? JSON.parse(data) : []
}
}
// 方案2: Node.js 文件存储 (最优方案,支持分享)
class FileStorage {
private filePath = path.join(app.getPath('userData'), 'playlists.json')
async save(playlists: Playlist[]) {
await fs.writeFile(this.filePath, JSON.stringify(playlists, null, 2))
}
async load(): Promise<Playlist[]> {
try {
const data = await fs.readFile(this.filePath, 'utf-8')
return JSON.parse(data)
} catch {
return []
}
}
// 导出播放列表
async export(playlist: Playlist, exportPath: string) {
await fs.writeFile(exportPath, JSON.stringify(playlist, null, 2))
}
// 导入播放列表
async import(importPath: string): Promise<Playlist> {
const data = await fs.readFile(importPath, 'utf-8')
return JSON.parse(data)
}
}
```
## 用户体验设计
### 首次启动流程
```typescript
// stores/app.ts
export const useAppStore = defineStore('app', {
state: () => ({
isFirstLaunch: true,
hasCompletedWelcome: false,
userPreferences: {
theme: 'auto' as 'light' | 'dark' | 'auto',
language: 'zh-CN',
defaultMusicSource: 'netease',
autoPlay: false
}
}),
actions: {
checkFirstLaunch() {
const hasLaunched = localStorage.getItem('has-launched')
this.isFirstLaunch = !hasLaunched
if (this.isFirstLaunch) {
// 跳转到欢迎页面
router.push('/welcome')
} else {
// 加载用户配置
this.loadUserPreferences()
router.push('/home')
}
},
completeWelcome(preferences?: Partial<UserPreferences>) {
if (preferences) {
Object.assign(this.userPreferences, preferences)
}
this.hasCompletedWelcome = true
localStorage.setItem('has-launched', 'true')
localStorage.setItem('user-preferences', JSON.stringify(this.userPreferences))
router.push('/home')
}
}
})
```
### 欢迎页面设计
![image-20250813180856660](..\assets\image-20250813180856660.png)
```vue
<template>
<div class="welcome-container">
<t-steps :current="currentStep" class="welcome-steps">
<t-step title="欢迎使用" content="欢迎使用 Ceru Music" />
<t-step title="基础设置" content="配置您的偏好设置" />
<t-step title="完成设置" content="开始您的音乐之旅" />
</t-steps>
<transition name="slide" mode="out-in">
<component :is="currentStepComponent" @next="nextStep" @skip="skipWelcome" />
</transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import WelcomeStep1 from './steps/WelcomeStep1.vue'
import WelcomeStep2 from './steps/WelcomeStep2.vue'
import WelcomeStep3 from './steps/WelcomeStep3.vue'
const currentStep = ref(0)
const steps = [WelcomeStep1, WelcomeStep2, WelcomeStep3]
const currentStepComponent = computed(() => steps[currentStep.value])
function nextStep() {
if (currentStep.value < steps.length - 1) {
currentStep.value++
} else {
completeWelcome()
}
}
function skipWelcome() {
appStore.completeWelcome()
}
</script>
<style scoped>
.slide-enter-active,
.slide-leave-active {
transition: all 0.3s ease;
}
.slide-enter-from {
opacity: 0;
transform: translateX(30px);
}
.slide-leave-to {
opacity: 0;
transform: translateX(-30px);
}
</style>
```
##### 界面UI参考
![..\assets\image-20250813180944752.png)
## 页面动画设计
### 路由过渡动画
```vue
<template>
<router-view v-slot="{ Component, route }">
<transition :name="getTransitionName(route)" mode="out-in">
<component :is="Component" :key="route.path" />
</transition>
</router-view>
</template>
<script setup lang="ts">
function getTransitionName(route: any) {
// 根据路由层级决定动画方向
const depth = route.path.split('/').length
return depth > 2 ? 'slide-left' : 'slide-right'
}
</script>
<style>
/* 滑动动画 */
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.slide-left-enter-from {
opacity: 0;
transform: translateX(100%);
}
.slide-left-leave-to {
opacity: 0;
transform: translateX(-100%);
}
.slide-right-enter-from {
opacity: 0;
transform: translateX(-100%);
}
.slide-right-leave-to {
opacity: 0;
transform: translateX(100%);
}
/* 淡入淡出动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
```
## 核心组件设计
### 音乐播放器组件
```vue
<template>
<div class="music-player">
<div class="player-info">
<img :src="currentSong?.pic" class="song-cover" />
<div class="song-details">
<div class="song-name">{{ currentSong?.name }}</div>
<div class="song-artist">{{ currentSong?.artist }}</div>
</div>
</div>
<div class="player-controls">
<t-button variant="text" @click="previousSong">
<t-icon name="skip-previous" />
</t-button>
<t-button :variant="isPlaying ? 'filled' : 'outline'" @click="togglePlay">
<t-icon :name="isPlaying ? 'pause' : 'play'" />
</t-button>
<t-button variant="text" @click="nextSong">
<t-icon name="skip-next" />
</t-button>
</div>
<div class="player-progress">
<span class="time-current">{{ formatTime(currentTime) }}</span>
<t-slider v-model="progress" :max="duration" @change="seekTo" class="progress-slider" />
<span class="time-duration">{{ formatTime(duration) }}</span>
</div>
</div>
</template>
```
## 开发规范
### 代码规范
- 使用 TypeScript 进行类型检查
- 遵循 ESLint 配置的代码规范
- 使用 Prettier 进行代码格式化
- 组件命名使用 PascalCase
- 文件命名使用 kebab-case
### Git 提交规范
```
feat: 新功能
fix: 修复bug
docs: 文档更新
style: 代码格式调整
refactor: 代码重构
test: 测试相关
chore: 构建过程或辅助工具的变动
```
### 性能优化
- 使用虚拟滚动处理大列表
- 图片懒加载
- 组件按需加载
- 音频预加载和缓存
- 防抖和节流优化用户交互
## 待补充功能
1. **歌词显示**: 滚动歌词、逐字高亮
2. **音效处理**: 均衡器、音效增强
3. **主题系统**: 多主题切换、自定义主题
4. **快捷键**: 全局快捷键支持
5. **系统集成**: 媒体键支持、系统通知
6. **云同步**: 播放列表云端同步
7. **插件系统**: 支持第三方插件扩展
8. **音乐推荐**: 基于听歌历史的智能推荐
---
_本设计文档将随着项目开发进度持续更新和完善。_

16
docs/guide/sponsorship.md Normal file
View File

@@ -0,0 +1,16 @@
# 赞助名单
## 鸣谢
| 昵称 | 赞助金额 |
| :-------------------------: | :------: |
| **群友**:可惜情绪落泪零碎 | 6.66 |
| **群友**:🍀 | 5 |
| **群友**:涟漪 | 50 |
| **作者朋友** | 188 |
| **群友**:我叫阿狸 | 3 |
| RiseSun | 9.9 |
| **b站小友**:光牙阿普斯木兰 | 5 |
| 青禾 | 8.8 |
据不完全统计 如有疏漏可联系sqj@shiqianjiang.cn

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

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

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

@@ -0,0 +1,106 @@
# 澜音版本更新日志
## 日志
- ###### 2025-10-7 (v1.4.0)
1. 优化搜索联想功能
支持
- 歌单
- 专辑
- 歌手
- 单曲名
2. 设置功能
- 性能优化设置
- 歌词弹簧跳动 开关
- 背景布朗运动 开关
- 音频可视化 开关
- 网络负载优化设置
- 存储设置 -> 缓存可以设置是否开启
优化网络差情况歌曲加载卡顿
3. 新增播放列表 **tag** 动画
4. **debug**
- 修复 2 条 接口失效无法获取搜索联想建议
- SMTC: 如果歌曲是播放状态时 切换到其他页面导致组件注销后 歌曲确实在播放是正常的可是切换回来时 能暂停 但是图标马上变为播放图标 后续无法播放的问题
- 去除播放歌曲多余提醒
- ###### 2025-10-6 (v1.3.13)
1. 添加搜索联想功能
2. debug: 某云歌单导入 限制1000问题
- ###### 2025-10-3 (v1.3.12)
1. 支持暗黑主题
2. 调整插件页面ui
- ###### 2025-9-29 (v1.3.11)
1. 新增插件在线导入
- ###### 2025-9-28 (v1.3.10)
1. 优化播放列表
2. 单击播放
3. 右键菜单
4. 调整播放进度调粗细
- ###### 2025-09-27 (v1.3.9)
1. debug:flac格式使用ffmpeg
2. 修复高音质下载失效
- ###### 2025-9-26 (v1.3.8)
1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
- ###### 2025-9-25 (v1.3.7)
1. 歌单
- 新增右键移除歌曲
- local 页歌单右键操作
- 歌单页支持修改封面
2. debug右键菜单二级菜单位置决策
- ###### 2025-9-22 (v1.3.6)
1. 歌单列表可以右键操作
- 播放
- 下载
- 添加到歌单
- 添加到播放列表
2. 播放列表滚动条
3. 搜索页切换源重新加载
- ###### 2025-9-22 (v1.3.5)
1. 软件启动位置 宽高记忆 限制软件最大宽高
2. debug: 修复歌曲音质支持短缺问题
- ###### 2025-9-21 (v1.3.4)
1. 紧急修复QQ音乐歌词失效问题
- ###### 2025-9-21(v1.3.3)
1. 兼容多平台歌单导入
2. 点击搜索框的 源图标实现快速切换
3. debug: fix:列表删除按钮冒泡
- ###### 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,26 @@
# 音乐播放列表-机制分析
## 基础使用
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: 50%;" />
## 歌曲列表的导出和分享
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

@@ -120,7 +120,7 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工
## 文档与资源
- [产品设计文档](./guide/design):涵盖项目架构、核心功能设计和开发规范(不含任何音乐数据源信息)。
- [产品设计文档](#):涵盖项目架构、核心功能设计和开发规范(不含任何音乐数据源信息)。
- [插件开发文档](./guide/CeruMusicPluginDev):仅提供插件开发技术规范,**明确要求插件开发者需遵守数据来源平台的用户协议与版权法**,禁止开发、传播获取非公开数据的插件。
## 开源许可
@@ -132,7 +132,7 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工
欢迎开发者贡献代码与反馈建议,贡献内容需符合以下要求:
1. 仅涉及播放器框架功能优化、bug 修复、文档完善,不包含任何音乐数据源相关代码。
2. 遵循 [Git 提交规范](./guide/design#git提交规范) 并确保代码符合项目风格指南。
2. 遵循 [Git 提交规范](#) 并确保代码符合项目风格指南。
3. 贡献的代码需无第三方版权纠纷,且不违反开源许可协议。
## 联系方式

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
}

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

@@ -0,0 +1,216 @@
# 插件通知系统使用说明
## 概述
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 类型定义
- ✨ 响应式设计和深色主题支持
- ✨ 完善的错误处理机制

View File

@@ -1,150 +0,0 @@
# WebDAV 同步配置指南
本项目包含两个 GitHub Actions 工作流,用于自动将 GitHub Releases 同步到 alistWebDAV 服务器)。
## 工作流说明
### 1. 手动同步工作流 (`sync-releases-to-webdav.yml`)
- **触发方式**: 手动触发 (workflow_dispatch)
- **功能**: 同步现有的所有版本或指定版本到 WebDAV
- **参数**:
- `tag_name`: 可选,指定要同步的版本标签(如 v1.0.0),留空则同步所有版本
### 2. 自动同步工作流 (集成在 `main.yml` 中)
- **触发方式**: 在 AutoBuild 完成后自动触发
- **功能**: 自动将新构建的版本同步到 WebDAV
- **参数**: 无需手动设置,自动获取发布信息
### 3. 独立自动同步工作流 (`auto-sync-release.yml`)
- **触发方式**: 当新版本发布时自动触发 (on release published)
- **功能**: 备用的自动同步机制
- **参数**: 无需手动设置,自动获取发布信息
## 配置要求
在 GitHub 仓库的 Settings > Secrets and variables > Actions 中添加以下密钥:
### 必需的 Secrets
1. **WEBDAV_BASE_URL**
- 描述: WebDAV 服务器的基础 URL
- 示例: `https://your-alist-domain.com/dav`
- 注意: 不要在末尾添加斜杠
2. **WEBDAV_USERNAME**
- 描述: WebDAV 服务器的用户名
- 示例: `admin`
3. **WEBDAV_PASSWORD**
- 描述: WebDAV 服务器的密码
- 示例: `your-password`
4. **GITHUB_TOKEN**
- 描述: GitHub 访问令牌(通常自动提供)
- 注意: 如果默认的 `GITHUB_TOKEN` 权限不足,可能需要创建个人访问令牌
## 使用方法
### 手动同步现有版本
1. 进入 GitHub 仓库的 Actions 页面
2. 选择 "Sync Existing Releases to WebDAV" 工作流
3. 点击 "Run workflow"
4. 可选择指定版本标签或留空同步所有版本
5. 点击 "Run workflow" 开始执行
### 自动同步新版本
现在有两种自动同步方式:
1. **集成同步** (推荐): 在主构建工作流 (`main.yml`) 中集成了 WebDAV 同步,当您推送 `v*` 标签时,会自动执行:
- 构建应用 → 创建 Release → 同步到 WebDAV
2. **独立同步**: 当您手动发布 Release 时,`auto-sync-release.yml` 工作流会自动触发
推荐使用集成同步方式,因为它确保了构建和同步的一致性。
## 文件结构
同步后的文件将按以下结构存储在 alist 中:
```
/yd/ceru/
├── v1.0.0/
│ ├── app-setup.exe
│ ├── app.dmg
│ └── app.AppImage
├── v1.1.0/
│ ├── app-setup.exe
│ ├── app.dmg
│ └── app.AppImage
└── ...
```
## 故障排除
### 常见问题
1. **上传失败**
- 检查 WebDAV 服务器是否正常运行
- 验证用户名和密码是否正确
- 确认 WebDAV URL 格式正确
2. **权限错误**
- 确保 WebDAV 用户有写入权限
- 检查目标目录是否存在且可写
3. **文件大小不匹配**
- 网络问题导致下载不完整
- GitHub API 限制或临时故障
4. **目录创建失败**
- WebDAV 服务器不支持 MKCOL 方法
- 权限不足或路径错误
### 调试步骤
1. 查看 Actions 运行日志
2. 检查 WebDAV 服务器日志
3. 验证所有 Secrets 配置正确
4. 测试 WebDAV 连接是否正常
## 安全注意事项
1. **密钥管理**
- 不要在代码中硬编码密码
- 定期更换 WebDAV 密码
- 使用强密码
2. **权限控制**
- 为 WebDAV 用户设置最小必要权限
- 考虑使用专用的同步账户
3. **网络安全**
- 建议使用 HTTPS 连接
- 考虑 IP 白名单限制
## 自定义配置
如需修改同步路径或其他配置,请编辑对应的工作流文件:
- 修改存储路径: 更改 `remote_path` 变量
- 调整重试逻辑: 修改错误处理部分
- 添加通知: 集成 Slack、邮件等通知服务
## 支持的文件类型
工作流支持同步所有类型的 Release 资源文件,包括但不限于:
- 可执行文件 (.exe, .dmg, .AppImage)
- 压缩包 (.zip, .tar.gz, .7z)
- 安装包 (.msi, .deb, .rpm)
- 其他二进制文件
## 版本兼容性
- GitHub Actions: 支持最新版本
- alist: 支持 WebDAV 协议的版本
- 操作系统: Ubuntu Latest (工作流运行环境)

View File

@@ -12,6 +12,7 @@ files:
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
- node_modules/ffmpeg-static/**
win:
executableName: ceru-music
icon: 'resources/icons/icon.ico'
@@ -20,18 +21,16 @@ win:
arch:
- x64
- ia32
# 简化版本信息设置避免rcedit错误
- target: zip
arch:
- x64
- ia32
fileAssociations:
- ext: cerumusic
name: CeruMusic File
description: CeruMusic playlist file
# 如果有证书文件,取消注释以下配置
# certificateFile: path/to/certificate.p12
# certificatePassword: your-password
# 或者使用证书存储
# certificateSubjectName: "Your Company Name"
nsis:
artifactName: ${name}-${version}-${arch}-setup.${ext}
artifactName: ${name}-${version}-win-${arch}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
@@ -43,23 +42,40 @@ nsis:
mac:
icon: 'resources/icons/icon.icns'
entitlementsInherit: build/entitlements.mac.plist
target:
- target: dmg
arch:
- universal
- target: zip
arch:
- universal
extendInfo:
- NSDocumentsFolderUsageDescription: 需要访问文档文件夹来保存和打开您创建的文件。
- NSDownloadsFolderUsageDescription: 需要访问下载文件夹来管理您下载的内容
- NSDownloadsFolderUsageDescription: 需要访问下载文件夹来管理您下载的歌曲
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
artifactName: ${name}-${version}-${arch}.${ext}
title: ${productName}
linux:
icon: 'resources/icons'
target:
- AppImage
- snap
- deb
- target: AppImage
arch:
- x64
- target: snap
arch:
- x64
- target: deb
arch:
- x64
maintainer: electronjs.org
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
artifactName: ${name}-${version}-linux-${arch}.${ext}
snap:
artifactName: ${name}-${version}-linux-${arch}.${ext}
deb:
artifactName: ${name}-${version}-linux-${arch}.${ext}
npmRebuild: false
publish:
provider: generic

View File

@@ -6,6 +6,7 @@ import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { TDesignResolver } from '@tdesign-vue-next/auto-import-resolver'
import wasm from 'vite-plugin-wasm'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import topLevelAwait from 'vite-plugin-top-level-await'
export default defineConfig({
@@ -36,14 +37,23 @@ export default defineConfig({
TDesignResolver({
library: 'vue-next'
})
]
],
imports: [
'vue',
{
'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar']
}
],
dts: true
}),
Components({
resolvers: [
TDesignResolver({
library: 'vue-next'
})
]
}),
NaiveUiResolver()
],
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.2.8",
"version": "1.4.0",
"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,9 +18,12 @@
"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 --config --publish never",
"build:win": "yarn run build && electron-builder --win --x64 --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:mac:intel": "yarn run build && electron-builder --mac --x64 --config --publish never",
"build:mac:arm64": "yarn run build && electron-builder --mac --arm64 --config --publish never",
"build:mac:universal": "yarn run build && electron-builder --mac --universal --config --publish never",
"build:linux": "yarn run build && electron-builder --linux --config --publish never",
"build:deps": "electron-builder install-app-deps && yarn run build && electron-builder --win --x64 --config",
"buildico": "electron-icon-builder --input=./resources/logo.png --output=resources --flatten",
@@ -44,6 +47,7 @@
"@pixi/filter-bulge-pinch": "^5.1.1",
"@pixi/filter-color-matrix": "^7.4.3",
"@pixi/sprite": "^7.4.3",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/needle": "^3.3.0",
"NeteaseCloudMusicApi": "^4.27.0",
"animate.css": "^4.1.1",
@@ -53,16 +57,22 @@
"dompurify": "^3.2.6",
"electron-log": "^5.4.3",
"electron-updater": "^6.3.9",
"ffmpeg-static": "^5.2.0",
"fluent-ffmpeg": "^2.1.3",
"hpagent": "^1.2.0",
"iconv-lite": "^0.7.0",
"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",
"node-fetch": "2",
"node-id3": "^0.2.9",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0",
"tdesign-icons-vue-next": "^0.4.1",
"tdesign-vue-next": "^1.15.2",
"vue-router": "^4.5.1",
"zlib": "^1.0.5"
@@ -75,15 +85,18 @@
"@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",
"@vueuse/core": "^13.9.0",
"electron": "^38.1.0",
"electron-builder": "^25.1.8",
"electron-icon-builder": "^2.0.1",
"electron-vite": "^4.0.0",
"eslint": "^9.31.0",
"eslint-plugin-vue": "^10.3.0",
"naive-ui": "^2.43.1",
"prettier": "^3.6.2",
"sass-embedded": "^1.90.0",
"scss": "^0.2.4",
@@ -95,7 +108,7 @@
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-vue-devtools": "^8.0.0",
"vite-plugin-wasm": "^3.5.0",
"vitepress": "^2.0.0-alpha.12",
"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

72090
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

79
scripts/genAst.js Normal file
View File

@@ -0,0 +1,79 @@
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)

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

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

@@ -22,7 +22,7 @@ const UPDATE_API_URL = `${UPDATE_SERVER}/update/${process.platform}/${app.getVer
// Alist API 配置
const ALIST_BASE_URL = 'https://alist.shiqianjiang.cn' // 请替换为实际的 alist 域名
const ALIST_USERNAME = 'ceruupdate'
const ALIST_PASSWORD = '123456'
const ALIST_PASSWORD = '123456' //登录公开的账号密码
// Alist 认证 token
let alistToken: string | null = null

View File

@@ -0,0 +1,157 @@
import { ipcMain, dialog } from 'electron'
import { configManager } from '../services/ConfigManager'
// 获取当前目录配置
ipcMain.handle('directory-settings:get-directories', async () => {
try {
const directories = configManager.getDirectories()
// 确保目录存在
await configManager.ensureDirectoryExists(directories.cacheDir)
await configManager.ensureDirectoryExists(directories.downloadDir)
return directories
} catch (error) {
console.error('获取目录配置失败:', error)
return configManager.getDirectories() // 返回默认配置
}
})
// 选择缓存目录
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 configManager.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 configManager.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 success = await configManager.saveDirectories(directories)
return { success, message: success ? '目录配置已保存' : '保存配置失败' }
} catch (error) {
console.error('保存目录配置失败:', error)
return { success: false, message: '保存配置失败' }
}
})
// 重置为默认目录
ipcMain.handle('directory-settings:reset-directories', async () => {
try {
// 重置目录配置
configManager.delete('cacheDir')
configManager.delete('downloadDir')
configManager.saveConfig()
// 获取默认目录
const directories = configManager.getDirectories()
// 确保默认目录存在
await configManager.ensureDirectoryExists(directories.cacheDir)
await configManager.ensureDirectoryExists(directories.downloadDir)
return { success: true, directories }
} 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 fs = require('fs')
const { join } = require('path')
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,18 +14,21 @@ 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)
}
}

View File

@@ -1,13 +1,12 @@
import { app, shell, BrowserWindow, ipcMain, Tray, Menu } from 'electron'
import { app, shell, BrowserWindow, ipcMain, Tray, Menu, screen } from 'electron'
import { configManager } from './services/ConfigManager'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/logo.png?asset'
import path from 'node:path'
import musicService from './services/music'
import pluginService from './services/plugin'
import aiEvents from './events/ai'
import './services/musicSdk/index'
// 获取单实例锁
const gotTheLock = app.requestSingleInstanceLock()
@@ -90,20 +89,27 @@ function createTray(): void {
function createWindow(): void {
// return
// Create the browser window.
mainWindow = new BrowserWindow({
// 获取保存的窗口位置和大小
const savedBounds = configManager.getWindowBounds()
// 获取屏幕尺寸
const primaryDisplay = screen.getPrimaryDisplay()
// 使用完整屏幕尺寸而不是工作区域,以支持真正的全屏模式
const { width: screenWidth, height: screenHeight } = primaryDisplay.size
// 默认窗口配置
const defaultOptions = {
width: 1100,
height: 750,
minWidth: 1100,
minHeight: 670,
maxWidth: screenWidth,
maxHeight: screenHeight,
show: false,
center: true,
center: !savedBounds, // 如果有保存的位置,则不居中
autoHideMenuBar: true,
// alwaysOnTop: true,
// 移除最大宽高限制,以便全屏模式能够铺满整个屏幕
titleBarStyle: 'hidden',
titleBarStyle: 'hidden' as const,
...(process.platform === 'linux' ? { icon } : {}),
// ...(process.platform !== 'darwin' ? { titleBarOverlay: true } : {}),
icon: path.join(__dirname, '../../resources/logo.ico'),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
@@ -113,9 +119,57 @@ function createWindow(): void {
contextIsolation: false,
backgroundThrottling: false
}
})
}
// 如果有保存的窗口位置和大小,则使用保存的值
if (savedBounds) {
Object.assign(defaultOptions, savedBounds)
}
// Create the browser window.
mainWindow = new BrowserWindow(defaultOptions)
if (process.platform == 'darwin') mainWindow.setWindowButtonVisibility(false)
// 监听窗口移动和调整大小事件,保存窗口位置和大小
mainWindow.on('moved', () => {
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
const bounds = mainWindow.getBounds()
configManager.saveWindowBounds(bounds)
}
})
mainWindow.on('resized', () => {
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
const bounds = mainWindow.getBounds()
// 获取当前屏幕尺寸
const { screen } = require('electron')
const currentDisplay = screen.getDisplayMatching(bounds)
const { width: screenWidth, height: screenHeight } = currentDisplay.workAreaSize
// 确保窗口不超过屏幕尺寸
let needResize = false
const newBounds = { ...bounds }
if (bounds.width > screenWidth) {
newBounds.width = screenWidth
needResize = true
}
if (bounds.height > screenHeight) {
newBounds.height = screenHeight
needResize = true
}
// 如果需要调整大小,应用新的尺寸
if (needResize) {
mainWindow.setBounds(newBounds)
}
configManager.saveWindowBounds(newBounds)
}
})
mainWindow.on('ready-to-show', () => {
mainWindow?.show()
})
@@ -160,6 +214,15 @@ ipcMain.handle('service-plugin-selectAndAddPlugin', async (_, type): Promise<any
}
})
ipcMain.handle('service-plugin-downloadAndAddPlugin', async (_, url, type): Promise<any> => {
try {
return await pluginService.downloadAndAddPlugin(url, type)
} catch (error: any) {
console.error('Error downloading and adding plugin:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-addPlugin', async (_, pluginCode, pluginName): Promise<any> => {
try {
return await pluginService.addPlugin(pluginCode, pluginName)
@@ -206,10 +269,6 @@ ipcMain.handle('service-plugin-uninstallPlugin', async (_, pluginId): Promise<an
}
})
ipcMain.handle('service-music-request', async (_, api, args) => {
return await musicService.request(api, args)
})
// 获取应用版本号
ipcMain.handle('get-app-version', () => {
return app.getVersion()
@@ -218,15 +277,23 @@ 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 () => {
// 初始化插件系统

View File

@@ -0,0 +1,162 @@
import { 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'
// 配置管理器类
export class ConfigManager {
private static instance: ConfigManager
private configPath: string
private config: Record<string, any> = {}
private constructor() {
this.configPath = join(app.getPath('userData'), CONFIG_NAME)
this.loadConfig()
}
// 单例模式获取实例
public static getInstance(): ConfigManager {
if (!ConfigManager.instance) {
ConfigManager.instance = new ConfigManager()
}
return ConfigManager.instance
}
// 加载配置
private loadConfig(): void {
try {
if (fs.existsSync(this.configPath)) {
const configData = fs.readFileSync(this.configPath, 'utf-8')
this.config = JSON.parse(configData)
}
} catch (error) {
console.error('加载配置失败:', error)
this.config = {}
}
}
// 保存配置
public saveConfig(): boolean {
try {
fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2))
return true
} catch (error) {
console.error('保存配置失败:', error)
return false
}
}
// 获取配置项
public get<T>(key: string, defaultValue?: T): T {
const value = this.config[key]
return value !== undefined ? value : (defaultValue as T)
}
// 设置配置项
public set<T>(key: string, value: T): void {
this.config[key] = value
}
// 删除配置项
public delete(key: string): void {
delete this.config[key]
}
// 重置所有配置
public reset(): void {
this.config = {}
this.saveConfig()
}
// 获取所有配置
public getAll(): Record<string, any> {
return { ...this.config }
}
// 确保目录存在
public async ensureDirectoryExists(dirPath: string): Promise<void> {
try {
await access(dirPath)
} catch {
await mkdir(dirPath, { recursive: true })
}
}
// 获取目录配置
public getDirectories() {
const userDataPath = app.getPath('userData')
const defaults = {
cacheDir: join(userDataPath, 'music-cache'),
downloadDir: join(app.getPath('music'), 'CeruMusic/songs')
}
return {
cacheDir: this.get('cacheDir', defaults.cacheDir),
downloadDir: this.get('downloadDir', defaults.downloadDir)
}
}
// 保存目录配置
public async saveDirectories(directories: {
cacheDir: string
downloadDir: string
}): Promise<boolean> {
try {
await this.ensureDirectoryExists(directories.cacheDir)
await this.ensureDirectoryExists(directories.downloadDir)
this.set('cacheDir', directories.cacheDir)
this.set('downloadDir', directories.downloadDir)
return this.saveConfig()
} catch (error) {
console.error('保存目录配置失败:', error)
return false
}
}
// 保存窗口位置和大小
public saveWindowBounds(bounds: { x: number; y: number; width: number; height: number }): void {
this.set('windowBounds', bounds)
this.saveConfig()
}
// 获取窗口位置和大小,确保窗口完全在屏幕内
public getWindowBounds(): { x: number; y: number; width: number; height: number } | null {
const bounds = this.get<{ x: number; y: number; width: number; height: number } | null>(
'windowBounds',
null
)
if (bounds) {
const { screen } = require('electron')
// 获取主显示器
const primaryDisplay = screen.getPrimaryDisplay()
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
// 确保窗口在屏幕内
if (bounds.x < 0) bounds.x = 0
if (bounds.y < 0) bounds.y = 0
// 确保窗口右侧不超出屏幕
if (bounds.x + bounds.width > screenWidth) {
bounds.x = Math.max(0, screenWidth - bounds.width)
}
// 确保窗口底部不超出屏幕
if (bounds.y + bounds.height > screenHeight) {
bounds.y = Math.max(0, screenHeight - bounds.height)
}
}
return bounds
}
}
// 导出单例实例
export const configManager = ConfigManager.getInstance()

View File

@@ -1,77 +0,0 @@
import { MusicServiceBase, ServiceNamesType, ServiceArgsType } from './service-base'
import {
GetToplistArgs,
SearchArgs,
GetLyricArgs,
GetSongDetailArgs,
GetSongUrlArgs,
GetToplistDetailArgs,
GetListSongsArgs,
DownloadSingleSongArgs
} from './service-base'
import { netEaseService } from './net-ease-service'
import { AxiosError } from 'axios'
const musicService: MusicServiceBase = netEaseService
type Response = {
success: boolean
data?: any
error?: any
}
async function request(api: ServiceNamesType, args: ServiceArgsType): Promise<any> {
const res: Response = { success: false }
try {
switch (api) {
case 'search':
res.data = await musicService.search(args as SearchArgs)
break
case 'getSongDetail':
res.data = await musicService.getSongDetail(args as GetSongDetailArgs)
break
case 'getSongUrl':
res.data = await musicService.getSongUrl(args as GetSongUrlArgs)
break
case 'getLyric':
res.data = await musicService.getLyric(args as GetLyricArgs)
break
case 'getToplist':
res.data = await musicService.getToplist(args as GetToplistArgs)
break
case 'getToplistDetail':
res.data = await musicService.getToplistDetail(args as GetToplistDetailArgs)
break
case 'getListSongs':
res.data = await musicService.getListSongs(args as GetListSongsArgs)
break
case 'downloadSingleSong':
res.data = await musicService.downloadSingleSong(args as DownloadSingleSongArgs)
break
default:
throw new Error(`未知的方法: ${api}`)
}
res.success = true
} catch (error: any) {
if (error instanceof AxiosError) {
error.message = '网络错误'
}
console.error('请求失败: ', error)
res.error = error
}
return res
}
export default { request }
// netEaseService
// .search({
// keyword: '稻香',
// type: 1,
// limit: 25
// })
// .then((res) => {
// console.log(res)
// })

View File

@@ -1,398 +0,0 @@
import path from 'path'
import fs from 'fs'
import fsPromise from 'fs/promises'
import NeteaseCloudMusicApi from 'NeteaseCloudMusicApi'
import { axiosClient, MusicServiceBase } from './service-base'
import { pipeline } from 'node:stream/promises'
import pluginService from '../plugin'
import musicSdk from '../../utils/musicSdk'
import {
SearchArgs,
GetSongDetailArgs,
GetSongUrlArgs,
GetToplistDetailArgs,
GetListSongsArgs,
GetLyricArgs,
GetToplistArgs,
DownloadSingleSongArgs
} from './service-base'
import { SongDetailResponse, SongResponse } from './service-base'
import { fieldsSelector } from '../../utils/object'
import { getAppDirPath } from '../../utils/path'
// 音乐源映射
const MUSIC_SOURCES = {
kg: 'kg', // 酷狗音乐
wy: 'wy', // 网易云音乐
tx: 'tx', // QQ音乐
kw: 'kw', // 酷我音乐
mg: 'mg' // 咪咕音乐
}
// 扩展搜索参数接口
interface ExtendedSearchArgs extends SearchArgs {
source?: string // 音乐源参数 kg|wy|tx|kw|mg
}
// 扩展歌曲详情参数接口
interface ExtendedGetSongDetailArgs extends GetSongDetailArgs {
source?: string
}
// 扩展歌词参数接口
interface ExtendedGetLyricArgs extends GetLyricArgs {
source?: string
}
const baseUrl: string = 'https://music.163.com'
const baseTwoUrl: string = 'https://www.lihouse.xyz/coco_widget'
const fileLock: Record<string, boolean> = {}
/**
* 获取支持的音乐源列表
*/
export const getSupportedSources = () => {
return Object.keys(MUSIC_SOURCES).map((key) => ({
id: key,
name: getSourceName(key),
available: !!musicSdk[key]
}))
}
/**
* 获取音乐源名称
*/
const getSourceName = (source: string): string => {
const sourceNames = {
kg: '酷狗音乐',
wy: '网易云音乐',
tx: 'QQ音乐',
kw: '酷我音乐',
mg: '咪咕音乐'
}
return sourceNames[source] || source
}
/**
* 智能音乐匹配使用musicSdk的findMusic功能
*/
export const findMusic = async (musicInfo: {
name: string
singer?: string
albumName?: string
interval?: string
source?: string
}) => {
try {
return await musicSdk.findMusic(musicInfo)
} catch (error) {
console.error('智能音乐匹配失败:', error)
return []
}
}
export const netEaseService: MusicServiceBase = {
async search({
type,
keyword,
offset,
limit,
source
}: ExtendedSearchArgs): Promise<SongResponse> {
// 如果指定了音乐源且不是网易云使用对应的musicSdk
if (source && source !== 'wy' && MUSIC_SOURCES[source]) {
try {
const sourceModule = musicSdk[source]
if (sourceModule && sourceModule.musicSearch) {
const page = Math.floor((offset || 0) / (limit || 25)) + 1
const result = await sourceModule.musicSearch.search(keyword, page, limit || 25)
// 转换为统一格式
return {
songs: result.list || [],
songCount: result.total || result.list?.length || 0
}
} else {
throw new Error(`不支持的音乐源: ${source}`)
}
} catch (error: any) {
console.error(`${source}音乐源搜索失败:`, error)
// 如果指定源失败,回退到网易云
console.log('回退到网易云音乐搜索')
}
}
// 默认使用网易云音乐搜索
return await axiosClient
.get(`${baseUrl}/api/search/get/web`, {
params: {
s: keyword,
type: type,
limit: limit,
offset: offset ?? 0
}
})
.then(({ data }) => {
if (data.code !== 200) {
console.error(data)
throw new Error(data.msg)
}
return data.result
})
},
async getSongDetail({ ids, source }: ExtendedGetSongDetailArgs): Promise<SongDetailResponse> {
// 如果指定了音乐源且不是网易云使用对应的musicSdk
if (source && source !== 'wy' && MUSIC_SOURCES[source]) {
try {
const sourceModule = musicSdk[source]
if (sourceModule && sourceModule.musicInfo) {
// 对于多个ID并行获取详情
const promises = ids.map((id) => sourceModule.musicInfo.getMusicInfo(id))
const results = await Promise.all(promises)
return results.filter((result: any) => result) // 过滤掉失败的结果
} else {
throw new Error(`不支持的音乐源: ${source}`)
}
} catch (error: any) {
console.error(`${source}音乐源获取歌曲详情失败:`, error)
// 如果指定源失败,回退到网易云
console.log('回退到网易云音乐获取歌曲详情')
}
}
// 默认使用网易云音乐
return await axiosClient
.get(`${baseUrl}/api/song/detail?ids=[${ids.join(',')}]`)
.then(({ data }) => {
if (data.code !== 200) {
console.error(data)
throw new Error(data.msg)
}
return data.songs
})
},
async getSongUrl({ id, pluginId, quality, source }: GetSongUrlArgs): Promise<any> {
// 如果提供了插件ID、音质和音乐源则使用插件获取音乐URL
if (pluginId && (quality || source)) {
try {
// 获取插件实例
const plugin = pluginService.getPluginById(pluginId)
if (!plugin) {
throw new Error(`未找到ID为 ${pluginId} 的插件`)
}
// 准备音乐信息对象确保符合MusicInfo类型要求
const musicInfo = {
songmid: id as unknown as number,
singer: '',
name: '',
albumName: '',
albumId: 0,
source: source || 'wy',
interval: '',
img: '',
lrc: null,
types: [],
_types: {},
typeUrl: {}
}
// 调用插件的getMusicUrl方法获取音乐URL
const url: string = await plugin.getMusicUrl(
source || 'wy',
musicInfo,
quality || 'standard'
)
// 构建返回对象
return { url }
} catch (error: any) {
console.error('通过插件获取音乐URL失败:', error)
throw new Error(`插件获取音乐URL失败: ${error.message}`)
}
}
// 如果没有提供插件信息或插件调用失败,则使用默认方法获取
return await axiosClient.get(`${baseTwoUrl}/music_resource/id/${id}`).then(({ data }) => {
if (!data.status) {
throw new Error('歌曲不存在')
}
return data.song_data
})
},
async getLyric({ id, lv, yv, tv, source }: ExtendedGetLyricArgs): Promise<any> {
// 如果指定了音乐源且不是网易云使用对应的musicSdk
if (source && source !== 'wy' && MUSIC_SOURCES[source]) {
try {
const sourceModule = musicSdk[source]
if (sourceModule && sourceModule.getLyric) {
// 构建歌曲信息对象,不同源可能需要不同的参数
const songInfo = { id, songmid: id, hash: id }
const result = await sourceModule.getLyric(songInfo)
// 转换为统一格式
return {
lrc: { lyric: result.lyric || '' },
tlyric: { lyric: result.tlyric || '' },
yrc: { lyric: result.yrc || '' }
}
} else {
throw new Error(`不支持的音乐源: ${source}`)
}
} catch (error: any) {
console.error(`${source}音乐源获取歌词失败:`, error)
// 如果指定源失败,回退到网易云
console.log('回退到网易云音乐获取歌词')
}
}
// 默认使用网易云音乐
const optionalParams: any = {}
if (lv) {
optionalParams.lv = -1
}
if (yv) {
optionalParams.yv = -1
}
if (tv) {
optionalParams.tv = -1
}
return await axiosClient
.get(`${baseUrl}/api/song/lyric`, {
params: {
id: id,
...optionalParams
}
})
.then(({ data }) => {
if (data.code !== 200) {
console.error(data)
throw Error(data.msg)
}
const requiredFields = ['lyricUser', 'lrc', 'tlyric', 'yrc']
return fieldsSelector(data, requiredFields)
})
},
async getToplist({}: GetToplistArgs): Promise<any> {
return await NeteaseCloudMusicApi.toplist({})
.then(({ body: data }) => {
return data.list
})
.catch((err: any) => {
console.error({
code: err.body?.code,
msg: err.body?.msg?.message
})
throw err.body?.msg ?? err
})
},
async getToplistDetail({}: GetToplistDetailArgs): Promise<any> {
return await NeteaseCloudMusicApi.toplist_detail({})
.then(({ body: data }) => {
return data.list
})
.catch((err: any) => {
console.error({
code: err.body?.code,
msg: err.body?.msg?.message
})
throw err.body?.msg ?? err
})
},
async getListSongs(args: GetListSongsArgs): Promise<any> {
return await NeteaseCloudMusicApi.playlist_track_all(args)
.then(({ body: data }) => {
const requiredFields = ['songs', 'privileges']
return fieldsSelector(data, requiredFields)
})
.catch((err: any) => {
console.error({
code: err.body?.code,
msg: err.body?.msg?.message
})
throw err.body?.msg ?? err
})
},
async downloadSingleSong({
id,
name,
artist,
pluginId,
source,
quality
}: DownloadSingleSongArgs) {
const { url } = await this.getSongUrl({ id, pluginId, source, quality })
// 从URL中提取文件扩展名如果没有则默认为mp3
const getFileExtension = (url: string): string => {
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname
const lastDotIndex = pathname.lastIndexOf('.')
if (lastDotIndex !== -1 && lastDotIndex < pathname.length - 1) {
const extension = pathname.substring(lastDotIndex + 1).toLowerCase()
// 验证是否为常见的音频格式
const validExtensions = ['mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'wma']
if (validExtensions.includes(extension)) {
return extension
}
}
} catch (error) {
console.warn('解析URL失败使用默认扩展名:', error)
}
return 'mp3' // 默认扩展名
}
const fileExtension = getFileExtension(url)
const songPath = path.join(
getAppDirPath(),
'download',
'songs',
`${name}-${artist}-${id}.${fileExtension}`
.replace(/[/\\:*?"<>|]/g, '')
.replace(/^\.+/, '')
.replace(/\.+$/, '')
.trim()
)
if (fileLock[songPath]) {
throw new Error('歌曲正在下载中')
} else {
fileLock[songPath] = true
}
try {
if (fs.existsSync(songPath)) {
return {
message: '歌曲已存在'
}
}
await fsPromise.mkdir(path.dirname(songPath), { recursive: true })
const songDataRes = await axiosClient({
method: 'GET',
url: url,
responseType: 'stream'
})
await pipeline(songDataRes.data, fs.createWriteStream(songPath))
} finally {
delete fileLock[songPath]
}
return {
message: '下载成功',
path: songPath
}
}
}

View File

@@ -1,276 +0,0 @@
import axios, { AxiosInstance } from 'axios'
const timeout: number = 5000
const mobileHeaders = {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148'
}
const axiosClient: AxiosInstance = axios.create({
timeout: timeout
})
type SearchArgs = {
type: number
keyword: string
offset?: number
limit: number
source?: string
}
type GetSongDetailArgs = {
ids: string[]
}
type GetSongUrlArgs = {
id: string
pluginId?: string // 插件ID
quality?: string // 音质
source?: string // 音乐源wy, tx等
}
type GetLyricArgs = {
id: string
lv?: boolean
yv?: boolean // 获取逐字歌词
tv?: boolean // 获取歌词翻译
}
type GetToplistArgs = Record<string, never>
type GetToplistDetailArgs = Record<string, never>
type GetListSongsArgs = {
id: string
limit?: number
offset?: number
}
type DownloadSingleSongArgs = {
id: string
name: string
artist: string
pluginId?: string
quality?: string
source?: string
}
type ServiceNamesType =
| 'search'
| 'getSongDetail'
| 'getSongUrl'
| 'getLyric'
| 'getToplist'
| 'getToplistDetail'
| 'getListSongs'
| 'downloadSingleSong'
type ServiceArgsType =
| SearchArgs
| GetSongDetailArgs
| GetSongUrlArgs
| GetLyricArgs
| GetToplistArgs
| GetToplistDetailArgs
| GetListSongsArgs
| DownloadSingleSongArgs
interface Artist {
id: number
name: string
picUrl: string | null
alias: string[]
albumSize: number
picId: number
fansGroup: null
img1v1Url: string
img1v1: number
trans: null
}
interface Album {
id: number
name: string
artist: {
id: number
name: string
picUrl: string | null
alias: string[]
albumSize: number
picId: number
fansGroup: null
img1v1Url: string
img1v1: number
trans: null
}
publishTime: number
size: number
copyrightId: number
status: number
picId: number
alia?: string[]
mark: number
}
interface Song {
id: number
name: string
artists: Artist[]
album: Album
duration: number
copyrightId: number
status: number
alias: string[]
rtype: number
ftype: number
mvid: number
fee: number
rUrl: null
mark: number
transNames?: string[]
}
interface SongResponse {
songs: Song[]
songCount: number
}
interface AlbumDetail {
name: string
id: number
type: string
size: number
picId: number
blurPicUrl: string
companyId: number
pic: number
picUrl: string
publishTime: number
description: string
tags: string
company: string
briefDesc: string
artist: {
name: string
id: number
picId: number
img1v1Id: number
briefDesc: string
picUrl: string
img1v1Url: string
albumSize: number
alias: string[]
trans: string
musicSize: number
topicPerson: number
}
songs: any[]
alias: string[]
status: number
copyrightId: number
commentThreadId: string
artists: Artist[]
subType: string
transName: null
onSale: boolean
mark: number
gapless: number
dolbyMark: number
}
interface MusicQuality {
name: null
id: number
size: number
extension: string
sr: number
dfsId: number
bitrate: number
playTime: number
volumeDelta: number
}
interface SongDetail {
name: string
id: number
position: number
alias: string[]
status: number
fee: number
copyrightId: number
disc: string
no: number
artists: Artist[]
album: AlbumDetail
starred: boolean
popularity: number
score: number
starredNum: number
duration: number
playedNum: number
dayPlays: number
hearTime: number
sqMusic: MusicQuality
hrMusic: null
ringtone: null
crbt: null
audition: null
copyFrom: string
commentThreadId: string
rtUrl: null
ftype: number
rtUrls: any[]
copyright: number
transName: null
sign: null
mark: number
originCoverType: number
originSongSimpleData: null
single: number
noCopyrightRcmd: null
hMusic: MusicQuality
mMusic: MusicQuality
lMusic: MusicQuality
bMusic: MusicQuality
mvid: number
mp3Url: null
rtype: number
rurl: null
}
interface SongDetailResponse {
songs: SongDetail[]
equalizers: Record<string, unknown>
code: number
}
interface SongUrlResponse {
id: number
url: string // 歌曲地址
name: string
artist: string
pic: string //封面图片
}
interface MusicServiceBase {
search({ type, keyword, offset, limit }: SearchArgs): Promise<SongResponse>
getSongDetail({ ids }: GetSongDetailArgs): Promise<SongDetailResponse>
getSongUrl({ id, pluginId, quality, source }: GetSongUrlArgs): Promise<SongUrlResponse>
getLyric({ id, lv, yv, tv }: GetLyricArgs): Promise<any>
getToplist({}: GetToplistArgs): Promise<any>
getToplistDetail({}: GetToplistDetailArgs): Promise<any>
getListSongs({ id, limit, offset }: GetListSongsArgs): Promise<any>
downloadSingleSong({ id }: DownloadSingleSongArgs): Promise<any>
}
export type { MusicServiceBase, ServiceNamesType, ServiceArgsType }
export type {
SearchArgs,
GetSongDetailArgs,
GetSongUrlArgs,
GetLyricArgs,
GetToplistArgs,
GetToplistDetailArgs,
GetListSongsArgs,
DownloadSingleSongArgs
}
export type { SongResponse, SongDetailResponse, SongUrlResponse }
export { mobileHeaders, axiosClient }

View File

@@ -1,20 +1,32 @@
import { app } from 'electron'
import * as path from 'path'
import * as fs from 'fs/promises'
import * as crypto from 'crypto'
import axios from 'axios'
import { configManager } from '../ConfigManager'
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 {
// 使用配置管理服务获取缓存目录
const directories = configManager.getDirectories()
return directories.cacheDir
}
// 动态获取缓存目录
public get cacheDir(): string {
return this.getCacheDirectory()
}
// 动态获取索引文件路径
public get indexFilePath(): string {
return path.join(this.cacheDir, 'cache-index.json')
}
private async initCache() {
try {
// 确保缓存目录存在
@@ -57,9 +69,9 @@ 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)) {
@@ -72,14 +84,29 @@ 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> {
@@ -130,43 +157,117 @@ 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'
@@ -176,10 +277,12 @@ 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)
}
}
}

View File

@@ -24,3 +24,15 @@ export function request<T extends keyof MainApi>(
}
}
ipcMain.handle('service-music-sdk-request', request)
// 处理搜索联想请求
ipcMain.handle('service-music-tip-search', async (_, source, keyword) => {
try {
if (!source) throw new Error('请配置音源')
const Api = main(source)
return await Api.tipSearch({ keyword })
} catch (error: any) {
console.error('搜索联想错误:', error)
return { result: { songs: [], order: ['songs'] } }
}
})

View File

@@ -7,20 +7,13 @@ import {
PlaylistResult,
GetSongListDetailsArg,
PlaylistDetailResult,
DownloadSingleSongArgs
DownloadSingleSongArgs,
TipSearchResult
} 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'
import fsPromise from 'fs/promises'
import axios from 'axios'
import { pipeline } from 'node:stream/promises'
import { fileURLToPath } from 'url'
const fileLock: Record<string, boolean> = {}
import download from '../../utils/downloadSongs'
function main(source: string) {
const Api = musicSdk[source]
@@ -29,26 +22,40 @@ function main(source: string) {
return (await Api.musicSearch.search(keyword, page, limit)) as Promise<SearchResult>
},
async getMusicUrl({ pluginId, songInfo, quality }: GetMusicUrlArg) {
async tipSearch({ keyword }: { keyword: string }) {
if (!Api.tipSearch?.search) {
// 如果音乐源没有实现tipSearch方法返回空结果
return [] as TipSearchResult
}
return (await Api.tipSearch.search(keyword)) as Promise<TipSearchResult>
},
async getMusicUrl({ pluginId, songInfo, quality, isCache }: GetMusicUrlArg) {
try {
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)
return cachedUrl
} catch (cacheError) {
console.warn('缓存获取失败使用原始URL:', cacheError)
const originalUrl = await originalUrlPromise
return originalUrl
// 先检查缓存isCache !== false 时)
if (isCache !== false) {
const cachedUrl = await musicCacheService.getCachedMusicUrl(songId)
if (cachedUrl) {
return cachedUrl
}
}
// 没有缓存时才发起网络请求
const originalUrl = await usePlugin.getMusicUrl(source, songInfo, quality)
// 按需异步缓存,不阻塞返回
if (isCache !== false) {
musicCacheService.cacheMusic(songId, originalUrl).catch((error) => {
console.warn('缓存歌曲失败:', error)
})
}
return originalUrl
} catch (e: any) {
return {
error: '获取歌曲失败 ' + e.error || e
@@ -82,83 +89,22 @@ 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
},
async downloadSingleSong({ pluginId, songInfo, quality }: DownloadSingleSongArgs) {
async downloadSingleSong({
pluginId,
songInfo,
quality,
tagWriteOptions
}: DownloadSingleSongArgs) {
const url = await this.getMusicUrl({ pluginId, songInfo, quality })
if (typeof url === 'object') throw new Error('无法获取歌曲链接')
// 从URL中提取文件扩展名如果没有则默认为mp3
const getFileExtension = (url: string): string => {
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname
const lastDotIndex = pathname.lastIndexOf('.')
if (lastDotIndex !== -1 && lastDotIndex < pathname.length - 1) {
const extension = pathname.substring(lastDotIndex + 1).toLowerCase()
// 验证是否为常见的音频格式
const validExtensions = ['mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'wma']
if (validExtensions.includes(extension)) {
return extension
}
}
} catch (error) {
console.warn('解析URL失败使用默认扩展名:', error)
}
return 'mp3' // 默认扩展名
}
const fileExtension = getFileExtension(url)
const songPath = path.join(
getAppDirPath('music'),
'CeruMusic',
'songs',
`${songInfo.name}-${songInfo.singer}-${source}.${fileExtension}`
.replace(/[/\\:*?"<>|]/g, '')
.replace(/^\.+/, '')
.replace(/\.+$/, '')
.trim()
)
if (fileLock[songPath]) {
throw new Error('歌曲正在下载中')
} else {
fileLock[songPath] = true
}
try {
if (fs.existsSync(songPath)) {
return {
message: '歌曲已存在'
}
}
await fsPromise.mkdir(path.dirname(songPath), { recursive: true })
if (url.startsWith('file://')) {
const filePath = fileURLToPath(url)
const readStream = fs.createReadStream(filePath)
const writeStream = fs.createWriteStream(songPath)
await pipeline(readStream, writeStream)
} else {
const songDataRes = await axios({
method: 'GET',
url: url,
responseType: 'stream'
})
await pipeline(songDataRes.data, fs.createWriteStream(songPath))
}
} finally {
delete fileLock[songPath]
}
return {
message: '下载成功',
path: songPath
}
return await download(url, songInfo, tagWriteOptions)
},
async parsePlaylistId({ url }: { url: string }) {

View File

@@ -39,6 +39,7 @@ export interface GetMusicUrlArg {
pluginId: string
songInfo: MusicItem
quality: string
isCache?: boolean
}
export interface GetMusicPicArg {
@@ -90,6 +91,16 @@ export interface PlaylistDetailResult {
info: PlaylistInfo
}
export interface TagWriteOptions {
basicInfo?: boolean
cover?: boolean
lyrics?: boolean
}
export interface DownloadSingleSongArgs extends GetMusicUrlArg {
path?: string
tagWriteOptions?: TagWriteOptions
}
// 搜索联想结果的类型定义
export type TipSearchResult = string[]

View File

@@ -4,6 +4,7 @@ import fsPromise from 'fs/promises'
import { randomUUID } from 'crypto'
import { dialog } from 'electron'
import { getAppDirPath } from '../../utils/path'
import axios from 'axios'
import CeruMusicPluginHost from './manager/CeruMusicPluginHost'
import convertEventDrivenPlugin from './manager/converter-event-driven'
@@ -223,6 +224,60 @@ const pluginService = {
})
},
async downloadAndAddPlugin(url: string, type: 'lx' | 'cr') {
try {
// 验证URL
if (!url || typeof url !== 'string') {
throw new Error('无效的URL地址')
}
// 下载文件
let pluginCode = await this.downloadFile(url)
// 生成临时文件名
const fileName = `downloaded_${Date.now()}.js`
if (type == 'lx') {
pluginCode = convertEventDrivenPlugin(pluginCode)
}
// 调用现有的添加插件方法
return await this.addPlugin(pluginCode, fileName)
} catch (error: any) {
console.error('下载并添加插件失败:', error)
return { error: error.message || '下载插件失败' }
}
},
async downloadFile(url: string): Promise<string> {
try {
const response = await axios.get(url, {
timeout: 30000, // 30秒超时
responseType: 'text',
headers: {
'User-Agent': 'CeruMusic/1.0'
}
})
if (response.status !== 200) {
throw new Error(`下载失败: HTTP ${response.status}`)
}
const data = response.data
if (!data || !data.trim()) {
throw new Error('下载的文件内容为空')
}
return data
} catch (error: any) {
if (error.response) {
throw new Error(`下载失败: HTTP ${error.response.status}`)
} else if (error.request) {
throw new Error('网络错误: 无法连接到服务器')
} else {
throw new Error(`下载错误: ${error.message}`)
}
}
},
async getPluginLog(pluginId: string) {
return await getLog(pluginId)
}

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,508 @@
import NodeID3 from 'node-id3'
import ffmpegStatic from 'ffmpeg-static'
import ffmpeg from 'fluent-ffmpeg'
import path from 'node:path'
import axios from 'axios'
import fs from 'fs'
import fsPromise from 'fs/promises'
import { configManager } from '../services/ConfigManager'
import { pipeline } from 'node:stream/promises'
import { fileURLToPath } from 'url'
const fileLock: Record<string, boolean> = {}
/**
* 转换LRC格式
* 将带有字符位置信息的LRC格式转换为标准的逐字时间戳格式
* @param lrcContent 原始LRC内容
* @returns 转换后的LRC内容
*/
function convertLrcFormat(lrcContent: string): string {
if (!lrcContent) return ''
const lines = lrcContent.split('\n')
const convertedLines: string[] = []
for (const line of lines) {
// 跳过空行
if (!line.trim()) {
convertedLines.push(line)
continue
}
// 检查是否是新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
const newFormatMatch = line.match(/^\[(\d+),(\d+)\](.*)$/)
if (newFormatMatch) {
const [, startTimeMs, , content] = newFormatMatch
const convertedLine = convertNewFormat(parseInt(startTimeMs), content)
convertedLines.push(convertedLine)
continue
}
// 检查是否是旧格式:[mm:ss.xxx]字符(偏移,持续时间)
const oldFormatMatch = line.match(/^\[(\d{2}:\d{2}\.\d{3})\](.*)$/)
if (oldFormatMatch) {
const [, timestamp, content] = oldFormatMatch
// 如果内容中没有位置信息,直接返回原行
if (!content.includes('(') || !content.includes(')')) {
convertedLines.push(line)
continue
}
const convertedLine = convertOldFormat(timestamp, content)
convertedLines.push(convertedLine)
continue
}
// 其他行直接保留
convertedLines.push(line)
}
return convertedLines.join('\n')
}
/**
* 将毫秒时间戳格式化为 mm:ss.xxx 格式
* @param timeMs 毫秒时间戳
* @returns 格式化的时间字符串
*/
function formatTimestamp(timeMs: number): string {
const minutes = Math.floor(timeMs / 60000)
const seconds = Math.floor((timeMs % 60000) / 1000)
const milliseconds = timeMs % 1000
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`
}
/**
* 转换新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
*/
function convertNewFormat(baseTimeMs: number, content: string): string {
const baseTimestamp = formatTimestamp(baseTimeMs)
let convertedContent = `<${baseTimestamp}>`
// 匹配模式:(开始时间,字符持续时间,0)字符
const charPattern = /\((\d+),(\d+),(\d+)\)([^(]*?)(?=\(|$)/g
let match
let isFirstChar = true
while ((match = charPattern.exec(content)) !== null) {
const [, charStartMs, , , char] = match
const charTimeMs = parseInt(charStartMs)
const charTimestamp = formatTimestamp(charTimeMs)
if (isFirstChar) {
// 第一个字符直接添加
convertedContent += char.trim()
isFirstChar = false
} else {
convertedContent += `<${charTimestamp}>${char.trim()}`
}
}
return `[${baseTimestamp}]${convertedContent}`
}
/**
* 转换旧格式:[mm:ss.xxx]字符(偏移,持续时间)
*/
function convertOldFormat(timestamp: string, content: string): string {
// 解析基础时间戳(毫秒)
const [minutes, seconds] = timestamp.split(':')
const [sec, ms] = seconds.split('.')
const baseTimeMs = parseInt(minutes) * 60 * 1000 + parseInt(sec) * 1000 + parseInt(ms)
let convertedContent = `<${timestamp}>`
// 匹配所有字符(偏移,持续时间)的模式
const charPattern = /([^()]+)\((\d+),(\d+)\)/g
let match
let lastIndex = 0
let isFirstChar = true
while ((match = charPattern.exec(content)) !== null) {
const [fullMatch, char, offsetMs, _durationMs] = match
const charTimeMs = baseTimeMs + parseInt(offsetMs)
const charTimestamp = formatTimestamp(charTimeMs)
// 添加匹配前的普通文本
if (match.index > lastIndex) {
const beforeText = content.substring(lastIndex, match.index)
if (beforeText.trim()) {
convertedContent += beforeText
}
}
// 添加带时间戳的字符
if (isFirstChar) {
// 第一个字符直接添加,不需要额外的时间戳
convertedContent += char
isFirstChar = false
} else {
convertedContent += `<${charTimestamp}>${char}`
}
lastIndex = match.index + fullMatch.length
}
// 添加剩余的普通文本
if (lastIndex < content.length) {
const remainingText = content.substring(lastIndex)
if (remainingText.trim()) {
convertedContent += remainingText
}
}
return `[${timestamp}]${convertedContent}`
}
// 写入音频标签的辅助函数
async function writeAudioTags(filePath: string, songInfo: any, tagWriteOptions: any) {
try {
console.log('开始写入音频标签:', filePath, tagWriteOptions, songInfo)
// 获取文件扩展名来判断格式
const fileExtension = path.extname(filePath).toLowerCase().substring(1)
console.log('文件格式:', fileExtension)
// 根据文件格式选择不同的标签写入方法
if (fileExtension === 'mp3') {
await writeMP3Tags(filePath, songInfo, tagWriteOptions)
} else if (['flac', 'ogg', 'opus'].includes(fileExtension)) {
await writeVorbisCommentTags(filePath, songInfo, tagWriteOptions)
} else if (['m4a', 'mp4', 'aac'].includes(fileExtension)) {
await writeMP4Tags(filePath, songInfo, tagWriteOptions)
} else {
console.warn('不支持的音频格式:', fileExtension)
// 尝试使用 NodeID3 作为后备方案
await writeMP3Tags(filePath, songInfo, tagWriteOptions)
}
} catch (error) {
console.error('写入音频标签时发生错误:', error)
throw error
}
}
// MP3 格式标签写入
async function writeMP3Tags(filePath: string, songInfo: any, tagWriteOptions: any) {
const tags: any = {}
// 写入基础信息
if (tagWriteOptions.basicInfo) {
tags.title = songInfo.name || ''
tags.artist = songInfo.singer || ''
tags.album = songInfo.albumName || ''
tags.year = songInfo.year || ''
tags.genre = songInfo.genre || ''
}
// 写入歌词
if (tagWriteOptions.lyrics && songInfo.lrc) {
const convertedLrc = convertLrcFormat(songInfo.lrc)
tags.unsynchronisedLyrics = {
language: 'chi',
shortText: 'Lyrics',
text: convertedLrc
}
}
// 写入封面
if (tagWriteOptions.cover && songInfo.img) {
try {
const coverResponse = await axios({
method: 'GET',
url: songInfo.img,
responseType: 'arraybuffer',
timeout: 10000
})
if (coverResponse.data) {
tags.image = {
mime: 'image/jpeg',
type: {
id: 3,
name: 'front cover'
},
description: 'Cover',
imageBuffer: Buffer.from(coverResponse.data)
}
}
} catch (coverError) {
console.warn('获取封面失败:', coverError)
}
}
// 写入标签到文件
if (Object.keys(tags).length > 0) {
const success = NodeID3.write(tags, filePath)
if (success) {
console.log('MP3音频标签写入成功:', filePath)
} else {
console.warn('MP3音频标签写入失败:', filePath)
}
}
}
// FLAC/OGG 格式标签写入 (使用 Vorbis Comment)
async function writeVorbisCommentTags(filePath: string, songInfo: any, tagWriteOptions: any) {
try {
console.log('开始写入 FLAC 标签:', filePath)
// 准备新的标签数据
const newTags: any = {}
// 写入基础信息
if (tagWriteOptions.basicInfo) {
if (songInfo.name) newTags.TITLE = songInfo.name
if (songInfo.singer) newTags.ARTIST = songInfo.singer
if (songInfo.albumName) newTags.ALBUM = songInfo.albumName
if (songInfo.year) newTags.DATE = songInfo.year.toString()
if (songInfo.genre) newTags.GENRE = songInfo.genre
}
// 写入歌词
if (tagWriteOptions.lyrics && songInfo.lrc) {
const convertedLrc = convertLrcFormat(songInfo.lrc)
newTags.LYRICS = convertedLrc
}
console.log('准备写入的标签:', newTags)
// 使用 ffmpeg-static 写入 FLAC 标签
if (path.extname(filePath).toLowerCase() === '.flac') {
await writeFLACTagsWithFFmpeg(filePath, newTags, songInfo, tagWriteOptions)
} else {
console.warn('暂不支持该格式的标签写入:', path.extname(filePath))
}
} catch (error) {
console.error('写入 Vorbis Comment 标签失败:', error)
throw error
}
}
// 使用 fluent-ffmpeg 写入 FLAC 标签
async function writeFLACTagsWithFFmpeg(
filePath: string,
tags: any,
songInfo: any,
tagWriteOptions: any
) {
let tempOutputPath: string | null = null
let tempCoverPath: string | null = null
try {
if (!ffmpegStatic) {
throw new Error('ffmpeg-static 不可用')
}
ffmpeg.setFfmpegPath(ffmpegStatic.replace('app.asar', 'app.asar.unpacked'))
// 创建临时输出文件
tempOutputPath = filePath + '.temp.flac'
// 创建 fluent-ffmpeg 实例
let command = ffmpeg(filePath)
.audioCodec('copy') // 复制音频编解码器,不重新编码
.output(tempOutputPath)
// 添加元数据标签
for (const [key, value] of Object.entries(tags)) {
if (value) {
// fluent-ffmpeg 会自动处理特殊字符转义
command = command.outputOptions(['-metadata', `${key}=${value}`])
}
}
// 处理封面
if (tagWriteOptions.cover && songInfo.img) {
try {
console.log('开始下载封面:', songInfo.img)
const coverResponse = await axios({
method: 'GET',
url: songInfo.img,
responseType: 'arraybuffer',
timeout: 10000
})
if (coverResponse.data) {
// 保存临时封面文件
tempCoverPath = path.join(path.dirname(filePath), 'temp_cover.jpg')
await fsPromise.writeFile(tempCoverPath, Buffer.from(coverResponse.data))
// 添加封面作为输入
command = command.input(tempCoverPath).outputOptions([
'-map',
'0:a', // 映射原始文件的音频流
'-map',
'1:v', // 映射封面的视频流
'-c:v',
'copy', // 复制视频编解码器
'-disposition:v:0',
'attached_pic' // 设置为附加图片
])
console.log('封面已添加到命令中')
}
} catch (coverError) {
console.warn(
'下载封面失败,跳过封面写入:',
coverError instanceof Error ? coverError.message : coverError
)
}
}
// 执行 ffmpeg 命令
await new Promise<void>((resolve, reject) => {
command
.on('start', () => {
console.log('执行 ffmpeg 命令')
})
.on('progress', (progress) => {
if (progress.percent) {
console.log('处理进度:', Math.round(progress.percent) + '%')
}
})
.on('end', () => {
console.log('ffmpeg 处理完成')
resolve()
})
.on('error', (err, _, stderr) => {
console.error('ffmpeg 错误:', err.message)
if (stderr) {
console.error('ffmpeg stderr:', stderr)
}
reject(new Error(`ffmpeg 处理失败: ${err.message}`))
})
.run()
})
// 检查临时文件是否创建成功
if (!fs.existsSync(tempOutputPath)) {
throw new Error('ffmpeg 未能创建输出文件')
}
// 替换原文件
await fsPromise.rename(tempOutputPath, filePath)
tempOutputPath = null // 标记已处理,避免重复清理
console.log('使用 fluent-ffmpeg 写入 FLAC 标签成功:', filePath)
} catch (error) {
console.error('使用 fluent-ffmpeg 写入 FLAC 标签失败:', error)
throw error
} finally {
// 清理所有临时文件
const filesToClean = [tempOutputPath, tempCoverPath].filter(Boolean) as string[]
for (const tempFile of filesToClean) {
try {
if (fs.existsSync(tempFile)) {
await fsPromise.unlink(tempFile)
console.log('已清理临时文件:', tempFile)
}
} catch (cleanupError) {
console.warn(
'清理临时文件失败:',
tempFile,
cleanupError instanceof Error ? cleanupError.message : cleanupError
)
}
}
}
}
// MP4/M4A 格式标签写入
async function writeMP4Tags(filePath: string, _songInfo: any, _tagWriteOptions: any) {
console.log('MP4/M4A 格式标签写入暂未实现:', filePath)
// 可以使用 ffmpeg 或其他工具实现
}
// 获取自定义下载目录
const getDownloadDirectory = (): string => {
// 使用配置管理服务获取下载目录
const directories = configManager.getDirectories()
return directories.downloadDir
}
// 从URL中提取文件扩展名如果没有则默认为mp3
const getFileExtension = (url: string): string => {
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname
const lastDotIndex = pathname.lastIndexOf('.')
if (lastDotIndex !== -1 && lastDotIndex < pathname.length - 1) {
const extension = pathname.substring(lastDotIndex + 1).toLowerCase()
// 验证是否为常见的音频格式
const validExtensions = ['mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'wma']
if (validExtensions.includes(extension)) {
return extension
}
}
} catch (error) {
console.warn('解析URL失败使用默认扩展名:', error)
}
return 'mp3' // 默认扩展名
}
export default async function download(
url: string,
songInfo: any,
tagWriteOptions: any
): Promise<any> {
const fileExtension = getFileExtension(url)
const downloadDir = getDownloadDirectory()
const songPath = path.join(
downloadDir,
`${songInfo.name}-${songInfo.singer}-${songInfo.source}.${fileExtension}`
.replace(/[/\\:*?"<>|]/g, '')
.replace(/^\.+/, '')
.replace(/\.+$/, '')
.trim()
)
if (fileLock[songPath]) {
throw new Error('歌曲正在下载中')
} else {
fileLock[songPath] = true
}
try {
if (fs.existsSync(songPath)) {
return {
message: '歌曲已存在'
}
}
await fsPromise.mkdir(path.dirname(songPath), { recursive: true })
if (url.startsWith('file://')) {
const filePath = fileURLToPath(url)
const readStream = fs.createReadStream(filePath)
const writeStream = fs.createWriteStream(songPath)
await pipeline(readStream, writeStream)
} else {
const songDataRes = await axios({
method: 'GET',
url: url,
responseType: 'stream'
})
await pipeline(songDataRes.data, fs.createWriteStream(songPath))
}
} finally {
delete fileLock[songPath]
}
// 写入标签信息
if (tagWriteOptions && fs.existsSync(songPath)) {
try {
await writeAudioTags(songPath, songInfo, tagWriteOptions)
} catch (error) {
console.warn('写入音频标签失败:', error)
throw ffmpegStatic
}
}
return {
message: '下载成功',
path: songPath
}
}

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

@@ -5,10 +5,10 @@ import pic from './pic'
import lyric from './lyric'
import hotSearch from './hotSearch'
import comment from './comment'
// import tipSearch from './tipSearch'
import tipSearch from './tipSearch'
const kg = {
// tipSearch,
tipSearch,
leaderboard,
songList,
musicSearch,
@@ -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)
@@ -408,9 +408,9 @@ export default {
},
async getUserListDetailByLink({ info }, link) {
let listInfo = info['0']
const listInfo = info['0']
let total = listInfo.count
let tasks = []
const tasks = []
let page = 0
while (total) {
const limit = total > 90 ? 90 : total
@@ -448,7 +448,7 @@ export default {
}
},
createGetListDetail2Task(id, total) {
let tasks = []
const tasks = []
let page = 0
while (total) {
const limit = total > 300 ? 300 : total
@@ -481,13 +481,13 @@ export default {
return Promise.all(tasks).then(([...datas]) => datas.flat())
},
async getUserListDetail2(global_collection_id) {
let id = global_collection_id
const id = global_collection_id
if (id.length > 1000) throw new Error('get list error')
const params =
'appid=1058&specialid=0&global_specialid=' +
id +
'&format=jsonp&srcappid=2919&clientver=20000&clienttime=1586163242519&mid=1586163242519&uuid=1586163242519&dfid=-'
let info = await this.createHttp(
const info = await this.createHttp(
`https://mobiles.kugou.com/api/v5/special/info_v2?${params}&signature=${signatureParams(params, 'web')}`,
{
headers: {
@@ -501,7 +501,7 @@ export default {
}
)
const songInfo = await this.createGetListDetail2Task(id, info.songcount)
let list = await this.getMusicInfos(songInfo)
const list = await this.getMusicInfos(songInfo)
// console.log(info, songInfo, list)
return {
list,
@@ -534,7 +534,7 @@ export default {
},
async getUserListDetailByPcChain(chain) {
let key = `${chain}_pc_list`
const key = `${chain}_pc_list`
if (this.cache.has(key)) return this.cache.get(key)
const { body } = await httpFetch(`http://www.kugou.com/share/${chain}.html`, {
headers: {
@@ -595,7 +595,7 @@ export default {
async getUserListDetailById(id, page, limit) {
const signature = await handleSignature(id, page, limit)
let info = await this.createHttp(
const info = await this.createHttp(
`https://pubsongscdn.kugou.com/v2/get_other_list_file?srcappid=2919&clientver=20000&appid=1058&type=0&module=playlist&page=${page}&pagesize=${limit}&specialid=${id}&signature=${signature}`,
{
headers: {
@@ -608,7 +608,7 @@ export default {
)
// console.log(info)
let result = await this.getMusicInfos(info.info)
const result = await this.getMusicInfos(info.info)
// console.log(info, songInfo)
return result
},
@@ -621,7 +621,7 @@ export default {
link.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1')
)
if (link.includes('gcid_')) {
let gcid = link.match(/gcid_\w+/)?.[0]
const gcid = link.match(/gcid_\w+/)?.[0]
if (gcid) {
const global_collection_id = await this.decodeGcid(gcid)
if (global_collection_id) return this.getUserListDetail2(global_collection_id)
@@ -667,7 +667,7 @@ export default {
location.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1')
)
if (location.includes('gcid_')) {
let gcid = link.match(/gcid_\w+/)?.[0]
const gcid = link.match(/gcid_\w+/)?.[0]
if (gcid) {
const global_collection_id = await this.decodeGcid(gcid)
if (global_collection_id) return this.getUserListDetail2(global_collection_id)
@@ -698,7 +698,7 @@ export default {
// console.log('location', location)
return this.getUserListDetail(location, page, ++retryNum)
}
if (typeof body == 'string') {
if (typeof body === 'string') {
let global_collection_id = body.match(/"global_collection_id":"(\w+)"/)?.[1]
if (!global_collection_id) {
let gcid = body.match(/"encode_gic":"(\w+)"/)?.[1]
@@ -735,7 +735,7 @@ export default {
const types = []
const _types = {}
if (item.filesize !== 0) {
let size = sizeFormate(item.filesize)
const size = sizeFormate(item.filesize)
types.push({ type: '128k', size, hash: item.hash })
_types['128k'] = {
size,
@@ -743,7 +743,7 @@ export default {
}
}
if (item.filesize_320 !== 0) {
let size = sizeFormate(item.filesize_320)
const size = sizeFormate(item.filesize_320)
types.push({ type: '320k', size, hash: item.hash_320 })
_types['320k'] = {
size,
@@ -751,7 +751,7 @@ export default {
}
}
if (item.filesize_ape !== 0) {
let size = sizeFormate(item.filesize_ape)
const size = sizeFormate(item.filesize_ape)
types.push({ type: 'ape', size, hash: item.hash_ape })
_types.ape = {
size,
@@ -759,7 +759,7 @@ export default {
}
}
if (item.filesize_flac !== 0) {
let size = sizeFormate(item.filesize_flac)
const size = sizeFormate(item.filesize_flac)
types.push({ type: 'flac', size, hash: item.hash_flac })
_types.flac = {
size,
@@ -849,8 +849,8 @@ export default {
// hash list filter
filterData2(rawList) {
// console.log(rawList)
let ids = new Set()
let list = []
const ids = new Set()
const list = []
rawList.forEach((item) => {
if (!item) return
if (ids.has(item.audio_info.audio_id)) return
@@ -858,7 +858,7 @@ export default {
const types = []
const _types = {}
if (item.audio_info.filesize !== '0') {
let size = sizeFormate(parseInt(item.audio_info.filesize))
const size = sizeFormate(parseInt(item.audio_info.filesize))
types.push({ type: '128k', size, hash: item.audio_info.hash })
_types['128k'] = {
size,
@@ -866,7 +866,7 @@ export default {
}
}
if (item.audio_info.filesize_320 !== '0') {
let size = sizeFormate(parseInt(item.audio_info.filesize_320))
const size = sizeFormate(parseInt(item.audio_info.filesize_320))
types.push({ type: '320k', size, hash: item.audio_info.hash_320 })
_types['320k'] = {
size,
@@ -874,7 +874,7 @@ export default {
}
}
if (item.audio_info.filesize_flac !== '0') {
let size = sizeFormate(parseInt(item.audio_info.filesize_flac))
const size = sizeFormate(parseInt(item.audio_info.filesize_flac))
types.push({ type: 'flac', size, hash: item.audio_info.hash_flac })
_types.flac = {
size,
@@ -882,7 +882,7 @@ export default {
}
}
if (item.audio_info.filesize_high !== '0') {
let size = sizeFormate(parseInt(item.audio_info.filesize_high))
const size = sizeFormate(parseInt(item.audio_info.filesize_high))
types.push({ type: 'flac24bit', size, hash: item.audio_info.hash_high })
_types.flac24bit = {
size,
@@ -927,7 +927,7 @@ export default {
// 获取列表数据
getList(sortId, tagId, page) {
let tasks = [this.getSongList(sortId, tagId, page)]
const tasks = [this.getSongList(sortId, tagId, page)]
tasks.push(
this.currentTagInfo.id === tagId
? Promise.resolve(this.currentTagInfo.info)
@@ -964,7 +964,7 @@ export default {
},
getDetailPageUrl(id) {
if (typeof id == 'string') {
if (typeof id === 'string') {
if (/^https?:\/\//.test(id)) return id
id = id.replace('id_', '')
}

View File

@@ -16,11 +16,28 @@ export default {
}
)
return this.requestObj.then((body) => {
return body[0].RecordDatas
return body
})
},
handleResult(rawData) {
return rawData.map((info) => info.HintInfo)
let list = {
order: [],
songs: [],
albums: []
}
if (rawData[0].RecordCount > 0) {
list.order.push('songs')
}
if (rawData[2].RecordCount > 0) {
list.order.push('albums')
}
list.songs = rawData[0].RecordDatas.map((info) => ({
name: info.HintInfo
}))
list.albums = rawData[2].RecordDatas.map((info) => ({
name: info.HintInfo
}))
return list
},
async search(str) {
return this.tipSearchBySong(str).then((result) => this.handleResult(result))

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

@@ -24,7 +24,18 @@ export default {
})
},
handleResult(rawData) {
return rawData.map((item) => item.RELWORD)
let list = {
order: [],
songs: []
}
if (rawData.length > 0) {
list.order.push('songs')
}
list.songs = rawData.map((item) => ({
name: item.RELWORD,
artist: item.TAG_TYPE === 4 ? { name: '热搜' } : null
}))
return list
},
cancelTipSearch() {
if (this.requestObj && this.requestObj.cancelHttp) this.requestObj.cancelHttp()

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
}

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