Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce743e1b65 | ||
|
|
32c9fdbfeb | ||
|
|
9df236b2e0 | ||
|
|
0988c71282 | ||
|
|
60881f7f48 | ||
|
|
775f87aa86 | ||
|
|
b1c471f15c | ||
|
|
f7ecfa1fa9 | ||
|
|
d44be6022a | ||
|
|
0c512bccff | ||
|
|
b07cc2359a | ||
|
|
46756a8b09 | ||
|
|
deb73fa789 | ||
|
|
910ab1ff10 | ||
|
|
0cfc31de31 | ||
|
|
2c0c8be2bf | ||
|
|
489e920b69 | ||
|
|
fdd548972c | ||
|
|
f81b46b1b4 | ||
|
|
e1e2d88c67 | ||
|
|
3c0be7a20f | ||
|
|
79e05c884d | ||
|
|
970baf081b | ||
|
|
9341c57278 | ||
|
|
15a76a5313 | ||
|
|
6ee7e6dc99 | ||
|
|
630ec056db | ||
|
|
d739e60930 | ||
|
|
86ea8b6797 | ||
|
|
3d87fa145f | ||
|
|
6fa4e21d40 | ||
|
|
4c11c19139 | ||
|
|
f82d4271a7 | ||
|
|
42dcf52d59 | ||
|
|
d9d2bdab93 | ||
|
|
6060aa2ef4 | ||
|
|
0b2e8eef64 | ||
|
|
57de7b49e8 | ||
|
|
42e17e83e7 | ||
|
|
380c273329 | ||
|
|
6f56f5e240 | ||
|
|
7af7779e5c | ||
|
|
669a348218 | ||
|
|
f02264c80c | ||
|
|
d0d5f918bd | ||
|
|
761d265d18 | ||
|
|
204df64535 | ||
|
|
cc814eddbd | ||
|
|
51df14a9e9 | ||
|
|
2473b36928 | ||
|
|
dbba7a3d26 | ||
|
|
a817865bd8 | ||
|
|
c4a4d26bd8 | ||
|
|
dfa36d872e | ||
|
|
995859e661 | ||
|
|
34fb0f7c2f | ||
|
|
191ba1e199 | ||
|
|
324e81c0dc |
66
.github/workflows/deploydocs.yml
vendored
@@ -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
|
||||
146
.github/workflows/main.yml
vendored
@@ -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描述
|
||||
|
||||
275
.vitepress/cache/deps/@theme_index.js
vendored
@@ -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
|
||||
7
.vitepress/cache/deps/@theme_index.js.map
vendored
40
.vitepress/cache/deps/_metadata.json
vendored
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
9719
.vitepress/cache/deps/chunk-B6YPYVPP.js
vendored
7
.vitepress/cache/deps/chunk-B6YPYVPP.js.map
vendored
12683
.vitepress/cache/deps/chunk-I4O5PVBA.js
vendored
7
.vitepress/cache/deps/chunk-I4O5PVBA.js.map
vendored
3
.vitepress/cache/deps/package.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
4505
.vitepress/cache/deps/vitepress___@vue_devtools-api.js
vendored
583
.vitepress/cache/deps/vitepress___@vueuse_core.js
vendored
@@ -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
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
343
.vitepress/cache/deps/vue.js
vendored
@@ -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
|
||||
7
.vitepress/cache/deps/vue.js.map
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
215
.workflow/main copy.yml
Normal 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
|
||||
143
README.md
@@ -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%;" />
|
||||
|
||||

|
||||
|
||||
## Star History
|
||||
|
||||
[](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,12 +290,16 @@ CeruMuisc/
|
||||
欢迎开发者贡献代码与反馈建议,贡献内容需符合以下要求:
|
||||
|
||||
1. 仅涉及播放器框架功能优化、bug 修复、文档完善,不包含任何音乐数据源相关代码。
|
||||
2. 遵循 [Git 提交规范](https://www.doubao.com/thread/docs/design.md#git提交规范) 并确保代码符合项目风格指南。
|
||||
2. 遵循 [Git 提交规范](#) 并确保代码符合项目风格指南。
|
||||
3. 贡献的代码需无第三方版权纠纷,且不违反开源许可协议。
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有技术问题或合作意向(仅限技术交流),请通过 Gitee 私信联系项目维护者。
|
||||
如有技术问题或合作意向
|
||||
可通过如下方式联系
|
||||
- QQ: 2115295703
|
||||
- 微信:13600973542
|
||||
- 邮箱:sqj@shiqianjiang.cn
|
||||
|
||||
## 项目开发者
|
||||
|
||||
|
||||
BIN
assets/image-20251003173109619.png
Normal file
|
After Width: | Height: | Size: 958 KiB |
BIN
assets/image-20251003173141699.png
Normal file
|
After Width: | Height: | Size: 1020 KiB |
BIN
assets/image-20251003173654569.png
Normal file
|
After Width: | Height: | Size: 870 KiB |
@@ -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,12 +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/updateLog' },
|
||||
{ text: '更新计划', link: '/guide/update' }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -42,6 +53,10 @@ export default defineConfig({
|
||||
{ text: '插件类使用', link: '/guide/CeruMusicPluginHost' },
|
||||
{ text: '澜音插件开发文档(重点)', link: '/guide/CeruMusicPluginDev' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '鸣谢名单',
|
||||
link: '/guide/sponsorship'
|
||||
}
|
||||
],
|
||||
|
||||
@@ -63,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
|
||||
|
||||
@@ -171,12 +171,12 @@ html.dark #app {
|
||||
// --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) ". ";
|
||||
|
||||
/* 主题颜色 */
|
||||
|
||||
|
||||
BIN
docs/assets/image-20251003173109619.png
Normal file
|
After Width: | Height: | Size: 958 KiB |
BIN
docs/assets/image-20251003173141699.png
Normal file
|
After Width: | Height: | Size: 1020 KiB |
BIN
docs/assets/image-20251003173654569.png
Normal file
|
After Width: | Height: | Size: 870 KiB |
@@ -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']
|
||||
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,13 +651,13 @@ 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'
|
||||
}
|
||||
})
|
||||
```
|
||||
@@ -659,6 +665,7 @@ cerumusic.NoticeCenter('update',{
|
||||
### Q: 如何调试插件?
|
||||
|
||||
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)
|
||||
|
||||
@@ -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**: 初始版本,支持基础的音乐数据获取功能
|
||||
BIN
docs/guide/assets/image-20250921130607735.png
Normal file
|
After Width: | Height: | Size: 606 KiB |
BIN
docs/guide/assets/image-20250926225425123.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
@@ -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)
|
||||
- 
|
||||
- **状态管理**: 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')
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 欢迎页面设计
|
||||
|
||||

|
||||
|
||||
```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. **音乐推荐**: 基于听歌历史的智能推荐
|
||||
|
||||
---
|
||||
|
||||
_本设计文档将随着项目开发进度持续更新和完善。_
|
||||
20
docs/guide/sponsorship.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# 赞助名单
|
||||
|
||||
## 鸣谢
|
||||
|
||||
| 昵称 | 赞助金额 |
|
||||
| :-------------------------: | :------: |
|
||||
| **群友**:可惜情绪落泪零碎 | 6.66 |
|
||||
| **群友**:🍀 | 5 |
|
||||
| **群友**:涟漪 | 50 |
|
||||
| **作者朋友** | 188 |
|
||||
| **群友**:我叫阿狸 | 3 |
|
||||
| RiseSun | 9.9 |
|
||||
| **b站小友**:光牙阿普斯木兰 | 5 |
|
||||
| 青禾 | 8.8 |
|
||||
| li peng | 200 |
|
||||
| **群友**:XIZ | 3 |
|
||||
| YL | 10 |
|
||||
| **群友**:way1437 | 50 |
|
||||
|
||||
据不完全统计 如有疏漏可联系sqj@shiqianjiang.cn
|
||||
16
docs/guide/update.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 我的-更新计划-欢迎issue
|
||||
|
||||
- [ ] 搜索框可以弄对应的音乐源的排行榜 搜索联想
|
||||
- [ ] 导航上面这几个按钮可以稍微优化一下
|
||||
- [x] 支持在线导入插件
|
||||
- [ ] 大熊好暖: 09-21 12:52:25 在搜索歌曲出来后 大熊好暖: 09-21 12:52:34 一般都是双击播放 大熊好暖: 09-21 12:54:16 这个是双击加入播放列表 可以优化 或者可以把这个双击做一个选项,让人选择
|
||||
- [ ] 播放页 样式模板选择 歌词背景律动很有沉浸感,但是黑胶唱片的专辑样式不是很喜欢,希望增加一个选项如图二一样只有圆形专辑封面
|
||||
- [x] 点击搜索框的 源图标实现快速切换
|
||||
- [ ] ai功能完善
|
||||
- [ ] 支持歌词隐藏
|
||||
- [x] 兼容多平台歌单导入
|
||||
- [x] 软件能不能记住上次打开的窗口大小,每次都要手动拉
|
||||
- [x] 歌单右键菜单
|
||||
- [x] 播放列表滚动条适配
|
||||
- [x] 暗色主题
|
||||
- [x] 歌单页支持修改封面
|
||||
@@ -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. **设置功能页**
|
||||
- 缓存路径支持自定义
|
||||
- 下载路径支持自定义
|
||||
|
||||
@@ -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正确的歌曲封面
|
||||
@@ -46,7 +46,10 @@ features:
|
||||
|
||||
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%;" />
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 2rem">
|
||||
<img src="./assets/image-20251003173109619.png" alt="image-20251003173109619"/>
|
||||
<img src= "./assets/image-20251003173654569.png">
|
||||
</div>
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -120,7 +123,7 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工
|
||||
|
||||
## 文档与资源
|
||||
|
||||
- [产品设计文档](./guide/design):涵盖项目架构、核心功能设计和开发规范(不含任何音乐数据源信息)。
|
||||
- [产品设计文档](#):涵盖项目架构、核心功能设计和开发规范(不含任何音乐数据源信息)。
|
||||
- [插件开发文档](./guide/CeruMusicPluginDev):仅提供插件开发技术规范,**明确要求插件开发者需遵守数据来源平台的用户协议与版权法**,禁止开发、传播获取非公开数据的插件。
|
||||
|
||||
## 开源许可
|
||||
@@ -132,7 +135,7 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工
|
||||
欢迎开发者贡献代码与反馈建议,贡献内容需符合以下要求:
|
||||
|
||||
1. 仅涉及播放器框架功能优化、bug 修复、文档完善,不包含任何音乐数据源相关代码。
|
||||
2. 遵循 [Git 提交规范](./guide/design#git提交规范) 并确保代码符合项目风格指南。
|
||||
2. 遵循 [Git 提交规范](#) 并确保代码符合项目风格指南。
|
||||
3. 贡献的代码需无第三方版权纠纷,且不违反开源许可协议。
|
||||
|
||||
## 联系方式
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
// 这个文件可以用来测试 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']
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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,6 +208,7 @@ window.api.on('plugin-notice', (_, notice) => {
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0 (2025-09-20)
|
||||
|
||||
- ✨ 初始版本发布
|
||||
- ✨ 支持 5 种通知类型
|
||||
- ✨ 完整的 TypeScript 类型定义
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ceru-music",
|
||||
"version": "1.3.2",
|
||||
"version": "1.4.6",
|
||||
"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",
|
||||
|
||||
9582
qodana.sarif.json
@@ -1,3 +1,3 @@
|
||||
version: "1.0"
|
||||
version: '1.0'
|
||||
profile:
|
||||
name: qodana.starter
|
||||
|
||||
@@ -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 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 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)
|
||||
|
||||
92
src/common/utils/quality.ts
Normal 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))
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
}
|
||||
80
src/main/events/plugins.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import ManageSongList, { SongListError } from '../services/songList/ManageSongList'
|
||||
import type { SongList, Songs } from '@common/types/songList'
|
||||
import { configManager } from '../services/ConfigManager'
|
||||
|
||||
// 创建新歌单
|
||||
ipcMain.handle(
|
||||
@@ -21,6 +22,31 @@ ipcMain.handle(
|
||||
}
|
||||
)
|
||||
|
||||
// 喜欢歌单ID持久化
|
||||
ipcMain.handle('songlist:get-favorites-id', async () => {
|
||||
try {
|
||||
const id = configManager.get<string>('favoritesHashId', '')
|
||||
return { success: true, data: id || null }
|
||||
} catch (error) {
|
||||
console.error('获取喜欢歌单ID失败:', error)
|
||||
return { success: false, error: '获取喜欢歌单ID失败' }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('songlist:set-favorites-id', async (_, id: string) => {
|
||||
try {
|
||||
if (!id || typeof id !== 'string' || !id.trim()) {
|
||||
return { success: false, error: '无效的歌单ID' }
|
||||
}
|
||||
configManager.set('favoritesHashId', id.trim())
|
||||
const ok = configManager.saveConfig()
|
||||
return { success: ok, message: ok ? '已保存喜欢歌单ID' : '保存失败' }
|
||||
} catch (error) {
|
||||
console.error('设置喜欢歌单ID失败:', error)
|
||||
return { success: false, error: '设置喜欢歌单ID失败' }
|
||||
}
|
||||
})
|
||||
|
||||
// 获取所有歌单
|
||||
ipcMain.handle('songlist:get-all', async () => {
|
||||
try {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { app, shell, BrowserWindow, ipcMain, Tray, Menu } from 'electron'
|
||||
import { app, shell, BrowserWindow, ipcMain, screen, Rectangle, Display } 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()
|
||||
|
||||
@@ -25,84 +29,49 @@ 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)
|
||||
/**
|
||||
* 根据窗口当前所在的显示器,动态更新窗口的最大尺寸限制。
|
||||
* 这样可以确保窗口在任何显示器上都能正常最大化或全屏。
|
||||
* @param {BrowserWindow} win - 要更新的窗口实例
|
||||
*/
|
||||
function updateWindowMaxLimits(win: BrowserWindow | null): void {
|
||||
if (!win) return
|
||||
|
||||
// 创建托盘菜单
|
||||
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()
|
||||
}
|
||||
}
|
||||
])
|
||||
// 1. 获取窗口的当前边界 (bounds)
|
||||
const currentBounds: Rectangle = win.getBounds()
|
||||
|
||||
tray.setContextMenu(contextMenu)
|
||||
tray.setToolTip('Ceru Music')
|
||||
// 2. 查找包含该边界的显示器
|
||||
const currentDisplay: Display = screen.getDisplayMatching(currentBounds)
|
||||
|
||||
// 双击托盘图标显示窗口
|
||||
tray.on('click', () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide()
|
||||
} else {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
}
|
||||
})
|
||||
// 3. 获取该显示器的完整尺寸 (full screen size)
|
||||
const { width: currentScreenWidth, height: currentScreenHeight } = currentDisplay.size
|
||||
|
||||
// 4. 应用新的最大尺寸限制
|
||||
// 移除 maxWidth/maxHeight 上的硬限制,使其能够最大化到当前屏幕的尺寸。
|
||||
// 注意:设置为 0, 0 意味着没有最小限制,我们只关注最大限制。
|
||||
win.setMaximumSize(currentScreenWidth, currentScreenHeight)
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
// return
|
||||
// Create the browser window.
|
||||
mainWindow = new BrowserWindow({
|
||||
// 获取保存的窗口位置和大小
|
||||
const savedBounds = configManager.getWindowBounds()
|
||||
|
||||
// 默认窗口配置
|
||||
const defaultOptions = {
|
||||
width: 1100,
|
||||
height: 750,
|
||||
minWidth: 1100,
|
||||
minHeight: 670,
|
||||
// ⚠️ 关键修改 1: 移除 maxWidth 和 maxHeight 的硬编码限制
|
||||
// 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 +81,71 @@ 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)
|
||||
|
||||
// ⚠️ 关键修改 2: 监听 'moved' 事件,动态更新最大尺寸
|
||||
mainWindow.on('moved', () => {
|
||||
// 当窗口移动时,确保最大尺寸限制随屏幕变化
|
||||
updateWindowMaxLimits(mainWindow)
|
||||
|
||||
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
|
||||
const bounds = mainWindow.getBounds()
|
||||
configManager.saveWindowBounds(bounds)
|
||||
}
|
||||
})
|
||||
|
||||
// ⚠️ 关键修改 3: 窗口创建后立即应用一次最大尺寸限制
|
||||
updateWindowMaxLimits(mainWindow)
|
||||
|
||||
mainWindow.on('resized', () => {
|
||||
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
|
||||
const bounds = mainWindow.getBounds()
|
||||
|
||||
// 获取当前屏幕尺寸 (已在文件顶部导入 screen,无需 require)
|
||||
const currentDisplay = screen.getDisplayMatching(bounds)
|
||||
// 使用 workAreaSize 避免窗口超出任务栏/Dock
|
||||
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 +155,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 +171,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 +178,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 +208,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)
|
||||
}
|
||||
|
||||
162
src/main/services/ConfigManager.ts
Normal 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()
|
||||
@@ -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)
|
||||
// })
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 动态获取缓存目录
|
||||
|
||||
@@ -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'] } }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
@@ -85,100 +89,22 @@ function main(source: string) {
|
||||
},
|
||||
|
||||
async getPlaylistDetail({ id, page }: GetSongListDetailsArg) {
|
||||
// 酷狗音乐特殊处理:直接调用getUserListDetail
|
||||
if (source === 'kg' && /https?:\/\//.test(id)) {
|
||||
return (await Api.songList.getUserListDetail(id, page)) as PlaylistDetailResult
|
||||
}
|
||||
return (await Api.songList.getListDetail(id, page)) as PlaylistDetailResult
|
||||
},
|
||||
|
||||
async downloadSingleSong({ pluginId, songInfo, quality }: DownloadSingleSongArgs) {
|
||||
async downloadSingleSong({
|
||||
pluginId,
|
||||
songInfo,
|
||||
quality,
|
||||
tagWriteOptions
|
||||
}: DownloadSingleSongArgs) {
|
||||
const url = await this.getMusicUrl({ pluginId, songInfo, quality })
|
||||
if (typeof url === 'object') throw new Error('无法获取歌曲链接')
|
||||
|
||||
// 获取自定义下载目录
|
||||
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 }) {
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
331
src/main/utils/downloadSongs.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import pic from './pic'
|
||||
import lyric from './lyric'
|
||||
import hotSearch from './hotSearch'
|
||||
import comment from './comment'
|
||||
// import tipSearch from './tipSearch'
|
||||
import tipSearch from './tipSearch'
|
||||
|
||||
const kg = {
|
||||
// tipSearch,
|
||||
tipSearch,
|
||||
leaderboard,
|
||||
songList,
|
||||
musicSearch,
|
||||
@@ -24,5 +24,4 @@ const kg = {
|
||||
return `https://www.kugou.com/song/#hash=${songInfo.hash}&album_id=${songInfo.albumId}`
|
||||
}
|
||||
}
|
||||
|
||||
export default kg
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
190
src/main/utils/musicSdk/kg/quality_detail.js
Normal 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: {},
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,15 +808,15 @@ 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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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,15 +527,15 @@ 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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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,10 +342,10 @@ export default {
|
||||
list,
|
||||
limit,
|
||||
total: parseInt(body.songListResultData.totalCount),
|
||||
source: 'mg'
|
||||
source: 'mg',
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// getList
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
src/main/utils/musicSdk/tx/__pycache__/des.cpython-313.pyc
Normal 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,
|
||||
@@ -21,5 +21,4 @@ const tx = {
|
||||
return `https://y.qq.com/n/yqq/song/${songInfo.songmid}.html`
|
||||
}
|
||||
}
|
||||
|
||||
export default tx
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
521
src/main/utils/musicSdk/tx/qrc-decrypt.js
Normal 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
|
||||
}
|
||||
86
src/main/utils/musicSdk/tx/quality_detail.js
Normal 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
|
||||
}
|
||||
@@ -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¬ice=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)
|
||||
@@ -202,7 +205,6 @@ export default {
|
||||
if (!result) throw new Error('failed')
|
||||
}
|
||||
id = result[1]
|
||||
// console.log(id)
|
||||
}
|
||||
return id
|
||||
},
|
||||
@@ -215,15 +217,16 @@ 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
|
||||
console.log(body);
|
||||
|
||||
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,15 +321,15 @@ 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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +39,7 @@ 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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
91
src/main/utils/musicSdk/wy/quality_detail.js
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
12
src/preload/index.d.ts
vendored
@@ -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: {
|
||||
@@ -54,6 +53,8 @@ interface CustomAPI {
|
||||
validateIntegrity: (hashId: string) => Promise<any>
|
||||
repairData: (hashId: string) => Promise<any>
|
||||
forceSave: (hashId: string) => Promise<any>
|
||||
getFavoritesId: () => Promise<any>
|
||||
setFavoritesId: (favoritesId: string) => Promise<any>
|
||||
}
|
||||
|
||||
ai: {
|
||||
@@ -68,6 +69,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>
|
||||
@@ -96,10 +98,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,14 +118,13 @@ interface CustomAPI {
|
||||
size: number
|
||||
formatted: string
|
||||
}>
|
||||
|
||||
}
|
||||
|
||||
// 用户配置API
|
||||
getUserConfig: () => Promise<any>
|
||||
|
||||
pluginNotice: {
|
||||
onPluginNotice: (listener: (...args: any[]) => void) => ()=>void
|
||||
onPluginNotice: (listener: (...args: any[]) => void) => () => void
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
@@ -110,7 +116,11 @@ const api = {
|
||||
validateIntegrity: (hashId: string) =>
|
||||
ipcRenderer.invoke('songlist:validate-integrity', hashId),
|
||||
repairData: (hashId: string) => ipcRenderer.invoke('songlist:repair-data', hashId),
|
||||
forceSave: (hashId: string) => ipcRenderer.invoke('songlist:force-save', hashId)
|
||||
forceSave: (hashId: string) => ipcRenderer.invoke('songlist:force-save', hashId),
|
||||
|
||||
// 喜欢歌单ID持久化
|
||||
getFavoritesId: () => ipcRenderer.invoke('songlist:get-favorites-id'),
|
||||
setFavoritesId: (id: string) => ipcRenderer.invoke('songlist:set-favorites-id', id)
|
||||
},
|
||||
|
||||
getUserConfig: () => ipcRenderer.invoke('get-user-config'),
|
||||
|
||||
70
src/renderer/auto-imports.d.ts
vendored
@@ -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')
|
||||
}
|
||||
|
||||
33
src/renderer/components.d.ts
vendored
@@ -10,23 +10,56 @@ 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']
|
||||
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']
|
||||
UpdateProgress: typeof import('./src/components/UpdateProgress.vue')['default']
|
||||
UpdateSettings: typeof import('./src/components/Settings/UpdateSettings.vue')['default']
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
1
src/renderer/src/assets/icons/Add.svg
Normal 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 |
1
src/renderer/src/assets/icons/AddList.svg
Normal 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 |
1
src/renderer/src/assets/icons/Album.svg
Normal 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 |
1
src/renderer/src/assets/icons/Artist.svg
Normal 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 |
1
src/renderer/src/assets/icons/AutoFix.svg
Normal 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 |
1
src/renderer/src/assets/icons/AutoTheme.svg
Normal 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 |