Compare commits

...

48 Commits

Author SHA1 Message Date
sqj
d44be6022a fix:优化滚动位置问题,某平台 歌单上限导入失败问题,优化包体积,修复歌曲下载失败 2025-10-11 22:54:10 +08:00
sqj
0c512bccff fix: 修复歌曲无法下载,支持更多音质选择 2025-10-09 20:25:27 +08:00
sqj
b07cc2359a fix: 修复歌曲播放缓存内存泄露问题 feat:歌曲播放出错自动切歌不是暂停 2025-10-09 01:47:07 +08:00
时迁酱
46756a8b09 Merge pull request #12 from GladerJ/main
修复播放新歌曲失败时,原歌曲 UI 状态被错误覆盖的问题。
2025-10-08 16:51:53 +08:00
Glader
deb73fa789 修复播放新歌曲失败时,原歌曲 UI 状态被错误覆盖的问题。
修复播放新歌曲失败时,原歌曲 UI 状态被错误覆盖的问题。
2025-10-08 15:15:30 +08:00
sqj
910ab1ff10 docs: add 赞助名单 2025-10-07 22:24:40 +08:00
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
260 changed files with 12644 additions and 40704 deletions

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描述

View File

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

File diff suppressed because one or more lines are too long

View File

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

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

View File

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

View File

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

View File

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

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

142
README.md
View File

@@ -6,11 +6,14 @@
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)
## 技术栈
- **Electron**:用于构建跨平台桌面应用
@@ -19,23 +22,17 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工
- **Pinia**:状态管理工具
- **Vite**:快速的前端构建工具
- **CeruPlugins**:音乐插件运行环境(仅提供框架,不包含默认插件)
- **AMLL**:音乐生态辅助模块(仅提供功能接口,不关联具体音乐数据源)
- **AMLL**:音乐生态(歌词渲染等)辅助模块(仅提供功能接口,不关联具体音乐数据源)
## 项目结构
<details>
<summary>点击查看目录结构</summary>
```ast
CeruMuisc/
├── .github/
│ └── workflows/
│ ├── auto-sync-release.yml
│ ├── deploydocs.yml
│ ├── main.yml
│ ├── sync-releases-to-webdav.yml
│ └── uploadpan.yml
├── scripts/
│ ├── auth-test.js
│ ├── genAst.js
│ └── test-alist.js
├── src/
│ ├── common/
│ │ ├── types/
@@ -55,6 +52,7 @@ CeruMuisc/
│ │ │ ├── autoUpdate.ts
│ │ │ ├── directorySettings.ts
│ │ │ ├── musicCache.ts
│ │ │ ├── pluginNotice.ts
│ │ │ └── songList.ts
│ │ ├── services/
│ │ │ ├── music/
@@ -76,91 +74,10 @@ CeruMuisc/
│ │ │ ├── songList/
│ │ │ │ ├── ManageSongList.ts
│ │ │ │ └── PlayListSongs.ts
│ │ │ ── ai-service.ts
│ │ │ ── ai-service.ts
│ │ │ └── ConfigManager.ts
│ │ ├── utils/
│ │ │ ├── musicSdk/
│ │ │ │ ├── kg/
│ │ │ │ │ ├── temp/
│ │ │ │ │ │ ├── musicSearch-new.js
│ │ │ │ │ │ └── songList-new.js
│ │ │ │ │ ├── vendors/
│ │ │ │ │ │ └── infSign.min.js
│ │ │ │ │ ├── album.js
│ │ │ │ │ ├── api-test.js
│ │ │ │ │ ├── comment.js
│ │ │ │ │ ├── hotSearch.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── leaderboard.js
│ │ │ │ │ ├── lyric.js
│ │ │ │ │ ├── musicInfo.js
│ │ │ │ │ ├── musicSearch.js
│ │ │ │ │ ├── pic.js
│ │ │ │ │ ├── singer.js
│ │ │ │ │ ├── songList.js
│ │ │ │ │ ├── tipSearch.js
│ │ │ │ │ └── util.js
│ │ │ │ ├── kw/
│ │ │ │ │ ├── album.js
│ │ │ │ │ ├── api-temp.js
│ │ │ │ │ ├── api-test.js
│ │ │ │ │ ├── comment.js
│ │ │ │ │ ├── hotSearch.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── kwdecode.ts
│ │ │ │ │ ├── leaderboard.js
│ │ │ │ │ ├── lyric.js
│ │ │ │ │ ├── musicSearch.js
│ │ │ │ │ ├── pic.js
│ │ │ │ │ ├── songList.js
│ │ │ │ │ ├── tipSearch.js
│ │ │ │ │ └── util.js
│ │ │ │ ├── mg/
│ │ │ │ │ ├── temp/
│ │ │ │ │ │ └── leaderboard-old.js
│ │ │ │ │ ├── utils/
│ │ │ │ │ │ ├── index.js
│ │ │ │ │ │ └── mrc.js
│ │ │ │ │ ├── album.js
│ │ │ │ │ ├── api-test.js
│ │ │ │ │ ├── comment.js
│ │ │ │ │ ├── hotSearch.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── leaderboard.js
│ │ │ │ │ ├── lyric.js
│ │ │ │ │ ├── musicInfo.js
│ │ │ │ │ ├── musicSearch.js
│ │ │ │ │ ├── pic.js
│ │ │ │ │ ├── songId.js
│ │ │ │ │ ├── songList.js
│ │ │ │ │ └── tipSearch.js
│ │ │ │ ├── tx/
│ │ │ │ │ ├── api-test.js
│ │ │ │ │ ├── comment.js
│ │ │ │ │ ├── hotSearch.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── leaderboard.js
│ │ │ │ │ ├── lyric.js
│ │ │ │ │ ├── musicInfo.js
│ │ │ │ │ ├── musicSearch.js
│ │ │ │ │ ├── singer.js
│ │ │ │ │ ├── songList.js
│ │ │ │ │ └── tipSearch.js
│ │ │ │ ├── wy/
│ │ │ │ │ ├── utils/
│ │ │ │ │ │ ├── crypto.js
│ │ │ │ │ │ └── index.js
│ │ │ │ │ ├── api-test.js
│ │ │ │ │ ├── comment.js
│ │ │ │ │ ├── hotSearch.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── leaderboard.js
│ │ │ │ │ ├── lyric.js
│ │ │ │ │ ├── musicDetail.js
│ │ │ │ │ ├── musicInfo.js
│ │ │ │ │ ├── musicSearch.js
│ │ │ │ │ ├── singer.js
│ │ │ │ │ ├── songList.js
│ │ │ │ │ └── tipSearch.js
│ │ │ │ ├── api-source-info.ts
│ │ │ │ ├── index.js
│ │ │ │ ├── options.js
@@ -189,6 +106,16 @@ CeruMuisc/
│ │ │ ├── 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/
@@ -199,14 +126,14 @@ CeruMuisc/
│ │ │ │ │ ├── PlaylistDrawer.vue
│ │ │ │ │ ├── PlayMusic.vue
│ │ │ │ │ └── ShaderBackground.vue
│ │ │ │ ├── Search/
│ │ │ │ │ └── SearchComponent.vue
│ │ │ │ ├── Settings/
│ │ │ │ │ ├── AIFloatBallSettings.vue
│ │ │ │ │ ├── DirectorySettings.vue
│ │ │ │ │ ├── MusicCache.vue
│ │ │ │ │ ├── PlaylistSettings.vue
│ │ │ │ │ ├── plugins.vue
│ │ │ │ │ └── UpdateSettings.vue
│ │ │ │ ├── PluginNoticeDialog.vue
│ │ │ │ ├── ThemeSelector.vue
│ │ │ │ ├── TitleBarControls.vue
│ │ │ │ ├── UpdateExample.vue
@@ -214,8 +141,6 @@ CeruMuisc/
│ │ │ │ └── Versions.vue
│ │ │ ├── composables/
│ │ │ │ └── useAutoUpdate.ts
│ │ │ ├── layout/
│ │ │ │ └── index.vue
│ │ │ ├── router/
│ │ │ │ └── index.ts
│ │ │ ├── services/
@@ -254,10 +179,10 @@ CeruMuisc/
│ │ │ │ │ ├── recent.vue
│ │ │ │ │ └── search.vue
│ │ │ │ ├── settings/
│ │ │ │ │ ── index.vue
│ │ │ │ │ └── plugins.vue
│ │ │ │ └── welcome/
│ │ │ │ └── index.vue
│ │ │ │ │ ── index.vue
│ │ │ │ ├── welcome/
│ │ │ │ │ └── index.vue
│ │ │ │ └── ThemeDemo.vue
│ │ │ ├── App.vue
│ │ │ ├── env.d.ts
│ │ │ └── main.ts
@@ -289,6 +214,8 @@ CeruMuisc/
└── yarn.lock
```
</details>
## 主要功能
- 提供插件加载与管理功能,支持通过合规插件获取公开音乐信息
@@ -351,8 +278,8 @@ CeruMuisc/
## 文档与资源
- [产品设计文档](https://www.doubao.com/thread/docs/design.md):涵盖项目架构、核心功能设计和开发规范(不含任何音乐数据源信息)。
- [插件开发文档](https://www.doubao.com/thread/docs/CeruMusic插件开发文档.md):仅提供插件开发技术规范,**明确要求插件开发者需遵守数据来源平台的用户协议与版权法**,禁止开发、传播获取非公开数据的插件。
- 产品设计文档:涵盖项目架构、核心功能设计和开发规范(不含任何音乐数据源信息)。
- [插件开发文档](https://ceru.docs.shiqianjiang.cn/guide/CeruMusicPluginDev.html):仅提供插件开发技术规范,**明确要求插件开发者需遵守数据来源平台的用户协议与版权法**,禁止开发、传播获取非公开数据的插件。
## 开源许可
@@ -363,7 +290,7 @@ CeruMuisc/
欢迎开发者贡献代码与反馈建议,贡献内容需符合以下要求:
1. 仅涉及播放器框架功能优化、bug 修复、文档完善,不包含任何音乐数据源相关代码。
2. 遵循 [Git 提交规范](https://www.doubao.com/thread/docs/design.md#git提交规范) 并确保代码符合项目风格指南。
2. 遵循 [Git 提交规范](#) 并确保代码符合项目风格指南。
3. 贡献的代码需无第三方版权纠纷,且不违反开源许可协议。
## 联系方式
@@ -430,3 +357,8 @@ CeruMuisc/
若您认可本项目的技术价值,欢迎通过以下方式支持开发者(仅用于项目技术维护与迭代):
<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

@@ -5,10 +5,23 @@ 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){
markdown: {
config(md) {
md.use(note)
}
},
@@ -28,13 +41,10 @@ export default defineConfig({
{ text: '安装教程', link: '/guide/' },
{
text: '使用教程',
items: [
{ text: '音乐播放列表', link: '/guide/used/playList' },
]
items: [{ text: '音乐播放列表', link: '/guide/used/playList' }]
},
{ text: '软件设计文档', link: '/guide/design' },
{ text: '更新日志', link: '/guide/updateLog' },
{ text: '更新计划', link: '/guide/update'}
{ text: '更新计划', link: '/guide/update' }
]
},
{
@@ -43,6 +53,10 @@ export default defineConfig({
{ text: '插件类使用', link: '/guide/CeruMusicPluginHost' },
{ text: '澜音插件开发文档(重点)', link: '/guide/CeruMusicPluginDev' }
]
},
{
text: '鸣谢名单',
link: '/guide/sponsorship'
}
],
@@ -64,21 +78,19 @@ export default defineConfig({
provider: 'local'
},
outline: {
level: [2,4],
level: [2, 4],
label: '文章导航'
},
docFooter: {
next: '下一篇',
prev: '上一篇'
},
lastUpdatedText: '上次更新',
lastUpdatedText: '上次更新'
},
sitemap: {
hostname: 'https://ceru.docs.shiqianjiang.cn'
},
lastUpdated: true,
head: [['link', { rel: 'icon', href: '/logo.svg' }]],
lastUpdated: true
})
console.log(process.env.BASE_URL_DOCS)
// Smooth scrolling functions

View File

@@ -168,15 +168,15 @@ html.dark #app {
// --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-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) ". ";
/* 主题颜色 */
@@ -284,4 +284,4 @@ html .vp-doc div[class*='language-'] pre {
}
.VPDoc.has-aside .content-container {
max-width: none !important;
}
}

View File

@@ -3,6 +3,7 @@
## 概述
CeruMusic 支持两种类型的插件:
1. **CeruMusic 原生插件**:基于 CeruMusic API 的插件格式
2. **LX 兼容插件**:兼容 LX Music 的事件驱动插件格式
@@ -64,11 +65,11 @@ const pluginInfo = {
const sources = {
kw:{
name: "酷我音乐",
qualities: ['128k', '320k', 'flac', 'flac24bit']
qualitys: ['128k', '320k', 'flac', 'flac24bit']
},
tx:{
name: "QQ音乐",
qualities: ['128k', '320k', 'flac']
name: "QQ音乐",
qualitys: ['128k', '320k', 'flac']
}
};
@@ -84,7 +85,7 @@ async function musicUrl(source, musicInfo, quality) {
},
body: JSON.stringify({
id: musicInfo.id,
quality: quality
qualitys: quality
})
});
@@ -132,23 +133,20 @@ module.exports = {
> #### PS:
>
> - `sources key` 取值
>
> - wy 网易云音乐 |
> - tx QQ音乐 |
> - kg 酷狗音乐 |
> - mg 咪咕音乐 |
> - tx QQ音乐 |
> - kg 酷狗音乐 |
> - mg 咪咕音乐 |
> - kw 酷我音乐
>
> - 导出
>
> ```javascript
> module.exports = {
> sources, // 你的音源支持
> };
> sources // 你的音源支持
> }
> ```
>
> - 支持的音质 ` sources.qualities: ['128k', '320k', 'flac']`
>
> - 支持的音质 ` sources.qualitys: ['128k', '320k', 'flac']`
> - `128k`: 128kbps
> - `320k`: 320kbps
> - `flac`: FLAC 无损
@@ -157,8 +155,6 @@ module.exports = {
> - `atmos`: 杜比全景声
> - `master`: 母带音质
### CeruMusic API 参考
#### cerumusic.request(url, options)
@@ -166,6 +162,7 @@ module.exports = {
HTTP 请求方法,返回 Promise。
**参数:**
- `url` (string): 请求地址
- `options` (object): 请求选项
- `method`: 请求方法 (GET, POST, PUT, DELETE 等)
@@ -174,6 +171,7 @@ HTTP 请求方法,返回 Promise。
- `timeout`: 超时时间(毫秒)
**返回值:**
```javascript
{
statusCode: 200,
@@ -206,16 +204,17 @@ cerumusic.utils.crypto.rsaEncrypt(data, key)
cerumusic.NoticeCenter('info', {
title: '通知标题',
content: '通知内容',
url: 'https://example.com', // 可选 当通知为update 版本跟新可传
url: 'https://example.com', // 可选 当通知为update 版本跟新可传
version: '版本号', // 当通知为update 版本跟新可传
pluginInfo: {
name: '插件名称',
type: 'cr', // 固定唯一标识
}// 当通知为update 版本跟新可传
});
type: 'cr' // 固定唯一标识
} // 当通知为update 版本跟新可传
})
```
**通知类型:**
- `'info'`: 信息通知
- `'success'`: 成功通知
- `'warn'`: 警告通知
@@ -247,46 +246,47 @@ const qualitys = {
'128k': '128',
'320k': '320',
flac: 'flac',
flac24bit: 'flac24bit',
flac24bit: 'flac24bit'
},
local: {},
local: {}
}
// HTTP 请求封装
const httpRequest = (url, options) => new Promise((resolve, reject) => {
request(url, options, (err, resp) => {
if (err) return reject(err)
resolve(resp.body)
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 httpRequest('http://xxx').then((data) => {
return data.url
})
},
}
},
local: {
musicUrl(info) {
return httpRequest('http://xxx').then(data => {
return httpRequest('http://xxx').then((data) => {
return data.url
})
},
pic(info) {
return httpRequest('http://xxx').then(data => {
return httpRequest('http://xxx').then((data) => {
return data.url
})
},
lyric(info) {
return httpRequest('http://xxx').then(data => {
return httpRequest('http://xxx').then((data) => {
return {
lyric: '...', // 歌曲歌词
tlyric: '...', // 翻译歌词,没有可为 null
rlyric: '...', // 罗马音歌词,没有可为 null
lxlyric: '...', // lx 逐字歌词,没有可为 null
lxlyric: '...' // lx 逐字歌词,没有可为 null
}
})
}
@@ -313,15 +313,15 @@ send(EVENT_NAMES.inited, {
name: '酷我音乐',
type: 'music',
actions: ['musicUrl'],
qualitys: ['128k', '320k', 'flac', 'flac24bit'],
qualitys: ['128k', '320k', 'flac', 'flac24bit']
},
local: {
name: '本地音乐',
type: 'music',
actions: ['musicUrl', 'lyric', 'pic'],
qualitys: [],
},
},
qualitys: []
}
}
})
```
@@ -342,8 +342,8 @@ send(EVENT_NAMES.inited, {
```javascript
lx.on(lx.EVENT_NAMES.request, ({ source, action, info }) => {
// 必须返回 Promise
return Promise.resolve(result);
});
return Promise.resolve(result)
})
```
#### globalThis.lx.send(eventName, data)
@@ -369,18 +369,22 @@ lx.send(lx.EVENT_NAMES.updateAlert, {
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;
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)
}
console.log('响应:', resp.body);
});
)
```
#### globalThis.lx.utils
@@ -433,28 +437,28 @@ async function musicUrl(source, musicInfo, quality) {
try {
// 参数验证
if (!musicInfo || !musicInfo.id) {
throw new Error('音乐信息不完整');
throw new Error('音乐信息不完整')
}
// API 调用
const result = await cerumusic.request(url, options);
const result = await cerumusic.request(url, options)
// 结果验证
if (!result || result.statusCode !== 200) {
throw new Error(`API 请求失败: ${result?.statusCode || 'Unknown'}`);
throw new Error(`API 请求失败: ${result?.statusCode || 'Unknown'}`)
}
if (!result.body || !result.body.url) {
throw new Error('返回数据格式错误');
throw new Error('返回数据格式错误')
}
return result.body.url;
return result.body.url
} catch (error) {
// 记录错误日志
console.error(`[${source}] 获取音乐链接失败:`, error.message);
console.error(`[${source}] 获取音乐链接失败:`, error.message)
// 重新抛出错误供上层处理
throw new Error(`获取 ${source} 音乐链接失败: ${error.message}`);
throw new Error(`获取 ${source} 音乐链接失败: ${error.message}`)
}
}
```
@@ -473,9 +477,9 @@ async function musicUrl(source, musicInfo, quality) {
### 1. 使用 console.log
```javascript
console.log('[插件名] 调试信息:', data);
console.warn('[插件名] 警告信息:', warning);
console.error('[插件名] 错误信息:', error);
console.log('[插件名] 调试信息:', data)
console.warn('[插件名] 警告信息:', warning)
console.error('[插件名] 错误信息:', error)
```
### 2. LX 插件开发者工具
@@ -491,8 +495,8 @@ send(EVENT_NAMES.inited, {
```javascript
process.on('unhandledRejection', (reason, promise) => {
console.error('未处理的 Promise 拒绝:', reason);
});
console.error('未处理的 Promise 拒绝:', reason)
})
```
---
@@ -502,17 +506,17 @@ process.on('unhandledRejection', (reason, promise) => {
### 1. 请求缓存
```javascript
const cache = new Map();
const cache = new Map()
async function getCachedData(key, fetcher, ttl = 300000) {
const cached = cache.get(key);
const cached = cache.get(key)
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.data;
return cached.data
}
const data = await fetcher();
cache.set(key, { data, timestamp: Date.now() });
return data;
const data = await fetcher()
cache.set(key, { data, timestamp: Date.now() })
return data
}
```
@@ -521,21 +525,21 @@ async function getCachedData(key, fetcher, ttl = 300000) {
```javascript
const result = await cerumusic.request(url, {
timeout: 10000 // 10秒超时
});
})
```
### 3. 并发控制
```javascript
// 限制并发请求数量
const semaphore = new Semaphore(3); // 最多3个并发请求
const semaphore = new Semaphore(3) // 最多3个并发请求
async function limitedRequest(url, options) {
await semaphore.acquire();
await semaphore.acquire()
try {
return await cerumusic.request(url, options);
return await cerumusic.request(url, options)
} finally {
semaphore.release();
semaphore.release()
}
}
```
@@ -549,14 +553,14 @@ async function limitedRequest(url, options) {
```javascript
function validateMusicInfo(musicInfo) {
if (!musicInfo || typeof musicInfo !== 'object') {
throw new Error('音乐信息格式错误');
throw new Error('音乐信息格式错误')
}
if (!musicInfo.id || typeof musicInfo.id !== 'string') {
throw new Error('音乐 ID 无效');
throw new Error('音乐 ID 无效')
}
return true;
return true
}
```
@@ -565,10 +569,10 @@ function validateMusicInfo(musicInfo) {
```javascript
function isValidUrl(url) {
try {
const urlObj = new URL(url);
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
const urlObj = new URL(url)
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'
} catch {
return false;
return false
}
}
```
@@ -581,7 +585,7 @@ console.log('请求参数:', {
...params,
token: '***', // 隐藏敏感信息
password: '***'
});
})
```
---
@@ -605,13 +609,13 @@ async function testMusicUrl() {
id: 'test123',
name: '测试歌曲',
artist: '测试歌手'
};
}
try {
const url = await musicUrl('kw', testMusicInfo, '320k');
console.log('测试通过:', url);
const url = await musicUrl('kw', testMusicInfo, '320k')
console.log('测试通过:', url)
} catch (error) {
console.error('测试失败:', error);
console.error('测试失败:', error)
}
}
```
@@ -619,6 +623,7 @@ async function testMusicUrl() {
### 3. 版本管理
使用语义化版本号:
- `1.0.0`: 主版本.次版本.修订版本
- 主版本:不兼容的 API 修改
- 次版本:向下兼容的功能性新增
@@ -631,6 +636,7 @@ async function testMusicUrl() {
### Q: 插件加载失败怎么办?
A: 检查以下几点:
1. 文件编码是否为 UTF-8
2. 插件信息注释格式是否正确
3. JavaScript 语法是否有错误
@@ -645,20 +651,21 @@ A: CeruMusic 的请求方法不受浏览器跨域限制,可以直接请求任
A: 使用 `cerumusic.NoticeCenter` 事件通知用户更新:
```javascript
cerumusic.NoticeCenter('update',{
title:'新版本更新',
content:'xxxx',
cerumusic.NoticeCenter('update', {
title: '新版本更新',
content: 'xxxx',
version: 'v1.0.3',
url:'https://shiqianjiang.cn',
pluginInfo:{
type:'cr'
url: 'https://shiqianjiang.cn',
pluginInfo: {
type: 'cr'
}
})
```
### Q: 如何调试插件?
A:
A:
1. 使用 `console.log` 输出调试信息 可在设置—>插件管理—>日志 查看调试
2. LX 插件可以设置 `openDevTools: true` 打开开发者工具
3. 查看 CeruMusic 的插件日志
@@ -668,5 +675,6 @@ A:
## 技术支持
如有问题或建议,请通过以下方式联系:
- GitHub Issues: [CeruMusic Issues](https://github.com/timeshiftsauce/CeruMusic/issues)
- Blog (最好登录,否则需要审核): [CeruMusic Blog](https://shiqianjiang.cn/blog/4966904626407280640)

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: 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. **音乐推荐**: 基于听歌历史的智能推荐
---
_本设计文档将随着项目开发进度持续更新和完善。_

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

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

View File

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

View File

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

View File

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

@@ -2,24 +2,24 @@
// 这个文件可以用来测试 NoticeCenter 功能
const pluginInfo = {
name: "测试通知插件",
version: "1.0.0",
author: "CeruMusic Team",
description: "用于测试插件通知功能的示例插件",
type: "cr"
name: '测试通知插件',
version: '1.0.0',
author: 'CeruMusic Team',
description: '用于测试插件通知功能的示例插件',
type: 'cr'
}
const sources = [
{
name: "test",
qualities: ["128k", "320k"]
name: 'test',
qualities: ['128k', '320k']
}
]
// 模拟音乐URL获取函数
async function musicUrl(source, musicInfo, quality) {
console.log('测试插件获取音乐URL')
// 测试不同类型的通知
setTimeout(() => {
// 测试信息通知
@@ -29,7 +29,7 @@ async function musicUrl(source, musicInfo, quality) {
content: '插件正在正常工作'
})
}, 1000)
setTimeout(() => {
// 测试警告通知
this.cerumusic.NoticeCenter('warning', {
@@ -38,7 +38,7 @@ async function musicUrl(source, musicInfo, quality) {
content: '请注意某些设置'
})
}, 2000)
setTimeout(() => {
// 测试成功通知
this.cerumusic.NoticeCenter('success', {
@@ -47,7 +47,7 @@ async function musicUrl(source, musicInfo, quality) {
content: '音乐URL获取成功'
})
}, 3000)
setTimeout(() => {
// 测试更新通知
this.cerumusic.NoticeCenter('update', {
@@ -62,7 +62,7 @@ async function musicUrl(source, musicInfo, quality) {
}
})
}, 4000)
setTimeout(() => {
// 测试错误通知
this.cerumusic.NoticeCenter('error', {
@@ -71,7 +71,7 @@ async function musicUrl(source, musicInfo, quality) {
error: '模拟的错误信息'
})
}, 5000)
// 返回一个测试URL
return 'https://example.com/test-music.mp3'
}
@@ -81,4 +81,4 @@ module.exports = {
pluginInfo,
sources,
musicUrl
}
}

View File

@@ -81,27 +81,27 @@ this.cerumusic.NoticeCenter('update', {
#### 通用参数 (data 对象)
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| title | string | 否 | 通知标题,不提供时使用默认标题 |
| message | string | 否 | 通知消息内容 |
| content | string | 否 | 详细内容(与 message 二选一) |
| 参数 | 类型 | 必填 | 说明 |
| ------- | ------ | ---- | ------------------------------ |
| title | string | 否 | 通知标题,不提供时使用默认标题 |
| message | string | 否 | 通知消息内容 |
| content | string | 否 | 详细内容(与 message 二选一) |
#### 更新通知特有参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| url | string | 是 | 插件更新下载链接 |
| version | string | 否 | 新版本号 |
| pluginInfo.name | string | 否 | 插件名称 |
| pluginInfo.type | 'cr' \| 'lx' | 否 | 插件类型 |
| pluginInfo.forcedUpdate | boolean | 否 | 是否强制更新 |
| 参数 | 类型 | 必填 | 说明 |
| ----------------------- | ------------ | ---- | ---------------- |
| url | string | 是 | 插件更新下载链接 |
| version | string | 否 | 新版本号 |
| pluginInfo.name | string | 否 | 插件名称 |
| pluginInfo.type | 'cr' \| 'lx' | 否 | 插件类型 |
| pluginInfo.forcedUpdate | boolean | 否 | 是否强制更新 |
#### 错误通知特有参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| error | string | 否 | 具体错误信息 |
| 参数 | 类型 | 必填 | 说明 |
| ----- | ------ | ---- | ------------ |
| error | string | 否 | 具体错误信息 |
## 实现原理
@@ -208,8 +208,9 @@ window.api.on('plugin-notice', (_, notice) => {
## 更新日志
### v1.0.0 (2025-09-20)
- ✨ 初始版本发布
- ✨ 支持 5 种通知类型
- ✨ 完整的 TypeScript 类型定义
- ✨ 响应式设计和深色主题支持
- ✨ 完善的错误处理机制
- ✨ 完善的错误处理机制

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({
@@ -37,13 +38,20 @@ export default defineConfig({
library: 'vue-next'
})
],
imports: [
'vue',
{
'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar']
}
],
dts: true
}),
Components({
resolvers: [
TDesignResolver({
library: 'vue-next'
})
}),
NaiveUiResolver()
],
dts: true
})

View File

@@ -1,6 +1,6 @@
{
"name": "ceru-music",
"version": "1.3.3",
"version": "1.4.3",
"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/howler": "^2.2.12",
"@types/needle": "^3.3.0",
"NeteaseCloudMusicApi": "^4.27.0",
"animate.css": "^4.1.1",
@@ -53,6 +57,7 @@
"dompurify": "^3.2.6",
"electron-log": "^5.4.3",
"electron-updater": "^6.3.9",
"howler": "^2.2.4",
"hpagent": "^1.2.0",
"iconv-lite": "^0.7.0",
"jss": "^10.10.0",
@@ -63,7 +68,10 @@
"mitt": "^3.0.1",
"needle": "^3.3.1",
"node-fetch": "2",
"node-taglib-sharp": "^6.0.1",
"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"
@@ -80,12 +88,14 @@
"@types/node": "^22.16.5",
"@types/node-fetch": "^2.6.13",
"@vitejs/plugin-vue": "^6.0.0",
"@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",

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,55 +1,79 @@
const fs = require('fs');
const path = require('path');
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)
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 (
basename.startsWith('.') &&
basename !== '.' &&
basename !== '..' &&
!['.github', '.workflow'].includes(basename)
) {
return
}
if (excludeDirs.includes(basename)) {
return;
return
}
// 当前项目显示
if (prefix === '') {
console.log(`${basename}/`);
console.log(`${basename}/`)
} else {
const connector = isLast ? '└── ' : '├── ';
const displayName = fs.statSync(dir).isDirectory() ? `${basename}/` : basename;
console.log(prefix + connector + displayName);
const connector = isLast ? '└── ' : '├── '
const displayName = fs.statSync(dir).isDirectory() ? `${basename}/` : basename
console.log(prefix + connector + displayName)
}
if (!fs.statSync(dir).isDirectory()) {
return;
return
}
try {
const items = fs.readdirSync(dir)
.filter(item => !item.startsWith('.') || ['.github', '.workflow'].includes(item))
.filter(item => !excludeDirs.includes(item))
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 ? ' ' : '│ ');
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);
});
const isLastItem = index === items.length - 1
generateTree(path.join(dir, item), newPrefix, isLastItem, excludeDirs)
})
} catch (error) {
console.error(`Error reading directory: ${dir}`, error.message);
console.error(`Error reading directory: ${dir}`, error.message)
}
}
// 使用示例
const targetDir = process.argv[2] || '.';
console.log('项目文件结构:');
generateTree(targetDir);
const targetDir = process.argv[2] || '.'
console.log('项目文件结构:')
generateTree(targetDir)

View File

@@ -0,0 +1,92 @@
export const QUALITY_ORDER = [
'master',
'atmos_plus',
'atmos',
'hires',
'flac24bit',
'flac',
'320k',
'192k',
'128k'
] as const
export type KnownQuality = (typeof QUALITY_ORDER)[number]
export type QualityInput = KnownQuality | string | { type: string; size?: string }
const DISPLAY_NAME_MAP: Record<string, string> = {
'128k': '标准',
'192k': '高品',
'320k': '超高',
flac: '无损',
flac24bit: '超高解析',
hires: '高清臻音',
atmos: '全景环绕',
atmos_plus: '全景增强',
master: '超清母带'
}
/**
* 统一获取音质中文显示名称
*/
export function getQualityDisplayName(quality: QualityInput | null | undefined): string {
if (!quality) return ''
const type = typeof quality === 'object' ? (quality as any).type : quality
return DISPLAY_NAME_MAP[type] || String(type || '')
}
/**
* 比较两个音质优先级(返回负数表示 a 优于 b
*/
export function compareQuality(aType: string, bType: string): number {
const ia = QUALITY_ORDER.indexOf(aType as KnownQuality)
const ib = QUALITY_ORDER.indexOf(bType as KnownQuality)
const va = ia === -1 ? QUALITY_ORDER.length : ia
const vb = ib === -1 ? QUALITY_ORDER.length : ib
return va - vb
}
/**
* 规范化 types兼容 string 与 {type,size}
*/
export function normalizeTypes(
types: Array<string | { type: string; size?: string }> | null | undefined
): string[] {
if (!types || !Array.isArray(types)) return []
return types
.map((t) => (typeof t === 'object' ? (t as any).type : t))
.filter((t): t is string => Boolean(t))
}
/**
* 获取数组中最高音质类型
*/
export function getHighestQualityType(
types: Array<string | { type: string; size?: string }> | null | undefined
): string | null {
const arr = normalizeTypes(types)
if (!arr.length) return null
return arr.sort(compareQuality)[0]
}
/**
* 构建并按优先级排序的 [{type, size}] 列表
* 支持传入:
* - 数组:[{type,size}]
* - _types 映射:{ [type]: { size } }
*/
export function buildQualityFormats(
input:
| Array<{ type: string; size?: string }>
| Record<string, { size?: string }>
| null
| undefined
): Array<{ type: string; size?: string }> {
if (!input) return []
let list: Array<{ type: string; size?: string }>
if (Array.isArray(input)) {
list = input.map((i) => ({ type: i.type, size: i.size }))
} else {
list = Object.keys(input).map((k) => ({ type: k, size: input[k]?.size }))
}
return list.sort((a, b) => compareQuality(a.type, b.type))
}

View File

@@ -1,61 +1,19 @@
import { ipcMain, dialog, app } from 'electron'
import { join } from 'path'
import fs from 'fs'
import { promisify } from 'util'
const mkdir = promisify(fs.mkdir)
const access = promisify(fs.access)
export const CONFIG_NAME = 'sqj_config.json'
// 默认目录配置
const getDefaultDirectories = () => {
const userDataPath = app.getPath('userData')
return {
cacheDir: join(userDataPath, 'music-cache'),
downloadDir: join(app.getPath('music'), 'CeruMusic/songs')
}
}
// 确保目录存在
const ensureDirectoryExists = async (dirPath: string) => {
try {
await access(dirPath)
} catch {
await mkdir(dirPath, { recursive: true })
}
}
import { ipcMain, dialog } from 'electron'
import { configManager } from '../services/ConfigManager'
// 获取当前目录配置
ipcMain.handle('directory-settings:get-directories', async () => {
try {
const defaults = getDefaultDirectories()
// 从配置文件读取用户设置的目录
const configPath = join(app.getPath('userData'), CONFIG_NAME)
let userConfig: any = {}
try {
const configData = fs.readFileSync(configPath, 'utf-8')
userConfig = JSON.parse(configData)
} catch {
// 配置文件不存在或读取失败,使用默认配置
}
const directories = {
cacheDir: userConfig.cacheDir || defaults.cacheDir,
downloadDir: userConfig.downloadDir || defaults.downloadDir
}
const directories = configManager.getDirectories()
// 确保目录存在
await ensureDirectoryExists(directories.cacheDir)
await ensureDirectoryExists(directories.downloadDir)
await configManager.ensureDirectoryExists(directories.cacheDir)
await configManager.ensureDirectoryExists(directories.downloadDir)
return directories
} catch (error) {
console.error('获取目录配置失败:', error)
const defaults = getDefaultDirectories()
return defaults
return configManager.getDirectories() // 返回默认配置
}
})
@@ -70,7 +28,7 @@ ipcMain.handle('directory-settings:select-cache-dir', async () => {
if (!result.canceled && result.filePaths.length > 0) {
const selectedPath = result.filePaths[0]
await ensureDirectoryExists(selectedPath)
await configManager.ensureDirectoryExists(selectedPath)
return { success: true, path: selectedPath }
}
@@ -92,7 +50,7 @@ ipcMain.handle('directory-settings:select-download-dir', async () => {
if (!result.canceled && result.filePaths.length > 0) {
const selectedPath = result.filePaths[0]
await ensureDirectoryExists(selectedPath)
await configManager.ensureDirectoryExists(selectedPath)
return { success: true, path: selectedPath }
}
@@ -106,16 +64,8 @@ ipcMain.handle('directory-settings:select-download-dir', async () => {
// 保存目录配置
ipcMain.handle('directory-settings:save-directories', async (_, directories) => {
try {
const configPath = join(app.getPath('userData'), CONFIG_NAME)
// 确保目录存在
await ensureDirectoryExists(directories.cacheDir)
await ensureDirectoryExists(directories.downloadDir)
// 保存配置
fs.writeFileSync(configPath, JSON.stringify(directories, null, 2))
return { success: true, message: '目录配置已保存' }
const success = await configManager.saveDirectories(directories)
return { success, message: success ? '目录配置已保存' : '保存配置失败' }
} catch (error) {
console.error('保存目录配置失败:', error)
return { success: false, message: '保存配置失败' }
@@ -125,21 +75,19 @@ ipcMain.handle('directory-settings:save-directories', async (_, directories) =>
// 重置为默认目录
ipcMain.handle('directory-settings:reset-directories', async () => {
try {
const defaults = getDefaultDirectories()
const configPath = join(app.getPath('userData'), CONFIG_NAME)
// 重置目录配置
configManager.delete('cacheDir')
configManager.delete('downloadDir')
configManager.saveConfig()
// 删除配置文件
try {
fs.unlinkSync(configPath)
} catch {
// 文件不存在,忽略错误
}
// 获取默认目录
const directories = configManager.getDirectories()
// 确保默认目录存在
await ensureDirectoryExists(defaults.cacheDir)
await ensureDirectoryExists(defaults.downloadDir)
await configManager.ensureDirectoryExists(directories.cacheDir)
await configManager.ensureDirectoryExists(directories.downloadDir)
return { success: true, directories: defaults }
return { success: true, directories }
} catch (error) {
console.error('重置目录配置失败:', error)
return { success: false, message: '重置配置失败' }
@@ -161,6 +109,9 @@ ipcMain.handle('directory-settings:open-directory', async (_, dirPath) => {
// 获取目录大小
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

150
src/main/events/index.ts Normal file
View File

@@ -0,0 +1,150 @@
import InitPluginService from './plugins'
import '../services/musicSdk/index'
import aiEvents from '../events/ai'
import { app, powerSaveBlocker, Menu } from 'electron'
import path from 'node:path'
import { type BrowserWindow, Tray, ipcMain } from 'electron'
export default function InitEventServices(mainWindow: BrowserWindow) {
InitPluginService()
aiEvents(mainWindow)
basisEvent(mainWindow)
}
function basisEvent(mainWindow: BrowserWindow) {
let psbId: number | null = null
let tray: Tray | null = null
let isQuitting = false
function createTray(): void {
// 创建系统托盘
const trayIconPath = path.join(__dirname, '../../resources/logo.png')
tray = new Tray(trayIconPath)
// 创建托盘菜单
const contextMenu = Menu.buildFromTemplate([
{
label: '显示窗口',
click: () => {
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
}
}
},
{
label: '播放/暂停',
click: () => {
// 这里可以添加播放控制逻辑
console.log('music-control')
mainWindow?.webContents.send('music-control')
}
},
{ type: 'separator' },
{
label: '退出',
click: () => {
isQuitting = true
app.quit()
}
}
])
tray.setContextMenu(contextMenu)
tray.setToolTip('Ceru Music')
// 单击托盘图标显示窗口
tray.on('click', () => {
if (mainWindow) {
if (mainWindow.isVisible()) {
mainWindow.hide()
} else {
mainWindow.show()
mainWindow.focus()
}
}
})
}
createTray()
// 应用退出前的清理
app.on('before-quit', () => {
isQuitting = true
})
// 窗口控制 IPC 处理
ipcMain.on('window-minimize', () => {
mainWindow.minimize()
})
ipcMain.on('window-maximize', () => {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize()
} else {
mainWindow.maximize()
}
})
ipcMain.on('window-close', () => {
mainWindow.close()
})
// Mini 模式 IPC 处理 - 最小化到系统托盘
ipcMain.on('window-mini-mode', (_, isMini) => {
if (mainWindow) {
if (isMini) {
// 进入 Mini 模式:隐藏窗口到系统托盘
mainWindow.hide()
// 显示托盘通知(可选)
if (tray) {
tray.displayBalloon({
title: '澜音 Music',
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
})
}
} else {
// 退出 Mini 模式:显示窗口
mainWindow.show()
mainWindow.focus()
}
}
})
// 全屏模式 IPC 处理
ipcMain.on('window-toggle-fullscreen', () => {
const isFullScreen = mainWindow.isFullScreen()
mainWindow.setFullScreen(!isFullScreen)
})
// 阻止窗口关闭,改为隐藏到系统托盘
mainWindow.on('close', (event) => {
if (!isQuitting) {
event.preventDefault()
mainWindow?.hide()
// 显示托盘通知
if (tray) {
tray.displayBalloon({
title: 'Ceru Music',
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
})
}
}
})
// 阻止系统息屏 IPC开启/关闭)
ipcMain.handle('power-save-blocker:start', () => {
if (psbId == null) {
psbId = powerSaveBlocker.start('prevent-display-sleep')
}
return psbId
})
ipcMain.handle('power-save-blocker:stop', () => {
if (psbId != null && powerSaveBlocker.isStarted(psbId)) {
powerSaveBlocker.stop(psbId)
}
psbId = null
return true
})
// 获取应用版本号
ipcMain.handle('get-app-version', () => {
return app.getVersion()
})
}

View File

@@ -0,0 +1,82 @@
import { ipcMain } from 'electron'
import pluginService from '../services/plugin'
function PluginEvent() {
ipcMain.handle('service-plugin-selectAndAddPlugin', async (_, type): Promise<any> => {
try {
return await pluginService.selectAndAddPlugin(type)
} catch (error: any) {
console.error('Error selecting and adding plugin:', error)
return { error: error.message }
}
})
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)
} catch (error: any) {
console.error('Error adding plugin:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-getPluginById', async (_, id): Promise<any> => {
try {
return pluginService.getPluginById(id)
} catch (error: any) {
console.error('Error getting plugin by id:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-loadAllPlugins', async (): Promise<any> => {
try {
// 使用新的 getPluginsList 方法,但保持 API 兼容性
return await pluginService.getPluginsList()
} catch (error: any) {
console.error('Error loading all plugins:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-getPluginLog', async (_, pluginId): Promise<any> => {
try {
return await pluginService.getPluginLog(pluginId)
} catch (error: any) {
console.error('Error getting plugin log:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-uninstallPlugin', async (_, pluginId): Promise<any> => {
try {
return await pluginService.uninstallPlugin(pluginId)
} catch (error: any) {
console.error('Error uninstalling plugin:', error)
return { error: error.message }
}
})
}
export default function InitPluginService() {
setTimeout(async () => {
// 初始化插件系统
try {
await pluginService.initializePlugins()
PluginEvent()
console.log('插件系统初始化完成')
} catch (error) {
console.error('插件系统初始化失败:', error)
}
}, 1000)
}

View File

@@ -1,12 +1,16 @@
import { app, shell, BrowserWindow, ipcMain, Tray, Menu } from 'electron'
import { app, shell, BrowserWindow, ipcMain, 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'
import InitEventServices from './events'
import './events/musicCache'
import './events/songList'
import './events/directorySettings'
import './events/pluginNotice'
// 获取单实例锁
const gotTheLock = app.requestSingleInstanceLock()
@@ -24,85 +28,30 @@ if (!gotTheLock) {
}
})
}
// import wy from './utils/musicSdk/wy/index'
// import kg from './utils/musicSdk/kg/index'
// wy.hotSearch.getList().then((res) => {
// console.log(res)
// })
// kg.hotSearch.getList().then((res) => {
// console.log(res)
// })
let tray: Tray | null = null
let mainWindow: BrowserWindow | null = null
let isQuitting = false
function createTray(): void {
// 创建系统托盘
const trayIconPath = path.join(__dirname, '../../resources/logo.png')
tray = new Tray(trayIconPath)
// 创建托盘菜单
const contextMenu = Menu.buildFromTemplate([
{
label: '显示窗口',
click: () => {
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
}
}
},
{
label: '播放/暂停',
click: () => {
// 这里可以添加播放控制逻辑
console.log('music-control')
mainWindow?.webContents.send('music-control')
}
},
{ type: 'separator' },
{
label: '退出',
click: () => {
isQuitting = true
app.quit()
}
}
])
tray.setContextMenu(contextMenu)
tray.setToolTip('Ceru Music')
// 双击托盘图标显示窗口
tray.on('click', () => {
if (mainWindow) {
if (mainWindow.isVisible()) {
mainWindow.hide()
} else {
mainWindow.show()
mainWindow.focus()
}
}
})
}
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'),
@@ -112,35 +61,65 @@ 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()
})
// 阻止窗口关闭,改为隐藏到系统托盘
mainWindow.on('close', (event) => {
if (!isQuitting) {
event.preventDefault()
mainWindow?.hide()
// 显示托盘通知
if (tray) {
tray.displayBalloon({
iconType: 'info',
title: 'Ceru Music',
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
})
}
}
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url).then()
return { action: 'deny' }
})
InitEventServices(mainWindow)
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
@@ -150,75 +129,6 @@ function createWindow(): void {
}
}
ipcMain.handle('service-plugin-selectAndAddPlugin', async (_, type): Promise<any> => {
try {
return await pluginService.selectAndAddPlugin(type)
} catch (error: any) {
console.error('Error selecting 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)
} catch (error: any) {
console.error('Error adding plugin:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-getPluginById', async (_, id): Promise<any> => {
try {
return pluginService.getPluginById(id)
} catch (error: any) {
console.error('Error getting plugin by id:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-loadAllPlugins', async (): Promise<any> => {
try {
// 使用新的 getPluginsList 方法,但保持 API 兼容性
return await pluginService.getPluginsList()
} catch (error: any) {
console.error('Error loading all plugins:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-getPluginLog', async (_, pluginId): Promise<any> => {
try {
return await pluginService.getPluginLog(pluginId)
} catch (error: any) {
console.error('Error getting plugin log:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-uninstallPlugin', async (_, pluginId): Promise<any> => {
try {
return await pluginService.uninstallPlugin(pluginId)
} catch (error: any) {
console.error('Error uninstalling plugin:', error)
return { error: error.message }
}
})
ipcMain.handle('service-music-request', async (_, api, args) => {
return await musicService.request(api, args)
})
// 获取应用版本号
ipcMain.handle('get-app-version', () => {
return app.getVersion()
})
aiEvents(mainWindow)
import './events/musicCache'
import './events/songList'
import './events/directorySettings'
import './events/pluginNotice'
import { registerAutoUpdateEvents, initAutoUpdateForWindow } from './events/autoUpdate'
// This method will be called when Electron has finished
@@ -235,16 +145,6 @@ app.whenReady().then(() => {
app.setName('澜音')
}
setTimeout(async () => {
// 初始化插件系统
try {
await pluginService.initializePlugins()
console.log('插件系统初始化完成')
} catch (error) {
console.error('插件系统初始化失败:', error)
}
}, 1000)
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
@@ -252,63 +152,7 @@ app.whenReady().then(() => {
optimizer.watchWindowShortcuts(window)
})
// 窗口控制 IPC 处理
ipcMain.on('window-minimize', () => {
const window = BrowserWindow.getFocusedWindow()
if (window) {
window.minimize()
}
})
ipcMain.on('window-maximize', () => {
const window = BrowserWindow.getFocusedWindow()
if (window) {
if (window.isMaximized()) {
window.unmaximize()
} else {
window.maximize()
}
}
})
ipcMain.on('window-close', () => {
const window = BrowserWindow.getFocusedWindow()
if (window) {
window.close()
}
})
// Mini 模式 IPC 处理 - 最小化到系统托盘
ipcMain.on('window-mini-mode', (_, isMini) => {
if (mainWindow) {
if (isMini) {
// 进入 Mini 模式:隐藏窗口到系统托盘
mainWindow.hide()
// 显示托盘通知(可选)
if (tray) {
tray.displayBalloon({
title: '澜音 Music',
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
})
}
} else {
// 退出 Mini 模式:显示窗口
mainWindow.show()
mainWindow.focus()
}
}
})
// 全屏模式 IPC 处理
ipcMain.on('window-toggle-fullscreen', () => {
if (mainWindow) {
const isFullScreen = mainWindow.isFullScreen()
mainWindow.setFullScreen(!isFullScreen)
}
})
createWindow()
createTray()
// 注册自动更新事件
registerAutoUpdateEvents()
@@ -338,67 +182,19 @@ app.on('window-all-closed', () => {
// 在其他平台上,我们也保持应用运行,因为有系统托盘
})
// 应用退出前的清理
app.on('before-quit', () => {
isQuitting = true
})
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.
let ping: NodeJS.Timeout
function startPing() {
let interval = 3000
// 已迁移到 Howler不再使用 DOM <audio> 轮询。
// 如需主进程感知播放结束,请在渲染进程通过 IPC 主动通知。
if (ping) {
clearInterval(ping)
}
ping = setInterval(() => {
if (mainWindow) {
mainWindow.webContents
.executeJavaScript(
`
(function() {
const audio = document.getElementById("globaAudio");
if(!audio) return { playing:false, ended: false };
if(audio.ended) return { playing:false, ended: true };
return { playing: !audio.paused, ended: false, currentTime: audio.currentTime, duration: audio.duration };
})()
`
)
.then((res) => {
console.log(res)
if (res.duration - res.currentTime <= 20) {
clearInterval(ping)
interval = 500
ping = setInterval(() => {
if (mainWindow) {
mainWindow.webContents
.executeJavaScript(
`
(function() {
const audio = document.getElementById("globaAudio");
if(!audio) return { playing:false, ended: false };
if(audio.ended) return { playing:false, ended: true };
return { playing: !audio.paused, ended: false, currentTime: audio.currentTime, duration: audio.duration };
})()
`
)
.then((res) => {
console.log(res)
if (res && res.ended) {
mainWindow?.webContents.send('song-ended')
console.log('next song')
clearInterval(ping)
}
})
.catch((err) => console.warn(err))
}
}, interval)
}
})
.catch((err) => console.warn(err))
}
}, interval)
// 保留占位,避免调用方报错;不再做任何轮询。
// 可在此处监听自定义 IPC 事件以扩展行为。
clearInterval(ping)
}, 1000)
}

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,9 +1,8 @@
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 { CONFIG_NAME } from '../../events/directorySettings'
import { configManager } from '../ConfigManager'
export class MusicCacheService {
private cacheIndex: Map<string, string> = new Map()
@@ -13,21 +12,9 @@ export class MusicCacheService {
}
private getCacheDirectory(): string {
try {
// 尝试从配置文件读取自定义缓存目录
const configPath = path.join(app.getPath('userData'), CONFIG_NAME)
const configData = require('fs').readFileSync(configPath, 'utf-8')
const config = JSON.parse(configData)
if (config.cacheDir && typeof config.cacheDir === 'string') {
return config.cacheDir
}
} catch {
// 配置文件不存在或读取失败,使用默认目录
}
// 默认缓存目录
return path.join(app.getPath('userData'), 'music-cache')
// 使用配置管理服务获取缓存目录
const directories = configManager.getDirectories()
return directories.cacheDir
}
// 动态获取缓存目录

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,21 +7,13 @@ import {
PlaylistResult,
GetSongListDetailsArg,
PlaylistDetailResult,
DownloadSingleSongArgs
DownloadSingleSongArgs,
TipSearchResult
} from './type'
import pluginService from '../plugin/index'
import musicSdk from '../../utils/musicSdk/index'
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'
import { app } from 'electron'
import { CONFIG_NAME } from '../../events/directorySettings'
const fileLock: Record<string, boolean> = {}
import download from '../../utils/downloadSongs'
function main(source: string) {
const Api = musicSdk[source]
@@ -30,7 +22,15 @@ 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: '请配置音源来播放歌曲' }
@@ -38,18 +38,22 @@ function main(source: string) {
// 生成歌曲唯一标识
const songId = `${songInfo.name}-${songInfo.singer}-${source}-${quality}`
// 先检查缓存
const cachedUrl = await musicCacheService.getCachedMusicUrl(songId)
if (cachedUrl) {
return cachedUrl
// 先检查缓存isCache !== false 时)
if (isCache !== false) {
const cachedUrl = await musicCacheService.getCachedMusicUrl(songId)
if (cachedUrl) {
return cachedUrl
}
}
// 没有缓存时才发起网络请求
const originalUrl = await usePlugin.getMusicUrl(source, songInfo, quality)
// 异步缓存,不阻塞返回
musicCacheService.cacheMusic(songId, originalUrl).catch((error) => {
console.warn('缓存歌曲失败:', error)
})
// 按需异步缓存,不阻塞返回
if (isCache !== false) {
musicCacheService.cacheMusic(songId, originalUrl).catch((error) => {
console.warn('缓存歌曲失败:', error)
})
}
return originalUrl
} catch (e: any) {
@@ -92,97 +96,15 @@ function main(source: string) {
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('无法获取歌曲链接')
// 获取自定义下载目录
const getDownloadDirectory = (): string => {
try {
const configPath = path.join(app.getPath('userData'), CONFIG_NAME)
const configData = fs.readFileSync(configPath, 'utf-8')
const config = JSON.parse(configData)
if (config.downloadDir && typeof config.downloadDir === 'string') {
return config.downloadDir
}
} catch {
// 配置文件不存在或读取失败,使用默认目录
}
// 默认下载目录
return path.join(app.getPath('music'), 'CeruMusic/songs')
}
// 从URL中提取文件扩展名如果没有则默认为mp3
const getFileExtension = (url: string): string => {
try {
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 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]
}
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

@@ -0,0 +1,331 @@
import { File, Picture, Id3v2Settings } from 'node-taglib-sharp'
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')}`
}
// 根据图片 URL 和 Content-Type 解析扩展名,默认返回 .jpg
function resolveCoverExt(imgUrl: string, contentType?: string): string {
const validExts = new Set(['.jpg', '.jpeg', '.png', '.webp', '.bmp'])
let urlExt: string | undefined
try {
const pathname = new URL(imgUrl).pathname
const i = pathname.lastIndexOf('.')
if (i !== -1) {
urlExt = pathname.substring(i).toLowerCase()
}
} catch {}
if (urlExt && validExts.has(urlExt)) {
return urlExt === '.jpeg' ? '.jpg' : urlExt
}
if (contentType) {
if (contentType.includes('image/png')) return '.png'
if (contentType.includes('image/webp')) return '.webp'
if (contentType.includes('image/jpeg') || contentType.includes('image/jpg')) return '.jpg'
if (contentType.includes('image/bmp')) return '.bmp'
}
return '.jpg'
}
/**
* 转换新格式:[开始时间,持续时间](开始时间,字符持续时间,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}`
}
// 获取自定义下载目录
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]
}
// 写入标签信息(使用 node-taglib-sharp
if (tagWriteOptions && fs.existsSync(songPath)) {
try {
const baseName = path.basename(songPath, path.extname(songPath))
const dirName = path.dirname(songPath)
let coverExt = '.jpg'
let coverPath = path.join(dirName, `${baseName}${coverExt}`)
let coverDownloaded = false
// 下载封面仅当启用且有URL
if (tagWriteOptions.cover && songInfo?.img) {
try {
const coverRes = await axios.get(songInfo.img, {
responseType: 'arraybuffer',
timeout: 10000
})
const ct =
(coverRes.headers && (coverRes.headers['content-type'] as string | undefined)) ||
undefined
coverExt = resolveCoverExt(songInfo.img, ct)
coverPath = path.join(dirName, `${baseName}${coverExt}`)
await fsPromise.writeFile(coverPath, Buffer.from(coverRes.data))
coverDownloaded = true
} catch (e) {
console.warn('下载封面失败:', e instanceof Error ? e.message : e)
}
}
// 读取歌曲文件并设置标签
const songFile = File.createFromPath(songPath)
// 使用默认 ID3v2.3
Id3v2Settings.forceDefaultVersion = true
Id3v2Settings.defaultVersion = 3
songFile.tag.title = songInfo?.name || '未知曲目'
songFile.tag.album = songInfo?.albumName || '未知专辑'
const artists = songInfo?.singer ? [songInfo.singer] : ['未知艺术家']
songFile.tag.performers = artists
songFile.tag.albumArtists = artists
// 写入歌词(转换为标准 LRC
if (tagWriteOptions.lyrics && songInfo?.lrc) {
const convertedLrc = convertLrcFormat(songInfo.lrc)
songFile.tag.lyrics = convertedLrc
}
// 写入封面
if (tagWriteOptions.cover && coverDownloaded) {
const songCover = Picture.fromPath(coverPath)
songFile.tag.pictures = [songCover]
}
// 保存并释放
songFile.save()
songFile.dispose()
// 删除临时封面
if (coverDownloaded) {
try {
await fsPromise.unlink(coverPath)
} catch {}
}
} catch (error) {
console.warn('写入音乐元信息失败:', error)
}
}
return {
message: '下载成功',
path: songPath
}
}

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,

View File

@@ -1,6 +1,7 @@
import { httpFetch } from '../../request'
import { decodeName, formatPlayTime, sizeFormate } from '../index'
import { decodeName, formatPlayTime } from '../../index'
import { formatSingerName } from '../utils'
import { getBatchMusicQualityInfo } from './quality_detail'
export default {
limit: 30,
@@ -9,87 +10,72 @@ export default {
allPage: 1,
musicSearch(str, page, limit) {
const searchRequest = httpFetch(
`https://songsearch.kugou.com/song_search_v2?keyword=${encodeURIComponent(str)}&page=${page}&pagesize=${limit}&userid=0&clientver=&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0&area_code=1`
`https://songsearch.kugou.com/song_search_v2?keyword=${encodeURIComponent(
str
)}&page=${page}&pagesize=${limit}&userid=0&clientver=&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0&area_code=1`
)
return searchRequest.promise.then(({ body }) => body)
},
filterData(rawData) {
const types = []
const _types = {}
if (rawData.FileSize !== 0) {
const size = sizeFormate(rawData.FileSize)
types.push({ type: '128k', size, hash: rawData.FileHash })
_types['128k'] = {
size,
hash: rawData.FileHash
}
}
if (rawData.HQFileSize !== 0) {
const size = sizeFormate(rawData.HQFileSize)
types.push({ type: '320k', size, hash: rawData.HQFileHash })
_types['320k'] = {
size,
hash: rawData.HQFileHash
}
}
if (rawData.SQFileSize !== 0) {
const size = sizeFormate(rawData.SQFileSize)
types.push({ type: 'flac', size, hash: rawData.SQFileHash })
_types.flac = {
size,
hash: rawData.SQFileHash
}
}
if (rawData.ResFileSize !== 0) {
const size = sizeFormate(rawData.ResFileSize)
types.push({ type: 'flac24bit', size, hash: rawData.ResFileHash })
_types.flac24bit = {
size,
hash: rawData.ResFileHash
}
}
return {
singer: decodeName(formatSingerName(rawData.Singers, 'name')),
name: decodeName(rawData.SongName),
albumName: decodeName(rawData.AlbumName),
albumId: rawData.AlbumID,
songmid: rawData.Audioid,
source: 'kg',
interval: formatPlayTime(rawData.Duration),
_interval: rawData.Duration,
img: null,
lrc: null,
otherSource: null,
hash: rawData.FileHash,
types,
_types,
typeUrl: {}
}
},
handleResult(rawData) {
const ids = new Set()
const list = []
async handleResult(rawData) {
let ids = new Set()
const items = []
rawData.forEach((item) => {
const key = item.Audioid + item.FileHash
if (ids.has(key)) return
ids.add(key)
list.push(this.filterData(item))
for (const childItem of item.Grp) {
const key = item.Audioid + item.FileHash
if (ids.has(key)) continue
if (!ids.has(key)) {
ids.add(key)
list.push(this.filterData(childItem))
items.push(item)
}
for (const childItem of item.Grp || []) {
const childKey = childItem.Audioid + childItem.FileHash
if (!ids.has(childKey)) {
ids.add(childKey)
items.push(childItem)
}
}
})
const hashList = items.map((item) => item.FileHash)
let qualityInfoMap = {}
try {
const qualityInfoRequest = getBatchMusicQualityInfo(hashList)
qualityInfoMap = await qualityInfoRequest.promise
} catch (error) {
console.error('Failed to fetch quality info:', error)
}
return items.map((item) => {
const { types = [], _types = {} } = qualityInfoMap[item.FileHash] || {}
return {
singer: decodeName(formatSingerName(item.Singers, 'name')),
name: decodeName(item.SongName),
albumName: decodeName(item.AlbumName),
albumId: item.AlbumID,
songmid: item.Audioid,
source: 'kg',
interval: formatPlayTime(item.Duration),
_interval: item.Duration,
img: null,
lrc: null,
otherSource: null,
hash: item.FileHash,
types,
_types,
typeUrl: {},
}
})
return list
},
search(str, page = 1, limit, retryNum = 0) {
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
if (limit == null) limit = this.limit
// http://newlyric.kuwo.cn/newlyric.lrc?62355680
return this.musicSearch(str, page, limit).then((result) => {
return this.musicSearch(str, page, limit).then(async (result) => {
if (!result || result.error_code !== 0) return this.search(str, page, limit, retryNum)
const list = this.handleResult(result.data.lists)
let list = await this.handleResult(result.data.lists)
if (list == null) return this.search(str, page, limit, retryNum)
@@ -102,8 +88,8 @@ export default {
allPage: this.allPage,
limit,
total: this.total,
source: 'kg'
source: 'kg',
})
})
}
}
},
}

View File

@@ -0,0 +1,190 @@
import { httpFetch } from '../../request'
import { dnsLookup } from '../utils'
import { headers, timeout } from '../options'
import { sizeFormate, decodeName, formatPlayTime } from '../../index'
import { formatSingerName } from '../utils'
console.log(headers);
export const getBatchMusicQualityInfo = (hashList) => {
const resources = hashList.map((hash) => ({
id: 0,
type: 'audio',
hash,
}))
const requestObj = httpFetch(
`https://gateway.kugou.com/goodsmstore/v1/get_res_privilege?appid=1005&clientver=20049&clienttime=${Date.now()}&mid=NeZha`,
{
method: 'post',
timeout,
headers,
body: {
behavior: 'play',
clientver: '20049',
resource: resources,
area_code: '1',
quality: '128',
qualities: [
'128',
'320',
'flac',
'high',
'dolby',
'viper_atmos',
'viper_tape',
'viper_clear',
],
},
lookup: dnsLookup,
family: 4,
}
)
const qualityInfoMap = {}
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
if (statusCode != 200 || body.error_code != 0)
return Promise.reject(new Error('获取音质信息失败'))
body.data.forEach((songData, index) => {
const hash = hashList[index]
const types = []
const _types = {}
if (!songData || !songData.relate_goods) return
for (const quality_data of songData.relate_goods) {
if (quality_data.quality === '128') {
let size = sizeFormate(quality_data.info.filesize)
types.push({ type: '128k', size, hash: quality_data.hash })
_types['128k'] = {
size,
hash: quality_data.hash,
}
}
if (quality_data.quality === '320') {
let size = sizeFormate(quality_data.info.filesize)
types.push({ type: '320k', size, hash: quality_data.hash })
_types['320k'] = {
size,
hash: quality_data.hash,
}
}
if (quality_data.quality === 'flac') {
let size = sizeFormate(quality_data.info.filesize)
types.push({ type: 'flac', size, hash: quality_data.hash })
_types.flac = {
size,
hash: quality_data.hash,
}
}
if (quality_data.quality === 'high') {
let size = sizeFormate(quality_data.info.filesize)
types.push({ type: 'hires', size, hash: quality_data.hash })
_types.hires = {
size,
hash: quality_data.hash,
}
}
if (quality_data.quality === 'viper_clear') {
let size = sizeFormate(quality_data.info.filesize)
types.push({ type: 'master', size, hash: quality_data.hash })
_types.master = {
size,
hash: quality_data.hash,
}
}
if (quality_data.quality === 'viper_atmos') {
let size = sizeFormate(quality_data.info.filesize)
types.push({ type: 'atmos', size, hash: quality_data.hash })
_types.atmos = {
size,
hash: quality_data.hash,
}
}
}
qualityInfoMap[hash] = { types, _types }
})
return qualityInfoMap
})
return requestObj
}
export const getHashFromItem = (item) => {
if (item.hash) return item.hash
if (item.FileHash) return item.FileHash
if (item.audio_info && item.audio_info.hash) return item.audio_info.hash
return null
}
export const filterData = async (rawList, options = {}) => {
let processedList = rawList
if (options.removeDuplicates) {
let ids = new Set()
processedList = rawList.filter((item) => {
if (!item) return false
const audioId = item.audio_info?.audio_id || item.audio_id
if (ids.has(audioId)) return false
ids.add(audioId)
return true
})
}
const hashList = processedList.map((item) => getHashFromItem(item)).filter((hash) => hash)
const qualityInfoRequest = getBatchMusicQualityInfo(hashList)
let qualityInfoMap = {}
try {
qualityInfoMap = await qualityInfoRequest.promise
} catch (error) {
console.error('Failed to fetch quality info:', error)
}
return processedList.map((item) => {
const hash = getHashFromItem(item)
const { types = [], _types = {} } = qualityInfoMap[hash] || {}
if (item.audio_info) {
return {
name: decodeName(item.songname),
singer: decodeName(item.author_name),
albumName: decodeName(item.album_info?.album_name || item.remark),
albumId: item.album_info.album_id,
songmid: item.audio_info.audio_id,
source: 'kg',
interval: options.fix
? formatPlayTime(parseInt(item.audio_info.timelength) / 1000)
: formatPlayTime(parseInt(item.audio_info.timelength)),
img: null,
lrc: null,
hash: item.audio_info.hash,
otherSource: null,
types,
_types,
typeUrl: {},
}
}
return {
name: decodeName(item.songname),
singer: decodeName(item.singername) || formatSingerName(item.authors, 'author_name'),
albumName: decodeName(item.album_name || item.remark),
albumId: item.album_id,
songmid: item.audio_id,
source: 'kg',
interval: options.fix ? formatPlayTime(item.duration / 1000) : formatPlayTime(item.duration),
img: null,
lrc: null,
hash: item.hash,
types,
_types,
typeUrl: {},
}
})
}

View File

@@ -1,8 +1,8 @@
import { httpFetch } from '../../request'
import { decodeName, formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index'
import { decodeName, dateFormat, formatPlayCount } from '../../index'
import './vendors/infSign.min'
import { signatureParams } from './util'
import { filterData } from './quality_detail'
const handleSignature = (id, page, limit) =>
new Promise((resolve, reject) => {
@@ -14,7 +14,7 @@ const handleSignature = (id, page, limit) =>
isCDN: !0,
callback(i) {
resolve(i.signature)
}
},
}
)
})
@@ -27,36 +27,36 @@ export default {
listDetailLimit: 10000,
currentTagInfo: {
id: undefined,
info: undefined
info: undefined,
},
sortList: [
{
name: '推荐',
id: '5'
id: '5',
},
{
name: '最热',
id: '6'
id: '6',
},
{
name: '最新',
id: '7'
id: '7',
},
{
name: '热藏',
id: '3'
id: '3',
},
{
name: '飙升',
id: '8'
}
id: '8',
},
],
cache: new Map(),
regExps: {
listData: /global\.data = (\[.+\]);/,
listInfo: /global = {[\s\S]+?name: "(.+)"[\s\S]+?pic: "(.+)"[\s\S]+?};/,
// https://www.kugou.com/yy/special/single/1067062.html
listDetailLink: /^.+\/(\d+)\.html(?:\?.*|&.*$|#.*$|$)/
listDetailLink: /^.+\/(\d+)\.html(?:\?.*|&.*$|#.*$|$)/,
},
parseHtmlDesc(html) {
const prefix = '<div class="pc_specail_text pc_singer_tab_content" id="specailIntroduceWrap">'
@@ -71,18 +71,17 @@ export default {
if (tryNum > 2) throw new Error('try max num')
const { body } = await httpFetch(this.getSongListDetailUrl(id)).promise
const listData = body.match(this.regExps.listData)
const listInfo = body.match(this.regExps.listInfo)
let listData = body.match(this.regExps.listData)
let listInfo = body.match(this.regExps.listInfo)
if (!listData) return this.getListDetailBySpecialId(id, page, ++tryNum)
const list = await this.getMusicInfos(JSON.parse(listData[1]))
// listData = this.filterData(JSON.parse(listData[1]))
let list = await this.getMusicInfos(JSON.parse(listData[1]))
let name
let pic
if (listInfo) {
name = listInfo[1]
pic = listInfo[2]
}
const desc = this.parseHtmlDesc(body)
let desc = this.parseHtmlDesc(body)
return {
list,
@@ -93,10 +92,10 @@ export default {
info: {
name,
img: pic,
desc
desc,
// author: body.result.info.userinfo.username,
// play_count: formatPlayCount(body.result.listen_num),
}
},
}
},
getInfoUrl(tagId) {
@@ -116,11 +115,11 @@ export default {
const result = []
if (rawData.status !== 1) return result
for (const key of Object.keys(rawData.data)) {
const tag = rawData.data[key]
let tag = rawData.data[key]
result.push({
id: tag.special_id,
name: tag.special_name,
source: 'kg'
source: 'kg',
})
}
return result
@@ -135,8 +134,8 @@ export default {
parent_name: tag.pname,
id: tag.id,
name: tag.name,
source: 'kg'
}))
source: 'kg',
})),
})
}
return result
@@ -159,7 +158,7 @@ export default {
{
method: 'post',
headers: {
'User-Agent': 'KuGou2012-8275-web_browser_event_handler'
'User-Agent': 'KuGou2012-8275-web_browser_event_handler',
},
body: {
appid: 1001,
@@ -170,8 +169,8 @@ export default {
platform: 'pc',
userid: '262643156',
return_min: 6,
return_max: 15
}
return_max: 15,
},
}
)
return this._requestObj_listRecommend.promise.then(({ body }) => {
@@ -190,7 +189,7 @@ export default {
total: item.songcount,
grade: item.grade,
desc: item.intro,
source: 'kg'
source: 'kg',
}))
},
@@ -219,7 +218,7 @@ export default {
},
createTask(hashs) {
const data = {
let data = {
area_code: '1',
show_privilege: 1,
show_album_info: '1',
@@ -230,16 +229,16 @@ export default {
dfid: '-',
clienttime: Date.now(),
key: 'OIlwieks28dk2k092lksi2UIkp',
fields: 'album_info,author_name,audio_info,ori_audio_name,base,songname'
fields: 'album_info,author_name,audio_info,ori_audio_name,base,songname',
}
let list = hashs
const tasks = []
let tasks = []
while (list.length) {
tasks.push(Object.assign({ data: list.slice(0, 100) }, data))
if (list.length < 100) break
list = list.slice(100)
}
const url = 'http://gateway.kugou.com/v2/album_audio/audio'
let url = 'http://gateway.kugou.com/v2/album_audio/audio'
return tasks.map((task) =>
this.createHttp(url, {
method: 'POST',
@@ -250,13 +249,13 @@ export default {
'KG-Fake': '0',
'KG-RF': '00869891',
'User-Agent': 'Android712-AndroidPhone-11451-376-0-FeeCacheUpdate-wifi',
'x-router': 'kmr.service.kugou.com'
}
'x-router': 'kmr.service.kugou.com',
},
}).then((data) => data.map((s) => s[0]))
)
},
async getMusicInfos(list) {
return this.filterData2(
return await this.filterData(
await Promise.all(
this.createTask(this.deDuplication(list).map((item) => ({ hash: item.hash })))
).then(([...datas]) => datas.flat())
@@ -269,7 +268,7 @@ export default {
headers: {
'KG-RC': 1,
'KG-THash': 'network_super_call.cpp:3676261689:379',
'User-Agent': ''
'User-Agent': '',
},
body: {
appid: 1001,
@@ -277,13 +276,13 @@ export default {
mid: '21511157a05844bd085308bc76ef3343',
clienttime: 640612895,
key: '36164c4015e704673c588ee202b9ecb8',
data: id
}
data: id,
},
})
// console.log(songInfo)
// type 1单曲2歌单3电台4酷狗码5别人的播放队列
let songList
const info = songInfo.info
let info = songInfo.info
switch (info.type) {
case 2:
if (!info.global_collection_id) return this.getListDetailBySpecialId(info.id)
@@ -299,7 +298,7 @@ export default {
headers: {
'KG-RC': 1,
'KG-THash': 'network_super_call.cpp:3676261689:379',
'User-Agent': ''
'User-Agent': '',
},
body: {
appid: 1001,
@@ -313,13 +312,13 @@ export default {
userid: info.userid,
collect_type: 0,
page: 1,
pagesize: info.count
}
}
pagesize: info.count,
},
},
})
// console.log(songList)
}
const list = await this.getMusicInfos(songList || songInfo.list)
let list = await this.getMusicInfos(songList || songInfo.list)
return {
list,
page: 1,
@@ -330,9 +329,9 @@ export default {
name: info.name,
img: (info.img_size && info.img_size.replace('{size}', 240)) || info.img,
// desc: body.result.info.list_desc,
author: info.username
author: info.username,
// play_count: formatPlayCount(info.count),
}
},
}
},
@@ -342,8 +341,8 @@ export default {
{
headers: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1'
}
'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
},
}
)
if (!songInfo.list) {
@@ -354,7 +353,7 @@ export default {
this.getUserListDetail5(chain)
)
}
const list = await this.getMusicInfos(songInfo.list)
let list = await this.getMusicInfos(songInfo.list)
// console.log(info, songInfo)
return {
list,
@@ -366,14 +365,14 @@ export default {
name: songInfo.info.name,
img: songInfo.info.img,
// desc: body.result.info.list_desc,
author: songInfo.info.username
author: songInfo.info.username,
// play_count: formatPlayCount(info.count),
}
},
}
},
deDuplication(datas) {
const ids = new Set()
let ids = new Set()
return datas.filter(({ hash }) => {
if (ids.has(hash)) return false
ids.add(hash)
@@ -388,29 +387,25 @@ export default {
data: [
{
id: gcid,
id_type: 2
}
]
}
const result = await this.createHttp(
`https://t.kugou.com/v1/songlist/batch_decode?${params}&signature=${signatureParams(params, 'android', JSON.stringify(body))}`,
{
method: 'POST',
headers: {
'User-Agent':
'Mozilla/5.0 (Linux; Android 10; HUAWEI HMA-AL00) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36',
Referer: 'https://m.kugou.com/'
id_type: 2,
},
body
}
)
],
}
const result = await this.createHttp(`https://t.kugou.com/v1/songlist/batch_decode?${params}&signature=${signatureParams(params, 'android', JSON.stringify(body))}`, {
method: 'POST',
headers: {
'User-Agent': 'Mozilla/5.0 (Linux; Android 10; HUAWEI HMA-AL00) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36',
Referer: 'https://m.kugou.com/',
},
body,
})
return result.list[0].global_collection_id
},
async getUserListDetailByLink({ info }, link) {
const listInfo = info['0']
let listInfo = info['0']
let total = listInfo.count
const tasks = []
let tasks = []
let page = 0
while (total) {
const limit = total > 90 ? 90 : total
@@ -423,8 +418,8 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
Referer: link
}
Referer: link,
},
}
).then((data) => data.list.info)
)
@@ -442,13 +437,13 @@ export default {
name: listInfo.name,
img: listInfo.pic && listInfo.pic.replace('{size}', 240),
// desc: body.result.info.list_desc,
author: listInfo.list_create_username
author: listInfo.list_create_username,
// play_count: formatPlayCount(listInfo.count),
}
},
}
},
createGetListDetail2Task(id, total) {
const tasks = []
let tasks = []
let page = 0
while (total) {
const limit = total > 300 ? 300 : total
@@ -464,7 +459,10 @@ export default {
'&srcappid=2919&clientver=20000&clienttime=1586163263991&mid=1586163263991&uuid=1586163263991&dfid=-'
tasks.push(
this.createHttp(
`https://mobiles.kugou.com/api/v5/special/song_v2?${params}&signature=${signatureParams(params, 'web')}`,
`https://mobiles.kugou.com/api/v5/special/song_v2?${params}&signature=${signatureParams(
params,
'web'
)}`,
{
headers: {
mid: '1586163263991',
@@ -472,8 +470,8 @@ export default {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
dfid: '-',
clienttime: '1586163263991'
}
clienttime: '1586163263991',
},
}
).then((data) => data.info)
)
@@ -481,14 +479,17 @@ export default {
return Promise.all(tasks).then(([...datas]) => datas.flat())
},
async getUserListDetail2(global_collection_id) {
const id = global_collection_id
let 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=-'
const info = await this.createHttp(
`https://mobiles.kugou.com/api/v5/special/info_v2?${params}&signature=${signatureParams(params, 'web')}`,
let info = await this.createHttp(
`https://mobiles.kugou.com/api/v5/special/info_v2?${params}&signature=${signatureParams(
params,
'web'
)}`,
{
headers: {
mid: '1586163242519',
@@ -496,12 +497,12 @@ export default {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
dfid: '-',
clienttime: '1586163242519'
}
clienttime: '1586163242519',
},
}
)
const songInfo = await this.createGetListDetail2Task(id, info.songcount)
const list = await this.getMusicInfos(songInfo)
let list = await this.getMusicInfos(songInfo)
// console.log(info, songInfo, list)
return {
list,
@@ -514,8 +515,8 @@ export default {
img: info.imgurl && info.imgurl.replace('{size}', 240),
desc: info.intro,
author: info.nickname,
play_count: formatPlayCount(info.playcount)
}
play_count: formatPlayCount(info.playcount),
},
}
},
@@ -524,8 +525,8 @@ export default {
const { body } = await httpFetch(`https://m.kugou.com/share/?chain=${chain}&id=${chain}`, {
headers: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
}
'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1',
},
}).promise
let result = body.match(/var\sphpParam\s=\s({.+?});/)
if (result) result = JSON.parse(result[1])
@@ -534,13 +535,13 @@ export default {
},
async getUserListDetailByPcChain(chain) {
const key = `${chain}_pc_list`
let 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: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36'
}
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
},
}).promise
let result = body.match(/var\sdataFromSmarty\s=\s(\[.+?\])/)
if (result) result = JSON.parse(result[1])
@@ -554,7 +555,7 @@ export default {
const limit = 100
const [listInfo, list] = await Promise.all([
this.getListInfoByChain(chain),
this.getUserListDetailById(songInfo.id, page, limit)
this.getUserListDetailById(songInfo.id, page, limit),
])
return {
list: list || [],
@@ -566,16 +567,16 @@ export default {
name: listInfo.specialname,
img: listInfo.imgurl && listInfo.imgurl.replace('{size}', 240),
// desc: body.result.info.list_desc,
author: listInfo.nickname
author: listInfo.nickname,
// play_count: formatPlayCount(info.count),
}
},
}
},
async getUserListDetail5(chain) {
const [listInfo, list] = await Promise.all([
this.getListInfoByChain(chain),
this.getUserListDetailByPcChain(chain)
this.getUserListDetailByPcChain(chain),
])
return {
list: list || [],
@@ -587,28 +588,28 @@ export default {
name: listInfo.specialname,
img: listInfo.imgurl && listInfo.imgurl.replace('{size}', 240),
// desc: body.result.info.list_desc,
author: listInfo.nickname
author: listInfo.nickname,
// play_count: formatPlayCount(info.count),
}
},
}
},
async getUserListDetailById(id, page, limit) {
const signature = await handleSignature(id, page, limit)
const info = await this.createHttp(
let 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: {
Referer: 'https://m3ws.kugou.com/share/index.php',
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
dfid: '-'
}
dfid: '-',
},
}
)
// console.log(info)
const result = await this.getMusicInfos(info.info)
let result = await this.getMusicInfos(info.info)
// console.log(info, songInfo)
return result
},
@@ -616,19 +617,15 @@ export default {
async getUserListDetail(link, page, retryNum = 0) {
if (retryNum > 3) return Promise.reject(new Error('link try max num'))
if (link.includes('#')) link = link.replace(/#.*$/, '')
if (link.includes('global_collection_id'))
return this.getUserListDetail2(
link.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1')
)
if (link.includes('global_collection_id')) return this.getUserListDetail2(link.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1'))
if (link.includes('gcid_')) {
const gcid = link.match(/gcid_\w+/)?.[0]
let 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)
}
}
if (link.includes('chain='))
return this.getUserListDetail3(link.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page)
if (link.includes('chain=')) return this.getUserListDetail3(link.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page)
if (link.includes('.html')) {
if (link.includes('zlist.html')) {
link = link.replace(/^(.*)zlist\.html/, 'https://m3ws.kugou.com/zlist/list')
@@ -650,34 +647,27 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
Referer: link
}
Referer: link,
},
})
const {
headers: { location },
statusCode,
body
body,
} = await requestObj_listDetailLink.promise
// console.log(body, location)
if (statusCode > 400) return this.getUserListDetail(link, page, ++retryNum)
if (location) {
// console.log(location)
if (location.includes('global_collection_id'))
return this.getUserListDetail2(
location.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1')
)
if (location.includes('global_collection_id')) return this.getUserListDetail2(location.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1'))
if (location.includes('gcid_')) {
const gcid = link.match(/gcid_\w+/)?.[0]
let 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)
}
}
if (location.includes('chain='))
return this.getUserListDetail3(
location.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'),
page
)
if (location.includes('chain=')) return this.getUserListDetail3(location.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page)
if (location.includes('.html')) {
if (location.includes('zlist.html')) {
let link = location.replace(/^(.*)zlist\.html/, 'https://m3ws.kugou.com/zlist/list')
@@ -698,7 +688,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]
@@ -729,184 +719,9 @@ export default {
return this.getListDetailBySpecialId(id, page)
},
filterData(rawList) {
// console.log(rawList)
return rawList.map((item) => {
const types = []
const _types = {}
if (item.filesize !== 0) {
const size = sizeFormate(item.filesize)
types.push({ type: '128k', size, hash: item.hash })
_types['128k'] = {
size,
hash: item.hash
}
}
if (item.filesize_320 !== 0) {
const size = sizeFormate(item.filesize_320)
types.push({ type: '320k', size, hash: item.hash_320 })
_types['320k'] = {
size,
hash: item.hash_320
}
}
if (item.filesize_ape !== 0) {
const size = sizeFormate(item.filesize_ape)
types.push({ type: 'ape', size, hash: item.hash_ape })
_types.ape = {
size,
hash: item.hash_ape
}
}
if (item.filesize_flac !== 0) {
const size = sizeFormate(item.filesize_flac)
types.push({ type: 'flac', size, hash: item.hash_flac })
_types.flac = {
size,
hash: item.hash_flac
}
}
return {
singer: decodeName(item.singername),
name: decodeName(item.songname),
albumName: decodeName(item.album_name),
albumId: item.album_id,
songmid: item.audio_id,
source: 'kg',
interval: formatPlayTime(item.duration / 1000),
img: null,
lrc: null,
hash: item.hash,
types,
_types,
typeUrl: {}
}
})
},
// getSinger(singers) {
// let arr = []
// singers?.forEach(singer => {
// arr.push(singer.name)
// })
// return arr.join('、')
// },
// v9 API
// filterDatav9(rawList) {
// console.log(rawList)
// return rawList.map(item => {
// const types = []
// const _types = {}
// item.relate_goods.forEach(qualityObj => {
// if (qualityObj.level === 2) {
// let size = sizeFormate(qualityObj.size)
// types.push({ type: '128k', size, hash: qualityObj.hash })
// _types['128k'] = {
// size,
// hash: qualityObj.hash,
// }
// } else if (qualityObj.level === 4) {
// let size = sizeFormate(qualityObj.size)
// types.push({ type: '320k', size, hash: qualityObj.hash })
// _types['320k'] = {
// size,
// hash: qualityObj.hash,
// }
// } else if (qualityObj.level === 5) {
// let size = sizeFormate(qualityObj.size)
// types.push({ type: 'flac', size, hash: qualityObj.hash })
// _types.flac = {
// size,
// hash: qualityObj.hash,
// }
// } else if (qualityObj.level === 6) {
// let size = sizeFormate(qualityObj.size)
// types.push({ type: 'flac24bit', size, hash: qualityObj.hash })
// _types.flac24bit = {
// size,
// hash: qualityObj.hash,
// }
// }
// })
// const nameInfo = item.name.split(' - ')
// return {
// singer: this.getSinger(item.singerinfo),
// name: decodeName((nameInfo[1] ?? nameInfo[0]).trim()),
// albumName: decodeName(item.albuminfo.name),
// albumId: item.albuminfo.id,
// songmid: item.audio_id,
// source: 'kg',
// interval: formatPlayTime(item.timelen / 1000),
// img: null,
// lrc: null,
// hash: item.hash,
// types,
// _types,
// typeUrl: {},
// }
// })
// },
// hash list filter
filterData2(rawList) {
// console.log(rawList)
const ids = new Set()
const list = []
rawList.forEach((item) => {
if (!item) return
if (ids.has(item.audio_info.audio_id)) return
ids.add(item.audio_info.audio_id)
const types = []
const _types = {}
if (item.audio_info.filesize !== '0') {
const size = sizeFormate(parseInt(item.audio_info.filesize))
types.push({ type: '128k', size, hash: item.audio_info.hash })
_types['128k'] = {
size,
hash: item.audio_info.hash
}
}
if (item.audio_info.filesize_320 !== '0') {
const size = sizeFormate(parseInt(item.audio_info.filesize_320))
types.push({ type: '320k', size, hash: item.audio_info.hash_320 })
_types['320k'] = {
size,
hash: item.audio_info.hash_320
}
}
if (item.audio_info.filesize_flac !== '0') {
const size = sizeFormate(parseInt(item.audio_info.filesize_flac))
types.push({ type: 'flac', size, hash: item.audio_info.hash_flac })
_types.flac = {
size,
hash: item.audio_info.hash_flac
}
}
if (item.audio_info.filesize_high !== '0') {
const size = sizeFormate(parseInt(item.audio_info.filesize_high))
types.push({ type: 'flac24bit', size, hash: item.audio_info.hash_high })
_types.flac24bit = {
size,
hash: item.audio_info.hash_high
}
}
list.push({
singer: decodeName(item.author_name),
name: decodeName(item.songname),
albumName: decodeName(item.album_info.album_name),
albumId: item.album_info.album_id,
songmid: item.audio_info.audio_id,
source: 'kg',
interval: formatPlayTime(parseInt(item.audio_info.timelength) / 1000),
img: null,
lrc: null,
hash: item.audio_info.hash,
otherSource: null,
types,
_types,
typeUrl: {}
})
})
return list
async filterData(rawList) {
return await filterData(rawList, { removeDuplicates: true, fix: true })
},
// 获取列表信息
@@ -920,14 +735,14 @@ export default {
limit: body.data.params.pagesize,
page: body.data.params.p,
total: body.data.params.total,
source: 'kg'
source: 'kg',
}
})
},
// 获取列表数据
getList(sortId, tagId, page) {
const tasks = [this.getSongList(sortId, tagId, page)]
let tasks = [this.getSongList(sortId, tagId, page)]
tasks.push(
this.currentTagInfo.id === tagId
? Promise.resolve(this.currentTagInfo.info)
@@ -943,7 +758,7 @@ export default {
if (recommendList) list.unshift(...recommendList)
return {
list,
...info
...info,
}
})
},
@@ -958,13 +773,13 @@ export default {
return {
hotTag: this.filterInfoHotTag(body.data.hotTag),
tags: this.filterTagInfo(body.data.tagids),
source: 'kg'
source: 'kg',
}
})
},
getDetailPageUrl(id) {
if (typeof id === 'string') {
if (typeof id == 'string') {
if (/^https?:\/\//.test(id)) return id
id = id.replace('id_', '')
}
@@ -975,7 +790,9 @@ export default {
// http://msearchretry.kugou.com/api/v3/search/special?version=9209&keyword=%E5%91%A8%E6%9D%B0%E4%BC%A6&pagesize=20&filter=0&page=1&sver=2&with_res_tag=0
// return httpFetch(`http://ioscdn.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(text)}&page=${page}&pagesize=${limit}&showtype=10&plat=2&version=7910&correct=1&sver=5`)
return httpFetch(
`http://msearchretry.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(text)}&page=${page}&pagesize=${limit}&showtype=10&filter=0&version=7910&sver=2`
`http://msearchretry.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(
text
)}&page=${page}&pagesize=${limit}&showtype=10&filter=0&version=7910&sver=2`
).promise.then(({ body }) => {
if (body.errcode != 0) throw new Error('filed')
// console.log(body.data.info)
@@ -991,17 +808,17 @@ export default {
grade: item.grade,
desc: item.intro,
total: item.songcount,
source: 'kg'
source: 'kg',
}
}),
limit,
total: body.data.total,
source: 'kg'
source: 'kg',
}
})
}
},
}
// getList
// getTags
// getListDetail
// getListDetail

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

@@ -1,13 +1,13 @@
// import '../../polyfill/array.find'
import { httpFetch } from '../../request'
import { formatPlayTime, decodeName } from '../index'
import { formatPlayTime, decodeName } from '../../index'
// import { debug } from '../../utils/env'
import { formatSinger } from './util'
export default {
regExps: {
mInfo: /level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)/
mInfo: /level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)/,
},
limit: 30,
total: 0,
@@ -32,7 +32,7 @@ export default {
// console.log(rawData)
for (let i = 0; i < rawData.length; i++) {
const info = rawData[i]
const songId = info.MUSICRID.replace('MUSIC_', '')
let songId = info.MUSICRID.replace('MUSIC_', '')
// const format = (info.FORMATS || info.formats).split('|')
if (!info.N_MINFO) {
@@ -43,33 +43,39 @@ export default {
const types = []
const _types = {}
const infoArr = info.N_MINFO.split(';')
let infoArr = info.N_MINFO.split(';')
for (let info of infoArr) {
info = info.match(this.regExps.mInfo)
if (info) {
switch (info[2]) {
case '20900':
types.push({ type: 'master', size: info[4] })
_types.master = {
size: info[4].toLocaleUpperCase(),
}
break
case '4000':
types.push({ type: 'flac24bit', size: info[4] })
_types.flac24bit = {
size: info[4].toLocaleUpperCase()
types.push({ type: 'hires', size: info[4] })
_types.hires = {
size: info[4].toLocaleUpperCase(),
}
break
case '2000':
types.push({ type: 'flac', size: info[4] })
_types.flac = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
case '320':
types.push({ type: '320k', size: info[4] })
_types['320k'] = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
case '128':
types.push({ type: '128k', size: info[4] })
_types['128k'] = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
}
@@ -77,7 +83,7 @@ export default {
}
types.reverse()
const interval = parseInt(info.DURATION)
let interval = parseInt(info.DURATION)
result.push({
name: decodeName(info.SONGNAME),
@@ -95,7 +101,7 @@ export default {
otherSource: null,
types,
_types,
typeUrl: {}
typeUrl: {},
})
}
// console.log(result)
@@ -109,7 +115,7 @@ export default {
// console.log(result)
if (!result || (result.TOTAL !== '0' && result.SHOW === '0'))
return this.search(str, page, limit, ++retryNum)
const list = this.handleResult(result.abslist)
let list = this.handleResult(result.abslist)
if (list == null) return this.search(str, page, limit, ++retryNum)
@@ -122,8 +128,8 @@ export default {
allPage: this.allPage,
total: this.total,
limit,
source: 'kw'
source: 'kw',
})
})
}
}
},
}

View File

@@ -1,5 +1,5 @@
import { httpFetch } from '../../request'
import { formatPlayTime, decodeName } from '../index'
import { formatPlayTime, decodeName } from '../../index'
import { formatSinger, objStr2JSON } from './util'
import album from './album'
@@ -13,18 +13,18 @@ export default {
sortList: [
{
name: '最新',
id: 'new'
id: 'new',
},
{
name: '最热',
id: 'hot'
}
id: 'hot',
},
],
regExps: {
mInfo: /level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)/,
// http://www.kuwo.cn/playlist_detail/2886046289
// https://m.kuwo.cn/h5app/playlist/2736267853?t=qqfriend
listDetailLink: /^.+\/playlist(?:_detail)?\/(\d+)(?:\?.*|&.*$|#.*$|$)/
listDetailLink: /^.+\/playlist(?:_detail)?\/(\d+)(?:\?.*|&.*$|#.*$|$)/,
},
tagsUrl:
'http://wapi.kuwo.cn/api/pc/classify/playlist/getTagList?cmd=rcm_keyword_playlist&user=0&prod=kwplayer_pc_9.0.5.0&vipver=9.0.5.0&source=kwplayer_pc_9.0.5.0&loginUid=0&loginSid=0&appUid=76039576',
@@ -43,7 +43,9 @@ export default {
},
getListDetailUrl(id, page) {
// http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=2858093057&pn=0&rn=100&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1
return `http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}&rn=${this.limit_song}&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1`
return `http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}&rn=${
this.limit_song
}&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1`
// http://mobileinterfaces.kuwo.cn/er.s?type=get_pc_qz_data&f=web&id=140&prod=pc
},
@@ -72,7 +74,7 @@ export default {
return rawList.map((item) => ({
id: `${item.id}-${item.digest}`,
name: item.name,
source: 'kw'
source: 'kw',
}))
},
filterTagInfo(rawList) {
@@ -83,8 +85,8 @@ export default {
parent_name: type.name,
id: `${item.id}-${item.digest}`,
name: item.name,
source: 'kw'
}))
source: 'kw',
})),
}))
},
@@ -95,7 +97,7 @@ export default {
let id
let type
if (tagId) {
const arr = tagId.split('-')
let arr = tagId.split('-')
id = arr[0]
type = arr[1]
} else {
@@ -110,7 +112,7 @@ export default {
total: body.data.total,
page: body.data.pn,
limit: body.data.rn,
source: 'kw'
source: 'kw',
}
} else if (!body.length) {
return this.getList(sortId, tagId, page, ++tryNum)
@@ -120,7 +122,7 @@ export default {
total: 1000,
page,
limit: 1000,
source: 'kw'
source: 'kw',
}
})
},
@@ -145,7 +147,7 @@ export default {
img: item.img,
grade: item.favorcnt / 10,
desc: item.desc,
source: 'kw'
source: 'kw',
}))
},
filterList2(rawData) {
@@ -164,7 +166,7 @@ export default {
img: item.img,
grade: item.favorcnt && item.favorcnt / 10,
desc: item.desc,
source: 'kw'
source: 'kw',
}))
)
})
@@ -188,8 +190,8 @@ export default {
img: body.pic,
desc: body.info,
author: body.uname,
play_count: this.formatPlayCount(body.playnum)
}
play_count: this.formatPlayCount(body.playnum),
},
}
})
},
@@ -207,7 +209,9 @@ export default {
getListDetailDigest5Music(id, page, tryNum = 0) {
if (tryNum > 2) return Promise.reject(new Error('try max num'))
const requestObj = httpFetch(
`http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}}&rn=${this.limit_song}&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1`
`http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}}&rn=${
this.limit_song
}&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1`
)
return requestObj.promise.then(({ body }) => {
// console.log(body)
@@ -223,8 +227,8 @@ export default {
img: body.pic,
desc: body.info,
author: body.uname,
play_count: this.formatPlayCount(body.playnum)
}
play_count: this.formatPlayCount(body.playnum),
},
}
})
},
@@ -235,33 +239,33 @@ export default {
filterBDListDetail(rawList) {
return rawList.map((item) => {
const types = []
const _types = {}
for (const info of item.audios) {
let types = []
let _types = {}
for (let info of item.audios) {
info.size = info.size?.toLocaleUpperCase()
switch (info.bitrate) {
case '4000':
types.push({ type: 'flac24bit', size: info.size })
_types.flac24bit = {
size: info.size
size: info.size,
}
break
case '2000':
types.push({ type: 'flac', size: info.size })
_types.flac = {
size: info.size
size: info.size,
}
break
case '320':
types.push({ type: '320k', size: info.size })
_types['320k'] = {
size: info.size
size: info.size,
}
break
case '128':
types.push({ type: '128k', size: info.size })
_types['128k'] = {
size: info.size
size: info.size,
}
break
}
@@ -282,7 +286,7 @@ export default {
otherSource: null,
types,
_types,
typeUrl: {}
typeUrl: {},
}
})
},
@@ -299,8 +303,8 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
plat: 'h5'
}
plat: 'h5',
},
}
).promise.catch(() => ({ code: 0 }))
@@ -311,7 +315,7 @@ export default {
img: infoData.data.pic,
desc: infoData.data.description,
author: infoData.data.creatorName,
play_count: infoData.data.playNum
play_count: infoData.data.playNum,
}
},
async getListDetailMusicListByBDUserPub(id) {
@@ -321,8 +325,8 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
plat: 'h5'
}
plat: 'h5',
},
}
).promise.catch(() => ({ code: 0 }))
@@ -334,18 +338,20 @@ export default {
img: infoData.data.userInfo.headImg,
desc: '',
author: infoData.data.userInfo.nickname,
play_count: ''
play_count: '',
}
},
async getListDetailMusicListByBDList(id, source, page, tryNum = 0) {
const { body: listData } = await httpFetch(
`https://bd-api.kuwo.cn/api/service/playlist/${id}/musicList?reqId=${this.getReqId()}&source=${source}&pn=${page}&rn=${this.limit_song}`,
`https://bd-api.kuwo.cn/api/service/playlist/${id}/musicList?reqId=${this.getReqId()}&source=${source}&pn=${page}&rn=${
this.limit_song
}`,
{
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
plat: 'h5'
}
plat: 'h5',
},
}
).promise.catch(() => {
if (tryNum > 2) return Promise.reject(new Error('try max num'))
@@ -359,7 +365,7 @@ export default {
page,
limit: listData.data.pageSize,
total: listData.data.total,
source: 'kw'
source: 'kw',
}
},
async getListDetailMusicListByBD(id, page) {
@@ -383,7 +389,7 @@ export default {
img: '',
desc: '',
author: '',
play_count: ''
play_count: '',
}
// console.log(listData)
return listData
@@ -415,35 +421,53 @@ export default {
filterListDetail(rawData) {
// console.log(rawData)
return rawData.map((item) => {
const infoArr = item.N_MINFO.split(';')
const types = []
const _types = {}
let infoArr = item.N_MINFO.split(';')
let types = []
let _types = {}
for (let info of infoArr) {
info = info.match(this.regExps.mInfo)
if (info) {
switch (info[2]) {
case '20900':
types.push({ type: 'master', size: info[4] })
_types.master = {
size: info[4].toLocaleUpperCase(),
}
break
case '20501':
types.push({ type: 'atmos_plus', size: info[4] })
_types.atmos_plus = {
size: info[4].toLocaleUpperCase(),
}
break
case '20201':
types.push({ type: 'atmos', size: info[4] })
_types.atmos = {
size: info[4].toLocaleUpperCase(),
}
break
case '4000':
types.push({ type: 'flac24bit', size: info[4] })
types.push({ type: 'hires', size: info[4] })
_types.flac24bit = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
case '2000':
types.push({ type: 'flac', size: info[4] })
_types.flac = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
case '320':
types.push({ type: '320k', size: info[4] })
_types['320k'] = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
case '128':
types.push({ type: '128k', size: info[4] })
_types['128k'] = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
}
@@ -464,7 +488,7 @@ export default {
otherSource: null,
types,
_types,
typeUrl: {}
typeUrl: {},
}
})
},
@@ -472,13 +496,13 @@ export default {
return Promise.all([this.getTag(), this.getHotTag()]).then(([tags, hotTag]) => ({
tags,
hotTag,
source: 'kw'
source: 'kw',
}))
},
getDetailPageUrl(id) {
if (/[?&:/]/.test(id)) id = id.replace(this.regExps.listDetailLink, '$1')
else if (/^digest-/.test(id)) {
const result = id.split('__')
let result = id.split('__')
id = result[1]
}
return `http://www.kuwo.cn/playlist_detail/${id}`
@@ -486,7 +510,9 @@ export default {
search(text, page, limit = 20) {
return httpFetch(
`http://search.kuwo.cn/r.s?all=${encodeURIComponent(text)}&pn=${page - 1}&rn=${limit}&rformat=json&encoding=utf8&ver=mbox&vipver=MUSIC_8.7.7.0_BCS37&plat=pc&devid=28156413&ft=playlist&pay=0&needliveshow=0`
`http://search.kuwo.cn/r.s?all=${encodeURIComponent(text)}&pn=${
page - 1
}&rn=${limit}&rformat=json&encoding=utf8&ver=mbox&vipver=MUSIC_8.7.7.0_BCS37&plat=pc&devid=28156413&ft=playlist&pay=0&needliveshow=0`
).promise.then(({ body }) => {
body = objStr2JSON(body)
// console.log(body)
@@ -501,17 +527,17 @@ export default {
// time: item.publish_time,
img: item.pic,
desc: decodeName(item.intro),
source: 'kw'
source: 'kw',
}
}),
limit,
total: parseInt(body.TOTAL),
source: 'kw'
source: 'kw',
}
})
}
},
}
// getList
// getTags
// getListDetail
// getListDetail

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

@@ -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 mg = {
// tipSearch,
tipSearch,
songList,
musicSearch,
leaderboard,

View File

@@ -1,5 +1,5 @@
import { httpFetch } from '../../request'
import { sizeFormate, formatPlayTime } from '../index'
import { sizeFormate, formatPlayTime } from '../../index'
import { toMD5, formatSingerName } from '../utils'
export const createSignature = (time, str) => {
@@ -17,100 +17,6 @@ export default {
page: 0,
allPage: 1,
// 旧版API
// musicSearch(str, page, limit) {
// const searchRequest = httpFetch(`http://pd.musicapp.migu.cn/MIGUM2.0/v1.0/content/search_all.do?ua=Android_migu&version=5.0.1&text=${encodeURIComponent(str)}&pageNo=${page}&pageSize=${limit}&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A0%2C%22mvSong%22%3A0%2C%22songlist%22%3A0%2C%22bestShow%22%3A1%7D`, {
// searchRequest = httpFetch(`http://pd.musicapp.migu.cn/MIGUM2.0/v1.0/content/search_all.do?ua=Android_migu&version=5.0.1&text=${encodeURIComponent(str)}&pageNo=${page}&pageSize=${limit}&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A0%2C%22mvSong%22%3A0%2C%22songlist%22%3A0%2C%22bestShow%22%3A1%7D`, {
// searchRequest = httpFetch(`http://jadeite.migu.cn:7090/music_search/v2/search/searchAll?sid=4f87090d01c84984a11976b828e2b02c18946be88a6b4c47bcdc92fbd40762db&isCorrect=1&isCopyright=1&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A1%2C%22mvSong%22%3A0%2C%22bestShow%22%3A1%2C%22songlist%22%3A0%2C%22lyricSong%22%3A0%7D&pageSize=${limit}&text=${encodeURIComponent(str)}&pageNo=${page}&sort=0`, {
// searchRequest = httpFetch(`https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/search_all.do?isCopyright=1&isCorrect=1&pageNo=${page}&pageSize=${limit}&searchSwitch={%22song%22:1,%22album%22:0,%22singer%22:0,%22tagSong%22:0,%22mvSong%22:0,%22songlist%22:0,%22bestShow%22:0}&sort=0&text=${encodeURIComponent(str)}`)
// // searchRequest = httpFetch(`http://jadeite.migu.cn:7090/music_search/v2/search/searchAll?sid=4f87090d01c84984a11976b828e2b02c18946be88a6b4c47bcdc92fbd40762db&isCorrect=1&isCopyright=1&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A1%2C%22mvSong%22%3A0%2C%22bestShow%22%3A1%2C%22songlist%22%3A0%2C%22lyricSong%22%3A0%7D&pageSize=${limit}&text=${encodeURIComponent(str)}&pageNo=${page}&sort=0`, {
// headers: {
// // sign: 'c3b7ae985e2206e97f1b2de8f88691e2',
// // timestamp: 1578225871982,
// // appId: 'yyapp2',
// // mode: 'android',
// // ua: 'Android_migu',
// // version: '6.9.4',
// osVersion: 'android 7.0',
// 'User-Agent': 'okhttp/3.9.1',
// },
// })
// // searchRequest = httpFetch(`https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/search_all.do?isCopyright=1&isCorrect=1&pageNo=${page}&pageSize=${limit}&searchSwitch={%22song%22:1,%22album%22:0,%22singer%22:0,%22tagSong%22:0,%22mvSong%22:0,%22songlist%22:0,%22bestShow%22:0}&sort=0&text=${encodeURIComponent(str)}`)
// return searchRequest.promise.then(({ body }) => body)
// },
// handleResult(rawData) {
// // console.log(rawData)
// let ids = new Set()
// const list = []
// rawData.forEach(item => {
// if (ids.has(item.id)) return
// ids.add(item.id)
// const types = []
// const _types = {}
// item.newRateFormats && item.newRateFormats.forEach(type => {
// let size
// switch (type.formatType) {
// case 'PQ':
// size = sizeFormate(type.size ?? type.androidSize)
// types.push({ type: '128k', size })
// _types['128k'] = {
// size,
// }
// break
// case 'HQ':
// size = sizeFormate(type.size ?? type.androidSize)
// types.push({ type: '320k', size })
// _types['320k'] = {
// size,
// }
// break
// case 'SQ':
// size = sizeFormate(type.size ?? type.androidSize)
// types.push({ type: 'flac', size })
// _types.flac = {
// size,
// }
// break
// case 'ZQ':
// size = sizeFormate(type.size ?? type.androidSize)
// types.push({ type: 'flac24bit', size })
// _types.flac24bit = {
// size,
// }
// break
// }
// })
// const albumNInfo = item.albums && item.albums.length
// ? {
// id: item.albums[0].id,
// name: item.albums[0].name,
// }
// : {}
// list.push({
// singer: this.getSinger(item.singers),
// name: item.name,
// albumName: albumNInfo.name,
// albumId: albumNInfo.id,
// songmid: item.songId,
// copyrightId: item.copyrightId,
// source: 'mg',
// interval: null,
// img: item.imgItems && item.imgItems.length ? item.imgItems[0].img : null,
// lrc: null,
// lrcUrl: item.lyricUrl,
// mrcUrl: item.mrcurl,
// trcUrl: item.trcUrl,
// otherSource: null,
// types,
// _types,
// typeUrl: {},
// })
// })
// return list
// },
musicSearch(str, page, limit) {
const time = Date.now().toString()
const signData = createSignature(time, str)
@@ -124,8 +30,8 @@ export default {
sign: signData.sign,
channel: '0146921',
'User-Agent':
'Mozilla/5.0 (Linux; U; Android 11.0.0; zh-cn; MI 11 Build/OPR1.170623.032) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30'
}
'Mozilla/5.0 (Linux; U; Android 11.0.0; zh-cn; MI 11 Build/OPR1.170623.032) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
},
}
)
return searchRequest.promise.then(({ body }) => body)
@@ -150,28 +56,28 @@ export default {
size = sizeFormate(type.asize ?? type.isize)
types.push({ type: '128k', size })
_types['128k'] = {
size
size,
}
break
case 'HQ':
size = sizeFormate(type.asize ?? type.isize)
types.push({ type: '320k', size })
_types['320k'] = {
size
size,
}
break
case 'SQ':
size = sizeFormate(type.asize ?? type.isize)
types.push({ type: 'flac', size })
_types.flac = {
size
size,
}
break
case 'ZQ24':
size = sizeFormate(type.asize ?? type.isize)
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size
types.push({ type: 'hires', size })
_types.hires = {
size,
}
break
}
@@ -196,7 +102,7 @@ export default {
trcUrl: data.trcUrl,
types,
_types,
typeUrl: {}
typeUrl: {},
})
})
})
@@ -212,7 +118,7 @@ export default {
return Promise.reject(new Error(result ? result.info : '搜索失败'))
const songResultData = result.songResultData || { resultList: [], totalCount: 0 }
const list = this.filterData(songResultData.resultList)
let list = this.filterData(songResultData.resultList)
if (list == null) return this.search(str, page, limit, retryNum)
this.total = parseInt(songResultData.totalCount)
@@ -224,8 +130,8 @@ export default {
allPage: this.allPage,
limit,
total: this.total,
source: 'mg'
source: 'mg',
}
})
}
}
},
}

View File

@@ -1,5 +1,5 @@
import { httpFetch } from '../../request'
import { dateFormat, formatPlayCount } from '../index'
import { dateFormat, formatPlayCount } from '../../index'
import { filterMusicInfoList } from './musicInfo'
import { createSignature } from './musicSearch'
import { createHttpFetch } from './utils/index'
@@ -17,14 +17,14 @@ export default {
sortList: [
{
name: '推荐',
id: '15127315'
id: '15127315',
// id: '1',
},
{
name: '最新',
id: '15127272'
id: '15127272',
// id: '2',
}
},
],
regExps: {
list: /<li><div class="thumb">.+?<\/li>/g,
@@ -32,7 +32,7 @@ export default {
/.+data-original="(.+?)".*data-id="(\d+)".*<div class="song-list-name"><a\s.*?>(.+?)<\/a>.+<i class="iconfont cf-bofangliang"><\/i>(.+?)<\/div>/,
// https://music.migu.cn/v3/music/playlist/161044573?page=1
listDetailLink: /^.+\/playlist\/(\d+)(?:\?.*|&.*$|#.*$|$)/
listDetailLink: /^.+\/playlist\/(\d+)(?:\?.*|&.*$|#.*$|$)/,
},
tagsUrl: 'https://app.c.nf.migu.cn/MIGUM3.0/v1.0/template/musiclistplaza-taglist/release',
// tagsUrl: 'https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/indexTagPage.do?needAll=0',
@@ -58,7 +58,7 @@ export default {
defaultHeaders: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1',
Referer: 'https://m.music.migu.cn/'
Referer: 'https://m.music.migu.cn/',
// language: 'Chinese',
// ua: 'Android_migu',
// mode: 'android',
@@ -74,7 +74,7 @@ export default {
} else if (/[?&:/]/.test(id)) id = id.replace(this.regExps.listDetailLink, '$1')
const requestObj_listDetail = httpFetch(this.getSongListDetailUrl(id, page), {
headers: this.defaultHeaders
headers: this.defaultHeaders,
})
return requestObj_listDetail.promise.then(({ body }) => {
if (body.code !== this.successCode) return this.getListDetail(id, page, ++tryNum)
@@ -85,7 +85,7 @@ export default {
page,
limit: this.limit_song,
total: body.totalCount,
source: 'mg'
source: 'mg',
}
})
},
@@ -97,7 +97,7 @@ export default {
const requestObj_listDetailInfo = httpFetch(
`https://c.musicapp.migu.cn/MIGUM3.0/resource/playlist/v2.0?playlistId=${id}`,
{
headers: this.defaultHeaders
headers: this.defaultHeaders,
}
)
return requestObj_listDetailInfo.promise.then(({ body }) => {
@@ -109,7 +109,7 @@ export default {
img: body.data.imgItem.img,
desc: body.data.summary,
author: body.data.ownerName,
play_count: formatPlayCount(body.data.opNumItem.playNum)
play_count: formatPlayCount(body.data.opNumItem.playNum),
})
return cachedDetailInfo
})
@@ -122,12 +122,12 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
Referer: link
}
Referer: link,
},
})
const {
headers: { location },
statusCode
statusCode,
} = await requestObj_listDetailLink.promise
// console.log(body, location)
if (statusCode > 400) return this.getDetailUrl(link, page, ++retryNum)
@@ -153,7 +153,7 @@ export default {
return Promise.all([
this.getListDetailList(id, page, retryNum),
this.getListDetailInfo(id, retryNum)
this.getListDetailInfo(id, retryNum),
]).then(([listData, info]) => {
listData.info = info
return listData
@@ -165,7 +165,7 @@ export default {
if (this._requestObj_list) this._requestObj_list.cancelHttp()
if (tryNum > 2) return Promise.reject(new Error('try max num'))
this._requestObj_list = httpFetch(this.getSongListUrl(sortId, tagId, page), {
headers: this.defaultHeaders
headers: this.defaultHeaders,
// headers: {
// sign: 'c3b7ae985e2206e97f1b2de8f88691e2',
// timestamp: 1578225871982,
@@ -205,7 +205,7 @@ export default {
total: parseInt(body.retMsg.countSize),
page,
limit: this.limit_list,
source: 'mg'
source: 'mg',
}
})
// return this._requestObj_list.promise.then(({ body }) => {
@@ -233,7 +233,7 @@ export default {
grade: item.grade,
total: item.contentCount,
desc: item.summary,
source: 'mg'
source: 'mg',
}))
},
@@ -254,7 +254,7 @@ export default {
hotTag: rawList[0].content.map(({ texts: [name, id] }) => ({
id,
name,
source: 'mg'
source: 'mg',
})),
tags: rawList.slice(1).map(({ header, content }) => ({
name: header.title,
@@ -263,10 +263,10 @@ export default {
// parent_name: objectInfo.columnTitle,
id,
name,
source: 'mg'
}))
source: 'mg',
})),
})),
source: 'mg'
source: 'mg',
}
// return {
// hotTag: rawList[0].objectInfo.contents.map(item => ({
@@ -313,7 +313,7 @@ export default {
name: item.name,
img: item.musicListPicUrl,
total: item.musicNum,
source: 'mg'
source: 'mg',
})
})
return list
@@ -331,8 +331,8 @@ export default {
sign: signResult.sign,
channel: '0146921',
'User-Agent':
'Mozilla/5.0 (Linux; U; Android 11.0.0; zh-cn; MI 11 Build/OPR1.170623.032) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30'
}
'Mozilla/5.0 (Linux; U; Android 11.0.0; zh-cn; MI 11 Build/OPR1.170623.032) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
},
}
).then((body) => {
if (!body.songListResultData) throw new Error('get song list faild.')
@@ -342,12 +342,12 @@ export default {
list,
limit,
total: parseInt(body.songListResultData.totalCount),
source: 'mg'
source: 'mg',
}
})
}
},
}
// getList
// getTags
// getListDetail
// getListDetail

View File

@@ -8,7 +8,8 @@ export default {
tipSearchBySong(str) {
this.cancelTipSearch()
this.requestObj = createHttpFetch(
`https://music.migu.cn/v3/api/search/suggest?keyword=${encodeURIComponent(str)}`,
//https://app.u.nf.migu.cn/pc/resource/content/tone_search_suggest/v1.0?text=%E5%90%8E
`https://app.u.nf.migu.cn/pc/resource/content/tone_search_suggest/v1.0?text=${encodeURIComponent(str)}`,
{
headers: {
referer: 'https://music.migu.cn/v3'
@@ -16,11 +17,29 @@ export default {
}
)
return this.requestObj.then((body) => {
return body.songs
return body
})
},
handleResult(rawData) {
return rawData.map((info) => `${info.name} - ${info.singerName}`)
let list = {
order: [],
songs: [],
artists: []
}
if (rawData.songList.length > 0) {
list.order.push('songs')
}
if (rawData.singerList.length > 0) {
list.order.push('artists')
}
list.songs = rawData.songList.map((info) => ({
name: info.songName
}))
list.artists = rawData.singerList.map((info) => ({
name: info.singerName
}))
console.log(JSON.stringify(list))
return list
},
async search(str) {
return this.tipSearchBySong(str).then((result) => this.handleResult(result))

View File

@@ -1,8 +1,7 @@
export const bHh = '624868746c'
export const headers = {
'User-Agent': 'lx-music request',
[bHh]: [bHh]
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
}
export const timeout = 15000

View File

@@ -4,10 +4,10 @@ import songList from './songList'
import musicSearch from './musicSearch'
import hotSearch from './hotSearch'
import comment from './comment'
// import tipSearch from './tipSearch'
import tipSearch from './tipSearch'
const tx = {
// tipSearch,
tipSearch,
leaderboard,
songList,
musicSearch,

View File

@@ -1,10 +1,31 @@
import qrcDecrypt from './qrc-decrypt'
import { httpFetch } from '../../request'
import getMusicInfo from './musicInfo'
const songIdMap = new Map()
const promises = new Map()
const decode = qrcDecrypt()
export default {
rxps: {
info: /^{"/,
lineTime: /^\[(\d+),\d+\]/,
lineTime2: /^\[([\d:.]+)\]/,
wordTime: /\(\d+,\d+,\d+\)/,
wordTimeAll: /(\(\d+,\d+,\d+\))/g,
timeLabelFixRxp: /(?:\.0+|0+)$/
},
msFormat(timeMs) {
if (Number.isNaN(timeMs)) return ''
let ms = timeMs % 1000
timeMs /= 1000
let m = parseInt(timeMs / 60)
.toString()
.padStart(2, '0')
timeMs %= 60
let s = parseInt(timeMs).toString().padStart(2, '0')
return `[${m}:${s}.${String(ms).padStart(3, '0')}]`
},
successCode: 0,
async getSongId({ songId, songmid }) {
if (songId) return songId
@@ -17,6 +38,179 @@ export default {
promises.delete(songmid)
return info.songId
},
removeTag(str) {
return str.replace(/^[\S\s]*?LyricContent="/, '').replace(/"\/>[\S\s]*?$/, '')
},
parseCeru(lrc) {
lrc = lrc.trim()
lrc = lrc.replace(/\r/g, '')
if (!lrc) return { lyric: '', lxlyric: '' }
const lines = lrc.split('\n')
const lxlrcLines = []
const lrcLines = []
for (let line of lines) {
line = line.trim()
let result = this.rxps.lineTime.exec(line)
if (!result) {
if (line.startsWith('[offset')) {
lxlrcLines.push(line)
lrcLines.push(line)
continue
}
if (this.rxps.lineTime2.test(line)) {
// lxlrcLines.push(line)
lrcLines.push(line)
}
continue
}
const startMsTime = parseInt(result[1])
const startTimeStr = this.msFormat(startMsTime)
if (!startTimeStr) continue
let words = line.replace(this.rxps.lineTime, '')
lrcLines.push(`${startTimeStr}${words.replace(this.rxps.wordTimeAll, '')}`)
let times = words.match(this.rxps.wordTimeAll)
if (!times) continue
let currentStart = startMsTime
const processedTimes = []
times.forEach((time) => {
const result = /\((\d+),(\d+),(\d+)\)/.exec(time)
const duration = parseInt(result[2])
processedTimes.push(`(${currentStart},${duration},0)`)
currentStart += duration
})
const wordArr = words.split(this.rxps.wordTime)
const newWords = processedTimes.map((time, index) => `${time}${wordArr[index]}`).join('')
lxlrcLines.push(`${startTimeStr}${newWords}`)
}
return {
lyric: lrcLines.join('\n'),
lxlyric: lxlrcLines.join('\n')
}
},
getIntv(interval) {
if (!interval) return 0
if (!interval.includes('.')) interval += '.0'
let arr = interval.split(/:|\./)
while (arr.length < 3) arr.unshift('0')
const [m, s, ms] = arr
return parseInt(m) * 3600000 + parseInt(s) * 1000 + parseInt(ms)
},
fixRlrcTimeTag(rlrc, lrc) {
// console.log(lrc)
// console.log(rlrc)
const rlrcLines = rlrc.split('\n')
let lrcLines = lrc.split('\n')
// let temp = []
let newLrc = []
rlrcLines.forEach((line) => {
const result = this.rxps.lineTime2.exec(line)
if (!result) return
const words = line.replace(this.rxps.lineTime2, '')
if (!words.trim()) return
const t1 = this.getIntv(result[1])
while (lrcLines.length) {
const lrcLine = lrcLines.shift()
const lrcLineResult = this.rxps.lineTime2.exec(lrcLine)
if (!lrcLineResult) continue
const t2 = this.getIntv(lrcLineResult[1])
if (Math.abs(t1 - t2) < 100) {
newLrc.push(line.replace(this.rxps.lineTime2, lrcLineResult[0]))
break
}
// temp.push(line)
}
// lrcLines = [...temp, ...lrcLines]
// temp = []
})
return newLrc.join('\n')
},
fixTlrcTimeTag(tlrc, lrc) {
// console.log(lrc)
// console.log(tlrc)
const tlrcLines = tlrc.split('\n')
let lrcLines = lrc.split('\n')
// let temp = []
let newLrc = []
tlrcLines.forEach((line) => {
const result = this.rxps.lineTime2.exec(line)
if (!result) return
const words = line.replace(this.rxps.lineTime2, '')
if (!words.trim()) return
let time = result[1]
if (time.includes('.')) {
time += ''.padStart(3 - time.split('.')[1].length, '0')
}
const t1 = this.getIntv(time)
while (lrcLines.length) {
const lrcLine = lrcLines.shift()
const lrcLineResult = this.rxps.lineTime2.exec(lrcLine)
if (!lrcLineResult) continue
const t2 = this.getIntv(lrcLineResult[1])
if (Math.abs(t1 - t2) < 100) {
newLrc.push(line.replace(this.rxps.lineTime2, lrcLineResult[0]))
break
}
// temp.push(line)
}
// lrcLines = [...temp, ...lrcLines]
// temp = []
})
return newLrc.join('\n')
},
parse(lrc, tlrc, rlrc) {
const info = {
lyric: '',
tlyric: '',
rlyric: '',
crlyric: ''
}
if (lrc) {
let { lyric } = this.parseCeru(this.removeTag(lrc))
info.lyric = lyric
info.crlyric = lrc
}
if (rlrc) info.rlyric = this.fixRlrcTimeTag(this.parseRlyric(this.removeTag(rlrc)), info.lyric)
if (tlrc) info.tlyric = this.fixTlrcTimeTag(tlrc, info.lyric)
return info
},
parseRlyric(lrc) {
lrc = lrc.trim()
lrc = lrc.replace(/\r/g, '')
if (!lrc) return { lyric: '', lxlyric: '' }
const lines = lrc.split('\n')
const lrcLines = []
for (let line of lines) {
line = line.trim()
let result = this.rxps.lineTime.exec(line)
if (!result) continue
const startMsTime = parseInt(result[1])
const startTimeStr = this.msFormat(startMsTime)
if (!startTimeStr) continue
let words = line.replace(this.rxps.lineTime, '')
lrcLines.push(`${startTimeStr}${words.replace(this.rxps.wordTimeAll, '')}`)
}
return lrcLines.join('\n')
},
parseLyric(lrc, tlrc, rlrc) {
return this.parse(decode(lrc), decode(tlrc), decode(rlrc))
},
getLyric(mInfo, retryNum = 0) {
if (retryNum > 3) return Promise.reject(new Error('Get lyric failed'))

View File

@@ -1,5 +1,5 @@
import { httpFetch } from '../../request'
import { formatPlayTime, sizeFormate } from '../index'
import { formatPlayTime, sizeFormate } from '../../index'
import { formatSingerName } from '../utils'
export default {
@@ -15,7 +15,7 @@ export default {
const searchRequest = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
method: 'post',
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)'
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)',
},
body: {
comm: {
@@ -26,7 +26,7 @@ export default {
phonetype: '0',
devicelevel: '31',
tmeAppID: 'qqmusiclight',
nettype: 'NETWORK_WIFI'
nettype: 'NETWORK_WIFI',
},
req: {
module: 'music.search.SearchCgiService',
@@ -37,10 +37,10 @@ export default {
num_per_page: limit,
page_num: page,
nqc_flag: 0,
grp: 1
}
}
}
grp: 1,
},
},
},
})
// searchRequest = httpFetch(`http://ioscdn.kugou.com/api/v3/search/song?keyword=${encodeURIComponent(str)}&page=${page}&pagesize=${this.limit}&showtype=10&plat=2&version=7910&tag=1&correct=1&privilege=1&sver=5`)
return searchRequest.promise.then(({ body }) => {
@@ -56,35 +56,56 @@ export default {
rawList.forEach((item) => {
if (!item.file?.media_mid) return
const types = []
const _types = {}
let types = []
let _types = {}
const file = item.file
if (file.size_128mp3 != 0) {
const size = sizeFormate(file.size_128mp3)
let size = sizeFormate(file.size_128mp3)
types.push({ type: '128k', size })
_types['128k'] = {
size
size,
}
}
if (file.size_320mp3 !== 0) {
const size = sizeFormate(file.size_320mp3)
let size = sizeFormate(file.size_320mp3)
types.push({ type: '320k', size })
_types['320k'] = {
size
size,
}
}
if (file.size_flac !== 0) {
const size = sizeFormate(file.size_flac)
let size = sizeFormate(file.size_flac)
types.push({ type: 'flac', size })
_types.flac = {
size
size,
}
}
if (file.size_hires !== 0) {
const size = sizeFormate(file.size_hires)
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size
let size = sizeFormate(file.size_hires)
types.push({ type: 'hires', size })
_types.hires = {
size,
}
}
if (file.size_new[1] !== 0) {
let size = sizeFormate(file.size_new[1])
types.push({ type: 'atmos', size })
_types.atmos = {
size,
}
}
if (file.size_new[2] !== 0) {
let size = sizeFormate(file.size_new[2])
types.push({ type: 'atmos_plus', size })
_types.atmos_plus = {
size,
}
}
if (file.size_new[0] !== 0) {
let size = sizeFormate(file.size_new[0])
types.push({ type: 'master', size })
_types.master = {
size,
}
}
// types.reverse()
@@ -113,7 +134,7 @@ export default {
: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${albumId}.jpg`,
types,
_types,
typeUrl: {}
typeUrl: {},
})
})
// console.log(list)
@@ -123,7 +144,7 @@ export default {
if (limit == null) limit = this.limit
// http://newlyric.kuwo.cn/newlyric.lrc?62355680
return this.musicSearch(str, page, limit).then(({ body, meta }) => {
const list = this.handleResult(body.item_song)
let list = this.handleResult(body.item_song)
this.total = meta.estimate_sum
this.page = page
@@ -134,8 +155,8 @@ export default {
allPage: this.allPage,
limit,
total: this.total,
source: 'tx'
source: 'tx',
})
})
}
}
},
}

View File

@@ -0,0 +1,521 @@
import zlib from 'zlib'
export default () => {
const ENCRYPT = 1
const DECRYPT = 0
const sbox = [
// sbox1
[
14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7, 0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12,
11, 9, 5, 3, 8, 4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0, 15, 12, 8, 2, 4, 9, 1,
7, 5, 11, 3, 14, 10, 0, 6, 13
],
// sbox2
[
15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10, 3, 13, 4, 7, 15, 2, 8, 15, 12, 0, 1, 10,
6, 9, 11, 5, 0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15, 13, 8, 10, 1, 3, 15, 4, 2,
11, 6, 7, 12, 0, 5, 14, 9
],
// sbox3
[
10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8, 13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14,
12, 11, 15, 1, 13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7, 1, 10, 13, 0, 6, 9, 8, 7,
4, 15, 14, 3, 11, 5, 2, 12
],
// sbox4
[
7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15, 13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12,
1, 10, 14, 9, 10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4, 3, 15, 0, 6, 10, 10, 13,
8, 9, 4, 5, 11, 12, 7, 2, 14
],
// sbox5
[
2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9, 14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15,
10, 3, 9, 8, 6, 4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14, 11, 8, 12, 7, 1, 14, 2,
13, 6, 15, 0, 9, 10, 4, 5, 3
],
// sbox6
[
12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11, 10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14,
0, 11, 3, 8, 9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6, 4, 3, 2, 12, 9, 5, 15, 10,
11, 14, 1, 7, 6, 0, 8, 13
],
// sbox7
[
4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1, 13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12,
2, 15, 8, 6, 1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2, 6, 11, 13, 8, 1, 4, 10, 7,
9, 5, 0, 15, 14, 2, 3, 12
],
// sbox8
[
13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7, 1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11,
0, 14, 9, 2, 7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8, 2, 1, 14, 7, 4, 10, 8, 13,
15, 12, 9, 0, 3, 5, 6, 11
]
]
/**
* 从 Buffer 中提取指定位置的位,并左移指定偏移量
* @param {Buffer} a - Buffer
* @param {number} b - 要提取的位索引
* @param {number} c - 位提取后的偏移量
* @returns {number} 提取后的位
*/
function bitnum(a, b, c) {
const byteIndex = Math.floor(b / 32) * 4 + 3 - Math.floor((b % 32) / 8)
const bitInByte = 7 - (b % 8)
const bit = (a[byteIndex] >> bitInByte) & 1
return bit << c
}
/**
* 从整数中提取指定位置的位,并左移指定偏移量
* @param {number} a - 整数
* @param {number} b - 要提取的位索引
* @param {number} c - 位提取后的偏移量
* @returns {number} 提取后的位
*/
function bitnum_intr(a, b, c) {
return (((a >>> (31 - b)) & 1) << c) | 0
}
/**
* 从整数中提取指定位置的位,并右移指定偏移量
* @param {number} a - 整数
* @param {number} b - 要提取的位索引
* @param {number} c - 位提取后的偏移量
* @returns {number} 提取后的位
*/
function bitnum_intl(a, b, c) {
return (((a << b) & 0x80000000) >>> c) | 0
}
/**
* 对输入整数进行位运算,重新组合位
* @param {number} a - 整数
* @returns {number} 重新组合后的位
*/
function sbox_bit(a) {
return (a & 32) | ((a & 31) >> 1) | ((a & 1) << 4) | 0
}
/**
* 初始置换
* @param {Buffer} input_data - 输入 Buffer
* @returns {[number, number]} 初始置换后的两个32位整数
*/
function initial_permutation(input_data) {
const s0 =
bitnum(input_data, 57, 31) |
bitnum(input_data, 49, 30) |
bitnum(input_data, 41, 29) |
bitnum(input_data, 33, 28) |
bitnum(input_data, 25, 27) |
bitnum(input_data, 17, 26) |
bitnum(input_data, 9, 25) |
bitnum(input_data, 1, 24) |
bitnum(input_data, 59, 23) |
bitnum(input_data, 51, 22) |
bitnum(input_data, 43, 21) |
bitnum(input_data, 35, 20) |
bitnum(input_data, 27, 19) |
bitnum(input_data, 19, 18) |
bitnum(input_data, 11, 17) |
bitnum(input_data, 3, 16) |
bitnum(input_data, 61, 15) |
bitnum(input_data, 53, 14) |
bitnum(input_data, 45, 13) |
bitnum(input_data, 37, 12) |
bitnum(input_data, 29, 11) |
bitnum(input_data, 21, 10) |
bitnum(input_data, 13, 9) |
bitnum(input_data, 5, 8) |
bitnum(input_data, 63, 7) |
bitnum(input_data, 55, 6) |
bitnum(input_data, 47, 5) |
bitnum(input_data, 39, 4) |
bitnum(input_data, 31, 3) |
bitnum(input_data, 23, 2) |
bitnum(input_data, 15, 1) |
bitnum(input_data, 7, 0) |
0
const s1 =
bitnum(input_data, 56, 31) |
bitnum(input_data, 48, 30) |
bitnum(input_data, 40, 29) |
bitnum(input_data, 32, 28) |
bitnum(input_data, 24, 27) |
bitnum(input_data, 16, 26) |
bitnum(input_data, 8, 25) |
bitnum(input_data, 0, 24) |
bitnum(input_data, 58, 23) |
bitnum(input_data, 50, 22) |
bitnum(input_data, 42, 21) |
bitnum(input_data, 34, 20) |
bitnum(input_data, 26, 19) |
bitnum(input_data, 18, 18) |
bitnum(input_data, 10, 17) |
bitnum(input_data, 2, 16) |
bitnum(input_data, 60, 15) |
bitnum(input_data, 52, 14) |
bitnum(input_data, 44, 13) |
bitnum(input_data, 36, 12) |
bitnum(input_data, 28, 11) |
bitnum(input_data, 20, 10) |
bitnum(input_data, 12, 9) |
bitnum(input_data, 4, 8) |
bitnum(input_data, 62, 7) |
bitnum(input_data, 54, 6) |
bitnum(input_data, 46, 5) |
bitnum(input_data, 38, 4) |
bitnum(input_data, 30, 3) |
bitnum(input_data, 22, 2) |
bitnum(input_data, 14, 1) |
bitnum(input_data, 6, 0) |
0
return [s0, s1]
}
/**
* 逆初始置换
* @param {number} s0 - 32位整数
* @param {number} s1 - 32位整数
* @returns {Buffer} 逆初始置换后的 Buffer
*/
function inverse_permutation(s0, s1) {
const data = Buffer.alloc(8)
data[3] =
bitnum_intr(s1, 7, 7) |
bitnum_intr(s0, 7, 6) |
bitnum_intr(s1, 15, 5) |
bitnum_intr(s0, 15, 4) |
bitnum_intr(s1, 23, 3) |
bitnum_intr(s0, 23, 2) |
bitnum_intr(s1, 31, 1) |
bitnum_intr(s0, 31, 0) |
0
data[2] =
bitnum_intr(s1, 6, 7) |
bitnum_intr(s0, 6, 6) |
bitnum_intr(s1, 14, 5) |
bitnum_intr(s0, 14, 4) |
bitnum_intr(s1, 22, 3) |
bitnum_intr(s0, 22, 2) |
bitnum_intr(s1, 30, 1) |
bitnum_intr(s0, 30, 0) |
0
data[1] =
bitnum_intr(s1, 5, 7) |
bitnum_intr(s0, 5, 6) |
bitnum_intr(s1, 13, 5) |
bitnum_intr(s0, 13, 4) |
bitnum_intr(s1, 21, 3) |
bitnum_intr(s0, 21, 2) |
bitnum_intr(s1, 29, 1) |
bitnum_intr(s0, 29, 0) |
0
data[0] =
bitnum_intr(s1, 4, 7) |
bitnum_intr(s0, 4, 6) |
bitnum_intr(s1, 12, 5) |
bitnum_intr(s0, 12, 4) |
bitnum_intr(s1, 20, 3) |
bitnum_intr(s0, 20, 2) |
bitnum_intr(s1, 28, 1) |
bitnum_intr(s0, 28, 0) |
0
data[7] =
bitnum_intr(s1, 3, 7) |
bitnum_intr(s0, 3, 6) |
bitnum_intr(s1, 11, 5) |
bitnum_intr(s0, 11, 4) |
bitnum_intr(s1, 19, 3) |
bitnum_intr(s0, 19, 2) |
bitnum_intr(s1, 27, 1) |
bitnum_intr(s0, 27, 0) |
0
data[6] =
bitnum_intr(s1, 2, 7) |
bitnum_intr(s0, 2, 6) |
bitnum_intr(s1, 10, 5) |
bitnum_intr(s0, 10, 4) |
bitnum_intr(s1, 18, 3) |
bitnum_intr(s0, 18, 2) |
bitnum_intr(s1, 26, 1) |
bitnum_intr(s0, 26, 0) |
0
data[5] =
bitnum_intr(s1, 1, 7) |
bitnum_intr(s0, 1, 6) |
bitnum_intr(s1, 9, 5) |
bitnum_intr(s0, 9, 4) |
bitnum_intr(s1, 17, 3) |
bitnum_intr(s0, 17, 2) |
bitnum_intr(s1, 25, 1) |
bitnum_intr(s0, 25, 0) |
0
data[4] =
bitnum_intr(s1, 0, 7) |
bitnum_intr(s0, 0, 6) |
bitnum_intr(s1, 8, 5) |
bitnum_intr(s0, 8, 4) |
bitnum_intr(s1, 16, 3) |
bitnum_intr(s0, 16, 2) |
bitnum_intr(s1, 24, 1) |
bitnum_intr(s0, 24, 0) |
0
return data
}
/**
* Triple-DES F函数
* @param {number} state - 输入
* @param {number[]} key - 密钥
* @returns {number} 输出
*/
function f(state, key) {
state = state | 0
const t1 =
bitnum_intl(state, 31, 0) |
(((state & 0xf0000000) >>> 1) | 0) |
bitnum_intl(state, 4, 5) |
bitnum_intl(state, 3, 6) |
(((state & 0x0f000000) >>> 3) | 0) |
bitnum_intl(state, 8, 11) |
bitnum_intl(state, 7, 12) |
(((state & 0x00f00000) >>> 5) | 0) |
bitnum_intl(state, 12, 17) |
bitnum_intl(state, 11, 18) |
(((state & 0x000f0000) >>> 7) | 0) |
bitnum_intl(state, 16, 23) |
0
const t2 =
bitnum_intl(state, 15, 0) |
(((state & 0x0000f000) << 15) | 0) |
bitnum_intl(state, 20, 5) |
bitnum_intl(state, 19, 6) |
(((state & 0x00000f00) << 13) | 0) |
bitnum_intl(state, 24, 11) |
bitnum_intl(state, 23, 12) |
(((state & 0x000000f0) << 11) | 0) |
bitnum_intl(state, 28, 17) |
bitnum_intl(state, 27, 18) |
(((state & 0x0000000f) << 9) | 0) |
bitnum_intl(state, 0, 23) |
0
const _lrgstate = [
(t1 >>> 24) & 0xff,
(t1 >>> 16) & 0xff,
(t1 >>> 8) & 0xff,
(t2 >>> 24) & 0xff,
(t2 >>> 16) & 0xff,
(t2 >>> 8) & 0xff
]
const lrgstate = _lrgstate.map((val, i) => val ^ key[i])
const newState =
(sbox[0][sbox_bit(lrgstate[0] >>> 2)] << 28) |
(sbox[1][sbox_bit(((lrgstate[0] & 0x03) << 4) | (lrgstate[1] >>> 4))] << 24) |
(sbox[2][sbox_bit(((lrgstate[1] & 0x0f) << 2) | (lrgstate[2] >>> 6))] << 20) |
(sbox[3][sbox_bit(lrgstate[2] & 0x3f)] << 16) |
(sbox[4][sbox_bit(lrgstate[3] >>> 2)] << 12) |
(sbox[5][sbox_bit(((lrgstate[3] & 0x03) << 4) | (lrgstate[4] >>> 4))] << 8) |
(sbox[6][sbox_bit(((lrgstate[4] & 0x0f) << 2) | (lrgstate[5] >>> 6))] << 4) |
sbox[7][sbox_bit(lrgstate[5] & 0x3f)] |
0
return (
bitnum_intl(newState, 15, 0) |
bitnum_intl(newState, 6, 1) |
bitnum_intl(newState, 19, 2) |
bitnum_intl(newState, 20, 3) |
bitnum_intl(newState, 28, 4) |
bitnum_intl(newState, 11, 5) |
bitnum_intl(newState, 27, 6) |
bitnum_intl(newState, 16, 7) |
bitnum_intl(newState, 0, 8) |
bitnum_intl(newState, 14, 9) |
bitnum_intl(newState, 22, 10) |
bitnum_intl(newState, 25, 11) |
bitnum_intl(newState, 4, 12) |
bitnum_intl(newState, 17, 13) |
bitnum_intl(newState, 30, 14) |
bitnum_intl(newState, 9, 15) |
bitnum_intl(newState, 1, 16) |
bitnum_intl(newState, 7, 17) |
bitnum_intl(newState, 23, 18) |
bitnum_intl(newState, 13, 19) |
bitnum_intl(newState, 31, 20) |
bitnum_intl(newState, 26, 21) |
bitnum_intl(newState, 2, 22) |
bitnum_intl(newState, 8, 23) |
bitnum_intl(newState, 18, 24) |
bitnum_intl(newState, 12, 25) |
bitnum_intl(newState, 29, 26) |
bitnum_intl(newState, 5, 27) |
bitnum_intl(newState, 21, 28) |
bitnum_intl(newState, 10, 29) |
bitnum_intl(newState, 3, 30) |
bitnum_intl(newState, 24, 31) |
0
)
}
/**
* TripleDES 加密/解密算法 (单块)
* @param {Buffer} input_data - 输入 Buffer
* @param {number[][]} key - 密钥
* @returns {Buffer} 加/解密后的 Buffer
*/
function crypt(input_data, key) {
let [s0, s1] = initial_permutation(input_data)
for (let idx = 0; idx < 15; idx++) {
const previous_s1 = s1
s1 = (f(s1, key[idx]) ^ s0) | 0
s0 = previous_s1
}
s0 = (f(s1, key[15]) ^ s0) | 0
return inverse_permutation(s0, s1)
}
/**
* TripleDES 密钥扩展算法
* @param {Buffer} key - 密钥
* @param {number} mode - 模式 (ENCRYPT/DECRYPT)
* @returns {number[][]} 密钥扩展
*/
function key_schedule(key, mode) {
const schedule = Array.from({ length: 16 }, () => Array(6).fill(0))
const key_rnd_shift = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1]
const key_perm_c = [
56, 48, 40, 32, 24, 16, 8, 0, 57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59,
51, 43, 35
]
const key_perm_d = [
62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 60, 52, 44, 36, 28, 20, 12, 4,
27, 19, 11, 3
]
const key_compression = [
13, 16, 10, 23, 0, 4, 2, 27, 14, 5, 20, 9, 22, 18, 11, 3, 25, 7, 15, 6, 26, 19, 12, 1, 40, 51,
30, 36, 46, 54, 29, 39, 50, 44, 32, 47, 43, 48, 38, 55, 33, 52, 45, 41, 49, 35, 28, 31
]
let c = 0,
d = 0
for (let i = 0; i < 28; i++) {
c |= bitnum(key, key_perm_c[i], 31 - i)
d |= bitnum(key, key_perm_d[i], 31 - i)
}
c = c | 0
d = d | 0
for (let i = 0; i < 16; i++) {
const shift = key_rnd_shift[i]
c = (((c << shift) | (c >>> (28 - shift))) & 0xfffffff0) | 0
d = (((d << shift) | (d >>> (28 - shift))) & 0xfffffff0) | 0
const togen = mode === DECRYPT ? 15 - i : i
schedule[togen] = Array(6).fill(0)
for (let j = 0; j < 24; j++) {
schedule[togen][Math.floor(j / 8)] |= bitnum_intr(c, key_compression[j], 7 - (j % 8))
}
for (let j = 24; j < 48; j++) {
schedule[togen][Math.floor(j / 8)] |= bitnum_intr(d, key_compression[j] - 27, 7 - (j % 8))
}
}
return schedule
}
/**
* TripleDES 密钥设置
* @param {Buffer} key - 密钥
* @param {number} mode - 模式
* @returns {number[][][]} 密钥设置
*/
function tripledes_key_setup(key, mode) {
if (mode === ENCRYPT) {
return [
key_schedule(key.slice(0, 8), ENCRYPT),
key_schedule(key.slice(8, 16), DECRYPT),
key_schedule(key.slice(16, 24), ENCRYPT)
]
}
return [
key_schedule(key.slice(16, 24), DECRYPT),
key_schedule(key.slice(8, 16), ENCRYPT),
key_schedule(key.slice(0, 8), DECRYPT)
]
}
/**
* TripleDES 加密/解密算法 (完整)
* @param {Buffer} data - 输入 Buffer
* @param {number[][][]} key - 密钥
* @returns {Buffer} 加/解密后的 Buffer
*/
function tripledes_crypt(data, key) {
let result = data
for (let i = 0; i < 3; i++) {
result = crypt(result, key[i])
}
return result
}
/**
* QRC解密主函数
* @param {string | Buffer} encrypted_qrc - 加密的QRC内容 (十六进制字符串或Buffer)
* @returns {string} 解密后的UTF-8字符串
*/
function qrc_decrypt(encrypted_qrc) {
if (!encrypted_qrc) {
return ''
}
let input_buffer
if (typeof encrypted_qrc === 'string') {
input_buffer = Buffer.from(encrypted_qrc, 'hex')
} else if (Buffer.isBuffer(encrypted_qrc)) {
input_buffer = encrypted_qrc
} else {
throw new Error('无效的加密数据类型')
}
try {
const decrypted_chunks = []
const key = Buffer.from('!@#)(*$%123ZXC!@!@#)(NHL')
const schedule = tripledes_key_setup(key, DECRYPT)
for (let i = 0; i < input_buffer.length; i += 8) {
const chunk = input_buffer.slice(i, i + 8)
if (chunk.length < 8) {
// 如果最后一块不足8字节DES无法处理但QRC格式应该是8的倍数
// 这里可以根据实际情况决定如何处理,例如抛出错误或填充
// 根据原始代码行为这里假设输入总是8字节的倍数
console.warn('警告: 数据末尾存在不足8字节的块可能导致解密不完整。')
continue
}
decrypted_chunks.push(tripledes_crypt(chunk, schedule))
}
const data = Buffer.concat(decrypted_chunks)
const decompressed = zlib.unzipSync(data)
return decompressed.toString('utf-8')
} catch (e) {
throw new Error(`解密失败: ${e.message}`)
}
}
// 导出主函数
return qrc_decrypt
}

View File

@@ -0,0 +1,86 @@
import { httpFetch } from '../../request'
import { sizeFormate } from '../../index'
export const getBatchMusicQualityInfo = (songList) => {
const songIds = songList.map((item) => item.id)
const requestObj = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
method: 'post',
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)',
},
body: {
comm: {
ct: '19',
cv: '1859',
uin: '0',
},
req: {
module: 'music.trackInfo.UniformRuleCtrl',
method: 'CgiGetTrackInfo',
param: {
types: Array(songIds.length).fill(1),
ids: songIds,
ctx: 0,
},
},
},
})
const qualityInfoMap = {}
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
if (statusCode != 200 || body.code != 0) return Promise.reject(new Error('获取音质信息失败'))
// Process each track from the response
body.req.data.tracks.forEach((track) => {
const file = track.file
const songId = track.id
const types = []
const _types = {}
if (file.size_128mp3 != 0) {
let size = sizeFormate(file.size_128mp3)
types.push({ type: '128k', size })
_types['128k'] = { size }
}
if (file.size_320mp3 !== 0) {
let size = sizeFormate(file.size_320mp3)
types.push({ type: '320k', size })
_types['320k'] = { size }
}
if (file.size_flac !== 0) {
let size = sizeFormate(file.size_flac)
types.push({ type: 'flac', size })
_types.flac = { size }
}
if (file.size_hires !== 0) {
let size = sizeFormate(file.size_hires)
types.push({ type: 'hires', size })
_types.hires = { size }
}
if (file.size_new[1] !== 0) {
let size = sizeFormate(file.size_new[1])
types.push({ type: 'atmos', size })
_types.atmos = { size }
}
if (file.size_new[2] !== 0) {
let size = sizeFormate(file.size_new[2])
types.push({ type: 'atmos_plus', size })
_types.atmos_plus = { size }
}
if (file.size_new[0] !== 0) {
let size = sizeFormate(file.size_new[0])
types.push({ type: 'master', size })
_types.master = { size }
}
qualityInfoMap[songId] = { types, _types }
})
return qualityInfoMap
})
return requestObj
}

View File

@@ -1,6 +1,7 @@
import { httpFetch } from '../../request'
import { decodeName, formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index'
import { decodeName, formatPlayTime, dateFormat, formatPlayCount } from '../../index'
import { formatSingerName } from '../utils'
import { getBatchMusicQualityInfo } from './quality_detail'
export default {
_requestObj_tags: null,
@@ -12,12 +13,12 @@ export default {
sortList: [
{
name: '最热',
id: 5
id: 5,
},
{
name: '最新',
id: 2
}
id: 2,
},
],
regExps: {
hotTagHtml: /class="c_bg_link js_tag_item" data-id="\w+">.+?<\/a>/g,
@@ -26,7 +27,7 @@ export default {
// https://y.qq.com/n/yqq/playlist/7217720898.html
// https://i.y.qq.com/n2/m/share/details/taoge.html?platform=11&appshare=android_qq&appversion=9050006&id=7217720898&ADTAG=qfshare
listDetailLink: /\/playlist\/(\d+)/,
listDetailLink2: /id=(\d+)/
listDetailLink2: /id=(\d+)/,
},
tagsUrl:
'https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8&notice=0&platform=wk_v15.json&needNewCode=0&data=%7B%22tags%22%3A%7B%22method%22%3A%22get_all_categories%22%2C%22param%22%3A%7B%22qq%22%3A%22%22%7D%2C%22module%22%3A%22playlist.PlaylistAllCategoriesServer%22%7D%7D',
@@ -45,10 +46,10 @@ export default {
category_id: id,
size: this.limit_list,
page: page - 1,
use_page: 1
use_page: 1,
},
module: 'playlist.PlayListCategoryServer'
}
module: 'playlist.PlayListCategoryServer',
},
})
)}`
}
@@ -62,10 +63,10 @@ export default {
sin: this.limit_list * (page - 1),
size: this.limit_list,
order: sortId,
cur_page: page
cur_page: page,
},
module: 'playlist.PlayListPlazaServer'
}
module: 'playlist.PlayListPlazaServer',
},
})
)}`
},
@@ -95,17 +96,17 @@ export default {
})
},
filterInfoHotTag(html) {
const hotTag = html.match(this.regExps.hotTagHtml)
let hotTag = html.match(this.regExps.hotTagHtml)
const hotTags = []
if (!hotTag) return hotTags
hotTag.forEach((tagHtml) => {
const result = tagHtml.match(this.regExps.hotTag)
let result = tagHtml.match(this.regExps.hotTag)
if (!result) return
hotTags.push({
id: parseInt(result[1]),
name: result[2],
source: 'tx'
source: 'tx',
})
})
return hotTags
@@ -118,8 +119,8 @@ export default {
parent_name: type.group_name,
id: item.id,
name: item.name,
source: 'tx'
}))
source: 'tx',
})),
}))
},
@@ -130,7 +131,9 @@ export default {
this._requestObj_list = httpFetch(this.getListUrl(sortId, tagId, page))
// console.log(this.getListUrl(sortId, tagId, page))
return this._requestObj_list.promise.then(({ body }) => {
if (body.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum)
if (body.code !== this.successCode) {
return this.getList(sortId, tagId, page, ++tryNum)
}
return tagId
? this.filterList2(body.playlist.data, page)
: this.filterList(body.playlist.data, page)
@@ -149,12 +152,12 @@ export default {
// grade: item.favorcnt / 10,
total: item.song_ids?.length,
desc: decodeName(item.desc).replace(/<br>/g, '\n'),
source: 'tx'
source: 'tx',
})),
total: data.total,
page,
limit: this.limit_list,
source: 'tx'
source: 'tx',
}
},
filterList2({ content }, page) {
@@ -169,12 +172,12 @@ export default {
img: basic.cover.medium_url || basic.cover.default_url,
// grade: basic.favorcnt / 10,
desc: decodeName(basic.desc).replace(/<br>/g, '\n'),
source: 'tx'
source: 'tx',
})),
total: content.total_cnt,
page,
limit: this.limit_list,
source: 'tx'
source: 'tx',
}
},
@@ -184,7 +187,7 @@ export default {
const requestObj_listDetailLink = httpFetch(link)
const {
headers: { location },
statusCode
statusCode,
} = await requestObj_listDetailLink.promise
// console.log(headers)
if (statusCode > 400) return this.handleParseId(link, ++retryNum)
@@ -215,15 +218,15 @@ export default {
const requestObj_listDetail = httpFetch(this.getListDetailUrl(id), {
headers: {
Origin: 'https://y.qq.com',
Referer: `https://y.qq.com/n/yqq/playsquare/${id}.html`
}
Referer: `https://y.qq.com/n/yqq/playsquare/${id}.html`,
},
})
const { body } = await requestObj_listDetail.promise
if (body.code !== this.successCode) return this.getListDetail(id, ++tryNum)
const cdlist = body.cdlist[0]
return {
list: this.filterListDetail(cdlist.songlist),
list: await this.filterListDetail(cdlist.songlist),
page: 1,
limit: cdlist.songlist.length + 1,
total: cdlist.songlist.length,
@@ -233,44 +236,23 @@ export default {
img: cdlist.logo,
desc: decodeName(cdlist.desc).replace(/<br>/g, '\n'),
author: cdlist.nickname,
play_count: formatPlayCount(cdlist.visitnum)
}
play_count: formatPlayCount(cdlist.visitnum),
},
}
},
filterListDetail(rawList) {
// console.log(rawList)
async filterListDetail(rawList) {
const qualityInfoRequest = getBatchMusicQualityInfo(rawList)
let qualityInfoMap = {}
try {
qualityInfoMap = await qualityInfoRequest.promise
} catch (error) {
console.error('Failed to fetch quality info:', error)
}
return rawList.map((item) => {
const types = []
const _types = {}
if (item.file.size_128mp3 !== 0) {
const size = sizeFormate(item.file.size_128mp3)
types.push({ type: '128k', size })
_types['128k'] = {
size
}
}
if (item.file.size_320mp3 !== 0) {
const size = sizeFormate(item.file.size_320mp3)
types.push({ type: '320k', size })
_types['320k'] = {
size
}
}
if (item.file.size_flac !== 0) {
const size = sizeFormate(item.file.size_flac)
types.push({ type: 'flac', size })
_types.flac = {
size
}
}
if (item.file.size_hires !== 0) {
const size = sizeFormate(item.file.size_hires)
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size
}
}
// types.reverse()
const { types = [], _types = {} } = qualityInfoMap[item.id] || {}
return {
singer: formatSingerName(item.singer, 'name'),
name: item.title,
@@ -292,7 +274,7 @@ export default {
otherSource: null,
types,
_types,
typeUrl: {}
typeUrl: {},
}
})
},
@@ -300,7 +282,7 @@ export default {
return Promise.all([this.getTag(), this.getHotTag()]).then(([tags, hotTag]) => ({
tags,
hotTag,
source: 'tx'
source: 'tx',
}))
},
@@ -313,12 +295,16 @@ export default {
search(text, page, limit = 20, retryNum = 0) {
if (retryNum > 5) throw new Error('max retry')
return httpFetch(
`http://c.y.qq.com/soso/fcgi-bin/client_music_search_songlist?page_no=${page - 1}&num_per_page=${limit}&format=json&query=${encodeURIComponent(text)}&remoteplace=txt.yqq.playlist&inCharset=utf8&outCharset=utf-8`,
`http://c.y.qq.com/soso/fcgi-bin/client_music_search_songlist?page_no=${
page - 1
}&num_per_page=${limit}&format=json&query=${encodeURIComponent(
text
)}&remoteplace=txt.yqq.playlist&inCharset=utf8&outCharset=utf-8`,
{
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)',
Referer: 'http://y.qq.com/portal/search.html'
}
Referer: 'http://y.qq.com/portal/search.html',
},
}
).promise.then(({ body }) => {
if (body.code != 0) return this.search(text, page, limit, ++retryNum)
@@ -335,17 +321,17 @@ export default {
// grade: item.favorcnt / 10,
total: item.song_count,
desc: decodeName(decodeName(item.introduction)).replace(/<br>/g, '\n'),
source: 'tx'
source: 'tx',
}
}),
limit,
total: body.data.sum,
source: 'tx'
source: 'tx',
}
})
}
},
}
// getList
// getTags
// getListDetail
// getListDetail

View File

@@ -21,12 +21,33 @@ export default {
})
},
handleResult(rawData) {
return rawData.map((info) => `${info.name} - ${info.singer}`)
let list = {
order: [],
songs: [],
artists: [],
albums: []
}
if (rawData.song.count > 0) {
list.order.push('songs')
}
if (rawData.singer.count > 0) {
list.order.push('artists')
}
if (rawData.album.count > 0) {
list.order.push('albums')
}
list.songs = rawData.song.itemlist.map((info) => ({
name: info.name,
artist: { name: info.singer }
}))
list.artists = rawData.singer.itemlist.map((info) => ({ name: info.name }))
list.albums = rawData.album.itemlist.map((info) => ({ name: info.name }))
return list
},
cancelTipSearch() {
if (this.requestObj && this.requestObj.cancelHttp) this.requestObj.cancelHttp()
},
async search(str) {
return this.tipSearch(str).then((result) => this.handleResult(result.song.itemlist))
return this.tipSearch(str).then((result) => this.handleResult(result))
}
}

View File

@@ -10,16 +10,13 @@ export const getHostIp = (hostname) => {
if (typeof result === 'object') return result
if (result === true) return
ipMap.set(hostname, true)
// console.log(hostname)
dns.lookup(
hostname,
{
// family: 4,
all: false
all: false,
},
(err, address, family) => {
if (err) return console.log(err)
// console.log(address, family)
ipMap.set(hostname, { address, family })
}
)
@@ -42,11 +39,11 @@ export const formatSingerName = (singers, nameKey = 'name', join = '、') => {
if (Array.isArray(singers)) {
const singer = []
singers.forEach((item) => {
const name = item[nameKey]
let name = item[nameKey]
if (!name) return
singer.push(name)
})
return decodeName(singer.join(join))
}
return decodeName(String(singers ?? ''))
}
}

View File

@@ -5,9 +5,10 @@ import musicSearch from './musicSearch'
import songList from './songList'
import hotSearch from './hotSearch'
import comment from './comment'
import tipSearch from './tipSearch'
const wy = {
// tipSearch,
tipSearch,
leaderboard,
musicSearch,
songList,

View File

@@ -1,57 +1,23 @@
import { httpFetch } from '../../request'
import { weapi } from './utils/crypto'
import { formatPlayTime, sizeFormate } from '../index'
// https://github.com/Binaryify/NeteaseCloudMusicApi/blob/master/module/song_detail.js
import { formatPlayTime } from '../../index'
import { getBatchMusicQualityInfo } from './quality_detail'
export default {
getSinger(singers) {
const arr = []
let arr = []
singers?.forEach((singer) => {
arr.push(singer.name)
})
return arr.join('、')
},
filterList({ songs, privileges }) {
// console.log(songs, privileges)
async filterList({ songs, privileges }) {
const list = []
const idList = songs.map((item) => item.id)
const qualityInfoMap = await getBatchMusicQualityInfo(idList)
songs.forEach((item, index) => {
const types = []
const _types = {}
let size
let privilege = privileges[index]
if (privilege.id !== item.id) privilege = privileges.find((p) => p.id === item.id)
if (!privilege) return
if (privilege.maxBrLevel == 'hires') {
size = item.hr ? sizeFormate(item.hr.size) : null
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size
}
}
switch (privilege.maxbr) {
case 999000:
size = item.sq ? sizeFormate(item.sq.size) : null
types.push({ type: 'flac', size })
_types.flac = {
size
}
case 320000:
size = item.h ? sizeFormate(item.h.size) : null
types.push({ type: '320k', size })
_types['320k'] = {
size
}
case 192000:
case 128000:
size = item.l ? sizeFormate(item.l.size) : null
types.push({ type: '128k', size })
_types['128k'] = {
size
}
}
types.reverse()
const { types, _types } = qualityInfoMap[item.id] || { types: [], _types: {} }
if (item.pc) {
list.push({
@@ -67,7 +33,7 @@ export default {
otherSource: null,
types,
_types,
typeUrl: {}
typeUrl: {},
})
} else {
list.push({
@@ -83,11 +49,10 @@ export default {
otherSource: null,
types,
_types,
typeUrl: {}
typeUrl: {},
})
}
})
// console.log(list)
return list
},
async getList(ids = [], retryNum = 0) {
@@ -98,16 +63,15 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
origin: 'https://music.163.com'
origin: 'https://music.163.com',
},
form: weapi({
c: '[' + ids.map((id) => '{"id":' + id + '}').join(',') + ']',
ids: '[' + ids.join(',') + ']'
})
ids: '[' + ids.join(',') + ']',
}),
})
const { body, statusCode } = await requestObj.promise
if (statusCode != 200 || body.code !== 200) throw new Error('获取歌曲详情失败')
// console.log(body)
return { source: 'wy', list: this.filterList(body) }
}
}
return { source: 'wy', list: await this.filterList(body) }
},
}

View File

@@ -1,7 +1,5 @@
// import { httpFetch } from '../../request'
// import { weapi } from './utils/crypto'
import { sizeFormate, formatPlayTime } from '../index'
// import musicDetailApi from './musicDetail'
import { httpFetch } from '../../request'
import { sizeFormate, formatPlayTime } from '../../index'
import { eapiRequest } from './utils/index'
export default {
@@ -9,101 +7,129 @@ export default {
total: 0,
page: 0,
allPage: 1,
musicSearch(str, page, limit) {
const searchRequest = eapiRequest('/api/cloudsearch/pc', {
s: str,
type: 1, // 1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单, 1002: 用户, 1004: MV, 1006: 歌词, 1009: 电台, 1014: 视频
limit,
total: page == 1,
offset: limit * (page - 1)
offset: limit * (page - 1),
})
return searchRequest.promise.then(({ body }) => body)
},
getSinger(singers) {
const arr = []
singers.forEach((singer) => {
arr.push(singer.name)
})
return arr.join('、')
return singers.map((singer) => singer.name).join('、')
},
handleResult(rawList) {
// console.log(rawList)
if (!rawList) return []
return rawList.map((item) => {
const types = []
const _types = {}
let size
if (item.privilege.maxBrLevel == 'hires') {
size = item.hr ? sizeFormate(item.hr.size) : null
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size
return Promise.all(
rawList.map(async (item) => {
const types = []
const _types = {}
let size
try {
const requestObj = httpFetch(
`https://music.163.com/api/song/music/detail/get?songId=${item.id}`,
{
method: 'get',
headers: {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
origin: 'https://music.163.com',
},
}
)
const { body, statusCode } = await requestObj.promise
if (statusCode !== 200 || !body || body.code !== 200) {
throw new Error('Failed to get song quality information')
}
if (body.data.jm && body.data.jm.size) {
size = sizeFormate(body.data.jm.size)
types.push({ type: 'master', size })
_types.master = { size }
}
if (body.data.db && body.data.db.size) {
size = sizeFormate(body.data.db.size)
types.push({ type: 'dolby', size })
_types.dolby = { size }
}
if (body.data.hr && body.data.hr.size) {
size = sizeFormate(body.data.hr.size)
types.push({ type: 'hires', size })
_types.hires = { size }
}
if (body.data.sq && body.data.sq.size) {
size = sizeFormate(body.data.sq.size)
types.push({ type: 'flac', size })
_types.flac = { size }
}
if (body.data.h && body.data.h.size) {
size = sizeFormate(body.data.h.size)
types.push({ type: '320k', size })
_types['320k'] = { size }
}
if (body.data.m && body.data.m.size) {
size = sizeFormate(body.data.m.size)
types.push({ type: '128k', size })
_types['128k'] = { size }
} else if (body.data.l && body.data.l.size) {
size = sizeFormate(body.data.l.size)
types.push({ type: '128k', size })
_types['128k'] = { size }
}
types.reverse()
return {
singer: this.getSinger(item.ar),
name: item.name,
albumName: item.al.name,
albumId: item.al.id,
source: 'wy',
interval: formatPlayTime(item.dt / 1000),
songmid: item.id,
img: item.al.picUrl,
lrc: null,
types,
_types,
typeUrl: {},
}
} catch (error) {
console.error(error.message)
return null
}
}
switch (item.privilege.maxbr) {
case 999000:
size = item.sq ? sizeFormate(item.sq.size) : null
types.push({ type: 'flac', size })
_types.flac = {
size
}
case 320000:
size = item.h ? sizeFormate(item.h.size) : null
types.push({ type: '320k', size })
_types['320k'] = {
size
}
case 192000:
case 128000:
size = item.l ? sizeFormate(item.l.size) : null
types.push({ type: '128k', size })
_types['128k'] = {
size
}
}
types.reverse()
return {
singer: this.getSinger(item.ar),
name: item.name,
albumName: item.al.name,
albumId: item.al.id,
source: 'wy',
interval: formatPlayTime(item.dt / 1000),
songmid: item.id,
img: item.al.picUrl,
lrc: null,
types,
_types,
typeUrl: {}
}
})
})
)
},
search(str, page = 1, limit, retryNum = 0) {
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
if (limit == null) limit = this.limit
return this.musicSearch(str, page, limit).then((result) => {
// console.log(result)
if (!result || result.code !== 200) return this.search(str, page, limit, retryNum)
const list = this.handleResult(result.result.songs || [])
// console.log(list)
return this.handleResult(result.result.songs || []).then((list) => {
if (!list || list.length === 0) return this.search(str, page, limit, retryNum)
if (list == null) return this.search(str, page, limit, retryNum)
this.total = result.result.songCount || 0
this.page = page
this.allPage = Math.ceil(this.total / this.limit)
this.total = result.result.songCount || 0
this.page = page
this.allPage = Math.ceil(this.total / this.limit)
return {
list,
allPage: this.allPage,
limit: this.limit,
total: this.total,
source: 'wy'
}
// return result.data
return {
list,
allPage: this.allPage,
limit: this.limit,
total: this.total,
source: 'wy',
}
})
})
}
}
},
}

View File

@@ -0,0 +1,91 @@
import { httpFetch } from '../../request'
import { sizeFormate } from '../../index'
export const getMusicQualityInfo = (id) => {
const requestObj = httpFetch(`https://music.163.com/api/song/music/detail/get?songId=${id}`, {
method: 'get',
headers: {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
origin: 'https://music.163.com',
},
})
const types = []
const _types = {}
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
if (statusCode != 200 && body.code != 200) return Promise.reject(new Error('获取音质信息失败' + id))
const data = body.data
types.length = 0
Object.keys(_types).forEach((key) => delete _types[key])
if (data.l != null && data.l.size != null) {
let size = sizeFormate(data.l.size)
types.push({ type: '128k', size })
_types['128k'] = { size }
} else if (data.m != null && data.m.size != null) {
let size = sizeFormate(data.m.size)
types.push({ type: '128k', size })
_types['128k'] = { size }
}
if (data.h != null && data.h.size != null) {
let size = sizeFormate(data.h.size)
types.push({ type: '320k', size })
_types['320k'] = { size }
}
if (data.sq != null && data.sq.size != null) {
let size = sizeFormate(data.sq.size)
types.push({ type: 'flac', size })
_types.flac = { size }
}
if (data.hr != null && data.hr.size != null) {
let size = sizeFormate(data.hr.size)
types.push({ type: 'hires', size })
_types.hires = { size }
}
if (data.jm != null && data.jm.size != null) {
let size = sizeFormate(data.jm.size)
types.push({ type: 'master', size })
_types.master = { size }
}
if (data.je != null && data.je.size != null) {
let size = sizeFormate(data.je.size)
types.push({ type: 'atmos', size })
_types.atmos = { size }
}
return { types: [...types], _types: { ..._types } }
})
return { requestObj, types, _types }
}
export const getBatchMusicQualityInfo = async (idList) => {
const ids = idList.filter((id) => id)
const qualityPromises = ids.map((id) => {
const result = getMusicQualityInfo(id)
return result.requestObj.promise.catch((err) => {
console.error(`获取歌曲 ${id} 音质信息失败:`, err)
return { types: [], _types: {} }
})
})
const qualityResults = await Promise.all(qualityPromises)
const qualityInfoMap = {}
ids.forEach((id, index) => {
qualityInfoMap[id] = qualityResults[index] || { types: [], _types: {} }
})
return qualityInfoMap
}

View File

@@ -1,9 +1,9 @@
import { weapi, linuxapi } from './utils/crypto'
import { httpFetch } from '../../request'
import { formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index'
import { /* formatPlayTime, */ dateFormat, formatPlayCount } from '../../index'
import musicDetailApi from './musicDetail'
import { eapiRequest } from './utils/index'
import { formatSingerName } from '../utils'
// import { formatSingerName } from '../utils'
export default {
_requestObj_tags: null,
@@ -16,16 +16,12 @@ export default {
sortList: [
{
name: '最热',
id: 'hot'
}
// {
// name: '最新',
// id: 'new',
// },
id: 'hot',
},
],
regExps: {
listDetailLink: /^.+(?:\?|&)id=(\d+)(?:&.*$|#.*$|$)/,
listDetailLink2: /^.+\/playlist\/(\d+)\/\d+\/.+$/
listDetailLink2: /^.+\/playlist\/(\d+)\/\d+\/.+$/,
},
async handleParseId(link, retryNum = 0) {
@@ -34,9 +30,8 @@ export default {
const requestObj_listDetailLink = httpFetch(link)
const {
headers: { location },
statusCode
statusCode,
} = await requestObj_listDetailLink.promise
// console.log(statusCode)
if (statusCode > 400) return this.handleParseId(link, ++retryNum)
const url = location == null ? link : location
return this.regExps.listDetailLink.test(url)
@@ -59,13 +54,11 @@ export default {
} else {
id = await this.handleParseId(id)
}
// console.log(id)
}
return { id, cookie }
},
async getListDetail(rawId, page, tryNum = 0) {
// 获取歌曲列表内的音乐
if (tryNum > 2) return Promise.reject(new Error('try max num'))
if (tryNum > 1000) return Promise.reject(new Error('try max num'))
const { id, cookie } = await this.getListId(rawId)
if (cookie) this.cookie = cookie
@@ -75,7 +68,7 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
Cookie: this.cookie
Cookie: this.cookie,
},
form: linuxapi({
method: 'POST',
@@ -83,36 +76,30 @@ export default {
params: {
id,
n: this.limit_song,
s: 8
}
})
s: 8,
},
}),
})
const { statusCode, body } = await requestObj_listDetail.promise
if (statusCode !== 200 || body.code !== this.successCode)
return this.getListDetail(id, page, ++tryNum)
const limit = 1000
const rangeStart = (page - 1) * limit
// console.log(body)
let limit = 50
let rangeStart = (page - 1) * limit
let list
if (body.playlist.trackIds.length == body.privileges.length) {
list = this.filterListDetail(body)
} else {
try {
list = (
await musicDetailApi.getList(
body.playlist.trackIds.slice(rangeStart, limit * page).map((trackId) => trackId.id)
)
).list
} catch (err) {
console.log(err)
if (err.message == 'try max num') {
throw err
} else {
return this.getListDetail(id, page, ++tryNum)
}
try {
list = (
await musicDetailApi.getList(
body.playlist.trackIds.slice(rangeStart, limit * page).map((trackId) => trackId.id)
)
).list
} catch (err) {
console.log(err)
if (err.message == 'try max num') {
throw err
} else {
return this.getListDetail(id, page, ++tryNum)
}
}
// console.log(list)
return {
list,
page,
@@ -124,119 +111,79 @@ export default {
name: body.playlist.name,
img: body.playlist.coverImgUrl,
desc: body.playlist.description,
author: body.playlist.creator.nickname
}
author: body.playlist.creator.nickname,
},
}
},
filterListDetail({ playlist: { tracks }, privileges }) {
// console.log(tracks, privileges)
const list = []
tracks.forEach((item, index) => {
const types = []
const _types = {}
let size
let privilege = privileges[index]
if (privilege.id !== item.id) privilege = privileges.find((p) => p.id === item.id)
if (!privilege) return
if (privilege.maxBrLevel == 'hires') {
size = item.hr ? sizeFormate(item.hr.size) : null
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size
}
}
switch (privilege.maxbr) {
case 999000:
size = null
types.push({ type: 'flac', size })
_types.flac = {
size
}
// filterListDetail({ playlist: { tracks } }) {
// const list = []
// tracks.forEach((item) => {
// const types = []
// const _types = {}
case 320000:
size = item.h ? sizeFormate(item.h.size) : null
types.push({ type: '320k', size })
_types['320k'] = {
size
}
// if (item.pc) {
// list.push({
// singer: item.pc.ar ?? '',
// name: item.pc.sn ?? '',
// albumName: item.pc.alb ?? '',
// albumId: item.al?.id,
// source: 'wy',
// interval: formatPlayTime(item.dt / 1000),
// songmid: item.id,
// img: item.al?.picUrl ?? '',
// lrc: null,
// otherSource: null,
// types,
// _types,
// typeUrl: {},
// })
// } else {
// list.push({
// singer: formatSingerName(item.ar, 'name'),
// name: item.name ?? '',
// albumName: item.al?.name,
// albumId: item.al?.id,
// source: 'wy',
// interval: formatPlayTime(item.dt / 1000),
// songmid: item.id,
// img: item.al?.picUrl,
// lrc: null,
// otherSource: null,
// types,
// _types,
// typeUrl: {},
// })
// }
// })
// return list
// },
case 192000:
case 128000:
size = item.l ? sizeFormate(item.l.size) : null
types.push({ type: '128k', size })
_types['128k'] = {
size
}
}
types.reverse()
if (item.pc) {
list.push({
singer: item.pc.ar ?? '',
name: item.pc.sn ?? '',
albumName: item.pc.alb ?? '',
albumId: item.al?.id,
source: 'wy',
interval: formatPlayTime(item.dt / 1000),
songmid: item.id,
img: item.al?.picUrl ?? '',
lrc: null,
otherSource: null,
types,
_types,
typeUrl: {}
})
} else {
list.push({
singer: formatSingerName(item.ar, 'name'),
name: item.name ?? '',
albumName: item.al?.name,
albumId: item.al?.id,
source: 'wy',
interval: formatPlayTime(item.dt / 1000),
songmid: item.id,
img: item.al?.picUrl,
lrc: null,
otherSource: null,
types,
_types,
typeUrl: {}
})
}
})
return list
},
// 获取列表数据
getList(sortId, tagId, page, tryNum = 0) {
if (tryNum > 2) return Promise.reject(new Error('try max num'))
if (this._requestObj_list) this._requestObj_list.cancelHttp()
this._requestObj_list = httpFetch('https://music.163.com/weapi/playlist/list', {
method: 'post',
form: weapi({
cat: tagId || '全部', // 全部,华语,欧美,日语,韩语,粤语,小语种,流行,摇滚,民谣,电子,舞曲,说唱,轻音乐,爵士,乡村,R&B/Soul,古典,民族,英伦,金属,朋克,蓝调,雷鬼,世界音乐,拉丁,另类/独立,New Age,古风,后摇,Bossa Nova,清晨,夜晚,学习,工作,午休,下午茶,地铁,驾车,运动,旅行,散步,酒吧,怀旧,清新,浪漫,性感,伤感,治愈,放松,孤独,感动,兴奋,快乐,安静,思念,影视原声,ACG,儿童,校园,游戏,70后,80后,90后,网络歌曲,KTV,经典,翻唱,吉他,钢琴,器乐,榜单,00后
order: sortId, // hot,new
cat: tagId || '全部',
order: sortId,
limit: this.limit_list,
offset: this.limit_list * (page - 1),
total: true
})
total: true,
}),
})
return this._requestObj_list.promise.then(({ body }) => {
// console.log(body)
if (body.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum)
return {
list: this.filterList(body.playlists),
total: parseInt(body.total),
page,
limit: this.limit_list,
source: 'wy'
source: 'wy',
}
})
},
filterList(rawData) {
// console.log(rawData)
return rawData.map((item) => ({
play_count: formatPlayCount(item.playCount),
id: String(item.id),
@@ -247,20 +194,18 @@ export default {
grade: item.grade,
total: item.trackCount,
desc: item.description,
source: 'wy'
source: 'wy',
}))
},
// 获取标签
getTag(tryNum = 0) {
if (this._requestObj_tags) this._requestObj_tags.cancelHttp()
if (tryNum > 2) return Promise.reject(new Error('try max num'))
this._requestObj_tags = httpFetch('https://music.163.com/weapi/playlist/catalogue', {
method: 'post',
form: weapi({})
form: weapi({}),
})
return this._requestObj_tags.promise.then(({ body }) => {
// console.log(JSON.stringify(body))
if (body.code !== this.successCode) return this.getTag(++tryNum)
return this.filterTagInfo(body)
})
@@ -274,7 +219,7 @@ export default {
parent_name: categories[item.category],
id: item.name,
name: item.name,
source: 'wy'
source: 'wy',
})
}
@@ -283,22 +228,20 @@ export default {
list.push({
name: categories[key],
list: subList[key],
source: 'wy'
source: 'wy',
})
}
return list
},
// 获取热门标签
getHotTag(tryNum = 0) {
if (this._requestObj_hotTags) this._requestObj_hotTags.cancelHttp()
if (tryNum > 2) return Promise.reject(new Error('try max num'))
this._requestObj_hotTags = httpFetch('https://music.163.com/weapi/playlist/hottags', {
method: 'post',
form: weapi({})
form: weapi({}),
})
return this._requestObj_hotTags.promise.then(({ body }) => {
// console.log(JSON.stringify(body))
if (body.code !== this.successCode) return this.getTag(++tryNum)
return this.filterHotTagInfo(body.tags)
})
@@ -307,7 +250,7 @@ export default {
return rawList.map((item) => ({
id: item.playlistTag.name,
name: item.playlistTag.name,
source: 'wy'
source: 'wy',
}))
},
@@ -315,7 +258,7 @@ export default {
return Promise.all([this.getTag(), this.getHotTag()]).then(([tags, hotTag]) => ({
tags,
hotTag,
source: 'wy'
source: 'wy',
}))
},
@@ -327,23 +270,18 @@ export default {
search(text, page, limit = 20) {
return eapiRequest('/api/cloudsearch/pc', {
s: text,
type: 1000, // 1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单, 1002: 用户, 1004: MV, 1006: 歌词, 1009: 电台, 1014: 视频
type: 1000,
limit,
total: page == 1,
offset: limit * (page - 1)
offset: limit * (page - 1),
}).promise.then(({ body }) => {
if (body.code != this.successCode) throw new Error('filed')
// console.log(body)
return {
list: this.filterList(body.result.playlists),
limit,
total: body.result.playlistCount,
source: 'wy'
source: 'wy',
}
})
}
}
// getList
// getTags
// getListDetail
},
}

View File

@@ -21,13 +21,13 @@ export default {
})
return this.requestObj.promise.then(({ statusCode, body }) => {
if (statusCode != 200 || body.code != 200) return Promise.reject(new Error('请求失败'))
return body.result.songs
return body.result
})
},
handleResult(rawData) {
return rawData.map((info) => `${info.name} - ${formatSingerName(info.artists, 'name')}`)
},
async search(str) {
return this.tipSearchBySong(str).then((result) => this.handleResult(result))
return this.tipSearchBySong(str)
}
}

View File

@@ -11,7 +11,6 @@ interface CustomAPI {
onMusicCtrl: (callback: (event: Event, args: any) => void) => () => void
music: {
request: (api: string, args: any) => Promise<any>
requestSdk: <T extends keyof MainApi>(
method: T,
args: {
@@ -68,6 +67,7 @@ interface CustomAPI {
// 插件管理API
plugins: {
selectAndAddPlugin: (type: 'lx' | 'cr') => Promise<any>
downloadAndAddPlugin: (url: string, type: 'lx' | 'cr') => Promise<any>
uninstallPlugin(pluginId: string): ApiResult | PromiseLike<ApiResult>
addPlugin: (pluginCode: string, pluginName: string) => Promise<any>
getPluginById: (id: string) => Promise<any>
@@ -79,7 +79,7 @@ interface CustomAPI {
start: () => undefined
stop: () => undefined
}
// 目录设置API
directorySettings: {
getDirectories: () => Promise<{
@@ -96,10 +96,7 @@ interface CustomAPI {
path?: string
message?: string
}>
saveDirectories: (directories: {
cacheDir: string
downloadDir: string
}) => Promise<{
saveDirectories: (directories: { cacheDir: string; downloadDir: string }) => Promise<{
success: boolean
message: string
}>
@@ -119,15 +116,14 @@ interface CustomAPI {
size: number
formatted: string
}>
}
// 用户配置API
getUserConfig: () => Promise<any>
pluginNotice: {
onPluginNotice: (listener: (...args: any[]) => void) => ()=>void
}
onPluginNotice: (listener: (...args: any[]) => void) => () => void
}
}
declare global {

View File

@@ -8,6 +8,11 @@ const api = {
console.log('preload: 发送 window-minimize 事件')
ipcRenderer.send('window-minimize')
},
// 阻止系统息屏
powerSaveBlocker: {
start: () => ipcRenderer.invoke('power-save-blocker:start'),
stop: () => ipcRenderer.invoke('power-save-blocker:stop')
},
maximize: () => {
console.log('preload: 发送 window-maximize 事件')
ipcRenderer.send('window-maximize')
@@ -29,7 +34,6 @@ const api = {
},
// 音乐相关方法
music: {
request: (api: string, args: any) => ipcRenderer.invoke('service-music-request', api, args),
requestSdk: (api: string, args: any) =>
ipcRenderer.invoke('service-music-sdk-request', api, args)
},
@@ -37,6 +41,8 @@ const api = {
plugins: {
selectAndAddPlugin: (type: 'lx' | 'cr') =>
ipcRenderer.invoke('service-plugin-selectAndAddPlugin', type),
downloadAndAddPlugin: (url: string, type: 'lx' | 'cr') =>
ipcRenderer.invoke('service-plugin-downloadAndAddPlugin', url, type),
addPlugin: (pluginCode: string, pluginName: string) =>
ipcRenderer.invoke('service-plugin-addPlugin', pluginCode, pluginName),
getPluginById: (id: string) => ipcRenderer.invoke('service-plugin-getPluginById', id),

View File

@@ -6,5 +6,73 @@
// biome-ignore lint: disable
export {}
declare global {
const DialogPlugin: (typeof import('tdesign-vue-next'))['DialogPlugin']
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const isShallow: typeof import('vue')['isShallow']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useDialog: typeof import('naive-ui')['useDialog']
const useId: typeof import('vue')['useId']
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
const useMessage: typeof import('naive-ui')['useMessage']
const useModel: typeof import('vue')['useModel']
const useNotification: typeof import('naive-ui')['useNotification']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

View File

@@ -10,35 +10,55 @@ declare module 'vue' {
export interface GlobalComponents {
AIFloatBallSettings: typeof import('./src/components/Settings/AIFloatBallSettings.vue')['default']
AudioVisualizer: typeof import('./src/components/Play/AudioVisualizer.vue')['default']
ContextMenu: typeof import('./src/components/ContextMenu/ContextMenu.vue')['default']
Demo: typeof import('./src/components/ContextMenu/demo.vue')['default']
DirectorySettings: typeof import('./src/components/Settings/DirectorySettings.vue')['default']
FloatBall: typeof import('./src/components/AI/FloatBall.vue')['default']
FullPlay: typeof import('./src/components/Play/FullPlay.vue')['default']
GlobalAudio: typeof import('./src/components/Play/GlobalAudio.vue')['default']
HomeLayout: typeof import('./src/components/layout/HomeLayout.vue')['default']
MusicCache: typeof import('./src/components/Settings/MusicCache.vue')['default']
NBackTop: typeof import('naive-ui')['NBackTop']
NBadge: typeof import('naive-ui')['NBadge']
NCard: typeof import('naive-ui')['NCard']
NIcon: typeof import('naive-ui')['NIcon']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NText: typeof import('naive-ui')['NText']
PlaylistActions: typeof import('./src/components/Play/PlaylistActions.vue')['default']
PlaylistDrawer: typeof import('./src/components/Play/PlaylistDrawer.vue')['default']
PlaylistSettings: typeof import('./src/components/Settings/PlaylistSettings.vue')['default']
PlayMusic: typeof import('./src/components/Play/PlayMusic.vue')['default']
PluginNoticeDialog: typeof import('./src/components/PluginNoticeDialog.vue')['default']
Plugins: typeof import('./src/components/Settings/plugins.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SearchSuggest: typeof import('./src/components/search/searchSuggest.vue')['default']
ShaderBackground: typeof import('./src/components/Play/ShaderBackground.vue')['default']
SongVirtualList: typeof import('./src/components/Music/SongVirtualList.vue')['default']
SvgIcon: typeof import('./src/components/SvgIcon.vue')['default']
TAlert: typeof import('tdesign-vue-next')['Alert']
TAside: typeof import('tdesign-vue-next')['Aside']
TButton: typeof import('tdesign-vue-next')['Button']
TCard: typeof import('tdesign-vue-next')['Card']
TCheckbox: typeof import('tdesign-vue-next')['Checkbox']
TContent: typeof import('tdesign-vue-next')['Content']
TDialog: typeof import('tdesign-vue-next')['Dialog']
TDivider: typeof import('tdesign-vue-next')['Divider']
TDropdown: typeof import('tdesign-vue-next')['Dropdown']
TForm: typeof import('tdesign-vue-next')['Form']
TFormItem: typeof import('tdesign-vue-next')['FormItem']
ThemeSelector: typeof import('./src/components/ThemeSelector.vue')['default']
TIcon: typeof import('tdesign-vue-next')['Icon']
TImage: typeof import('tdesign-vue-next')['Image']
TInput: typeof import('tdesign-vue-next')['Input']
TitleBarControls: typeof import('./src/components/TitleBarControls.vue')['default']
TLayout: typeof import('tdesign-vue-next')['Layout']
TLoading: typeof import('tdesign-vue-next')['Loading']
TRadioButton: typeof import('tdesign-vue-next')['RadioButton']
TRadioGroup: typeof import('tdesign-vue-next')['RadioGroup']
TSlider: typeof import('tdesign-vue-next')['Slider']
TSwitch: typeof import('tdesign-vue-next')['Switch']
TTag: typeof import('tdesign-vue-next')['Tag']
TTextarea: typeof import('tdesign-vue-next')['Textarea']
TTooltip: typeof import('tdesign-vue-next')['Tooltip']
UpdateExample: typeof import('./src/components/UpdateExample.vue')['default']

View File

@@ -10,9 +10,10 @@
-->
<script setup lang="ts">
import { onMounted } from 'vue'
import { onMounted, ref } from 'vue'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { useAutoUpdate } from './composables/useAutoUpdate'
import { NConfigProvider, darkTheme, NGlobalStyle } from 'naive-ui'
const userInfo = LocalUserDetailStore()
const { checkForUpdates } = useAutoUpdate()
@@ -25,7 +26,10 @@ import './assets/theme/cyan.css'
onMounted(() => {
userInfo.init()
setupSystemThemeListener()
loadSavedTheme()
syncNaiveTheme()
window.addEventListener('theme-changed', () => syncNaiveTheme())
// 应用启动后延迟3秒检查更新避免影响启动速度
setTimeout(() => {
@@ -42,44 +46,121 @@ const themes = [
{ name: 'orange', label: '橙色', color: '#fb9458' }
]
const loadSavedTheme = () => {
const savedTheme = localStorage.getItem('selected-theme')
if (savedTheme && themes.some((t) => t.name === savedTheme)) {
applyTheme(savedTheme)
const naiveTheme = ref<any>(null)
const themeOverrides = ref<any>({})
function syncNaiveTheme() {
const docEl = document.documentElement
const savedDarkMode = localStorage.getItem('dark-mode')
const isDark = savedDarkMode === 'true'
naiveTheme.value = isDark ? darkTheme : null
const computed = getComputedStyle(docEl)
const primary = (computed.getPropertyValue('--td-brand-color') || '').trim()
const savedThemeName = localStorage.getItem('selected-theme') || 'default'
const fallback = themes.find((t) => t.name === savedThemeName)?.color || '#2ba55b'
const mainColor = primary || fallback
themeOverrides.value = {
common: {
primaryColor: mainColor,
primaryColorHover: mainColor,
primaryColorPressed: mainColor
}
}
}
const applyTheme = (themeName) => {
const loadSavedTheme = () => {
const savedTheme = localStorage.getItem('selected-theme')
const savedDarkMode = localStorage.getItem('dark-mode')
let themeName = 'default'
let isDarkMode = false
if (savedTheme && themes.some((t) => t.name === savedTheme)) {
themeName = savedTheme
}
if (savedDarkMode !== null) {
isDarkMode = savedDarkMode === 'true'
} else {
// 如果没有保存的设置,检测系统偏好
isDarkMode = detectSystemTheme()
}
applyTheme(themeName, isDarkMode)
}
const applyTheme = (themeName, darkMode = false) => {
const documentElement = document.documentElement
// 移除之前的主题
// 移除之前的主题属性
documentElement.removeAttribute('theme-mode')
documentElement.removeAttribute('data-theme')
// 应用主题(如果不是默认主题)
// 应用主题色彩
if (themeName !== 'default') {
documentElement.setAttribute('theme-mode', themeName)
}
// 应用明暗模式
if (darkMode) {
documentElement.setAttribute('data-theme', 'dark')
} else {
documentElement.setAttribute('data-theme', 'light')
}
// 保存到本地存储
localStorage.setItem('selected-theme', themeName)
localStorage.setItem('dark-mode', darkMode.toString())
// 同步 Naive UI 主题
syncNaiveTheme()
}
// 检测系统主题偏好
const detectSystemTheme = () => {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return true
}
return false
}
// 监听系统主题变化
const setupSystemThemeListener = () => {
if (window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', (e) => {
const savedDarkMode = localStorage.getItem('dark-mode')
// 如果用户没有手动设置暗色模式,则跟随系统主题
if (savedDarkMode === null) {
const savedTheme = localStorage.getItem('selected-theme') || 'default'
applyTheme(savedTheme, e.matches)
}
})
}
}
</script>
<template>
<div class="page">
<router-view v-slot="{ Component }">
<Transition
:enter-active-class="`animate__animated animate__fadeIn pagesApp`"
:leave-active-class="`animate__animated animate__fadeOut pagesApp`"
>
<component :is="Component" />
</Transition>
</router-view>
<GlobalAudio />
<FloatBall />
<PluginNoticeDialog />
<UpdateProgress />
</div>
<NConfigProvider :theme="naiveTheme" :theme-overrides="themeOverrides">
<NGlobalStyle />
<div class="page">
<router-view v-slot="{ Component }">
<Transition
:enter-active-class="`animate__animated animate__fadeIn pagesApp`"
:leave-active-class="`animate__animated animate__fadeOut pagesApp`"
>
<component :is="Component" />
</Transition>
</router-view>
<GlobalAudio />
<FloatBall />
<PluginNoticeDialog />
<UpdateProgress />
</div>
</NConfigProvider>
</template>
<style>
.pagesApp {

View File

@@ -60,23 +60,23 @@ body {
}
&::-webkit-scrollbar-track {
background: #f1f5f9;
background: var(--td-scroll-track-color);
border-radius: 0.1875rem;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
background: var(--td-scrollbar-color);
border-radius: 0.1875rem;
transition: background-color 0.2s ease;
&:hover {
background: #94a3b8;
background: var(--td-scrollbar-hover-color);
}
}
/* Firefox 滚动条样式 */
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
scrollbar-color: var(--td-scrollbar-color) var(--td-scroll-track-color);
}
.t-dialog__mask{
.t-dialog__mask {
backdrop-filter: blur(5px);
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M18 13h-5v5c0 .55-.45 1-1 1s-1-.45-1-1v-5H6c-.55 0-1-.45-1-1s.45-1 1-1h5V6c0-.55.45-1 1-1s1 .45 1 1v5h5c.55 0 1 .45 1 1s-.45 1-1 1"/></svg>

After

Width:  |  Height:  |  Size: 251 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M14 16h2v-2h2v-2h-2v-2h-2v2h-2v2h2zM4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h6l2 2h8q.825 0 1.413.588T22 8v10q0 .825-.587 1.413T20 20z"/></svg>

After

Width:  |  Height:  |  Size: 266 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M12 16.5q1.875 0 3.188-1.312T16.5 12t-1.312-3.187T12 7.5T8.813 8.813T7.5 12t1.313 3.188T12 16.5m0-3.5q-.425 0-.712-.288T11 12t.288-.712T12 11t.713.288T13 12t-.288.713T12 13m0 9q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"/></svg>

After

Width:  |  Height:  |  Size: 459 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M11 14c1 0 2.05.16 3.2.44c-.81.87-1.2 1.89-1.2 3.06c0 .89.25 1.73.78 2.5H3v-2c0-1.19.91-2.15 2.74-2.88C7.57 14.38 9.33 14 11 14m0-2c-1.08 0-2-.39-2.82-1.17C7.38 10.05 7 9.11 7 8c0-1.08.38-2 1.18-2.82C9 4.38 9.92 4 11 4c1.11 0 2.05.38 2.83 1.18C14.61 6 15 6.92 15 8c0 1.11-.39 2.05-1.17 2.83c-.78.78-1.72 1.17-2.83 1.17m7.5-2H22v2h-2v5.5a2.5 2.5 0 0 1-2.5 2.5a2.5 2.5 0 0 1-2.5-2.5a2.5 2.5 0 0 1 2.5-2.5c.36 0 .69.07 1 .21z"/></svg>

After

Width:  |  Height:  |  Size: 543 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M7.5 5.6L10 7L8.6 4.5L10 2L7.5 3.4L5 2l1.4 2.5L5 7zm12 9.8L17 14l1.4 2.5L17 19l2.5-1.4L22 19l-1.4-2.5L22 14zM22 2l-2.5 1.4L17 2l1.4 2.5L17 7l2.5-1.4L22 7l-1.4-2.5zm-7.63 5.29a.996.996 0 0 0-1.41 0L1.29 18.96a.996.996 0 0 0 0 1.41l2.34 2.34c.39.39 1.02.39 1.41 0L16.7 11.05a.996.996 0 0 0 0-1.41zm-1.03 5.49l-2.12-2.12l2.44-2.44l2.12 2.12z"/></svg>

After

Width:  |  Height:  |  Size: 459 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M10.85 12.65h2.3L12 9zM20 8.69V6c0-1.1-.9-2-2-2h-2.69l-1.9-1.9c-.78-.78-2.05-.78-2.83 0L8.69 4H6c-1.1 0-2 .9-2 2v2.69l-1.9 1.9c-.78.78-.78 2.05 0 2.83l1.9 1.9V18c0 1.1.9 2 2 2h2.69l1.9 1.9c.78.78 2.05.78 2.83 0l1.9-1.9H18c1.1 0 2-.9 2-2v-2.69l1.9-1.9c.78-.78.78-2.05 0-2.83zm-5.91 6.71L13.6 14h-3.2l-.49 1.4c-.13.36-.46.6-.84.6a.888.888 0 0 1-.84-1.19l2.44-6.86c.2-.57.73-.95 1.33-.95c.6 0 1.13.38 1.34.94l2.44 6.86a.888.888 0 0 1-.84 1.19a.874.874 0 0 1-.85-.59"/></svg>

After

Width:  |  Height:  |  Size: 583 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M11 8c0-.55-.45-1-1-1H3c-.55 0-1 .45-1 1s.45 1 1 1h7c.55 0 1-.45 1-1m0 8c0-.55-.45-1-1-1H3c-.55 0-1 .45-1 1s.45 1 1 1h7c.55 0 1-.45 1-1m6.05-5.71a.996.996 0 0 1-1.41 0l-2.12-2.12a.996.996 0 1 1 1.41-1.41l1.41 1.41l3.54-3.54a.996.996 0 1 1 1.41 1.41zm0 8a.996.996 0 0 1-1.41 0l-2.12-2.12a.996.996 0 1 1 1.41-1.41l1.41 1.41l3.54-3.54a.996.996 0 1 1 1.41 1.41z"/></svg>

After

Width:  |  Height:  |  Size: 478 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M17.75 3A3.25 3.25 0 0 1 21 6.25v11.5A3.25 3.25 0 0 1 17.75 21H6.25A3.25 3.25 0 0 1 3 17.75V6.25A3.25 3.25 0 0 1 6.25 3zm1.75 5.5h-15v9.25c0 .966.784 1.75 1.75 1.75h11.5a1.75 1.75 0 0 0 1.75-1.75zm-1.75-4H6.25A1.75 1.75 0 0 0 4.5 6.25V7h15v-.75a1.75 1.75 0 0 0-1.75-1.75"/></svg>

After

Width:  |  Height:  |  Size: 393 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="m6 18l-2.3 2.3q-.475.475-1.088.213T2 19.575V4q0-.825.588-1.412T4 2h16q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18zm1-4h6q.425 0 .713-.288T14 13t-.288-.712T13 12H7q-.425 0-.712.288T6 13t.288.713T7 14m0-3h10q.425 0 .713-.288T18 10t-.288-.712T17 9H7q-.425 0-.712.288T6 10t.288.713T7 11m0-3h10q.425 0 .713-.288T18 7t-.288-.712T17 6H7q-.425 0-.712.288T6 7t.288.713T7 8"/></svg>

After

Width:  |  Height:  |  Size: 489 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M19.35 10.04A7.49 7.49 0 0 0 12 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 0 0 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5c0-2.64-2.05-4.78-4.65-4.96"/></svg>

After

Width:  |  Height:  |  Size: 269 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M14.2 13.5v1.24c-.7.6-1.2 1.5-1.2 2.46V20H6.5c-1.5 0-2.81-.5-3.89-1.57C1.54 17.38 1 16.09 1 14.58c0-1.3.39-2.46 1.17-3.48S4 9.43 5.25 9.15c.42-1.53 1.25-2.77 2.5-3.72S10.42 4 12 4c1.95 0 3.6.68 4.96 2.04a6.74 6.74 0 0 1 1.78 2.99c-2.49.13-4.54 2.12-4.54 4.47m7.6 2.5h-4.3v-2.5c0-.8.7-1.3 1.5-1.3s1.5.5 1.5 1.3v.5h1.3v-.5c0-1.4-1.4-2.5-2.8-2.5s-2.8 1.1-2.8 2.5V16c-.6 0-1.2.6-1.2 1.2v3.5c0 .7.6 1.3 1.2 1.3h5.5c.7 0 1.3-.6 1.3-1.2v-3.5c0-.7-.6-1.3-1.2-1.3"/></svg>

After

Width:  |  Height:  |  Size: 575 B

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