Compare commits

...

33 Commits

Author SHA1 Message Date
sqj
9545f32c81 update(v1.5.0):本次更新带来全新本地播放功能,支持精准匹配与批量操作,优化歌曲列表交互体验;新增桌面歌词双行展示,支持TTML歌词格式。同时修复了本地歌单播放、切歌控制等多个问题,性能与体验全面升级。 2025-11-24 21:12:50 +08:00
sqj
7945108243 feat(本地音乐): 改进本地音乐页面交互和样式
- 添加右键菜单功能支持播放、加入播放列表等操作
- 实现单击加入播放列表、双击播放的交互逻辑
- 优化按钮布局和样式,使用下拉菜单整合更多操作
- 调整歌曲列表样式,移除操作列,改为行点击交互
- 添加加载状态显示,优化匹配中的歌曲展示
2025-11-24 20:45:49 +08:00
sqj
6ce05d286f feat(播放器): 重构全局播放管理逻辑并优化本地音乐匹配功能
新增全局播放管理器模块,集中处理播放控制逻辑
为本地音乐添加精准匹配功能及批量匹配进度显示
优化歌曲列表UI样式和操作体验
修复音频控制事件重复监听的问题
2025-11-23 23:12:36 +08:00
star
8209d021de update(local.vue): 更改界面布局 2025-11-15 16:07:19 +08:00
sqj
a277cb7181 fix: 酷狗歌单无法播放,软件无法退出,全局播放进入设置无法下一曲 feat:本地播放 2025-11-15 13:26:42 +08:00
sqj
a9ad32e8ea 🚀feat: 添加桌面歌词功能,列表平台显示 本地日志优化 fix:大幅优化打包体积 2025-11-01 21:26:39 +08:00
sqj
ca3213d0b3 🚀feat: 添加桌面歌词功能,列表平台显示 本地日志优化 fix:大幅优化打包体积 2025-11-01 21:16:02 +08:00
sqj
7c7455786e 🚀feat: 添加桌面歌词功能,列表平台显示 fix:大幅优化打包体积 2025-11-01 21:07:05 +08:00
sqj
68fb9bcec5 🚀feat: 添加桌面歌词功能,列表平台显示 本地日志优化 fix:大幅优化打包体积 2025-11-01 20:53:45 +08:00
sqj
54e2842b1b �feat: 添加桌面歌词功能,列表平台显示 fix:大幅优化打包体积 2025-11-01 20:15:43 +08:00
sqj
ce743e1b65 Merge branch 'main' of https://github.com/timeshiftsauce/CeruMusic 2025-10-31 01:18:19 +08:00
sqj
32c9fdbfeb feat: 1.歌曲播放器喜欢和下载功能,列表快速加入喜欢。2. 歌曲播放时自动 点击播放其他歌曲自动添加到下一曲播放而不是提到最前,屎山逻辑(bushi。fix: 对于副屏用户优化 全屏宽高限制 导致无法全屏问题。(嘿嘿摸鱼这么多天就更新这些吧周末看看能不能把桌面歌词搞了,容我学习亿下下) 2025-10-31 01:17:36 +08:00
时迁酱
9df236b2e0 Update contact information and sponsorship section 2025-10-30 18:40:29 +08:00
时迁酱
0988c71282 Update README.md 2025-10-30 18:39:28 +08:00
sqj
60881f7f48 feat docs 2025-10-25 20:39:54 +08:00
sqj
775f87aa86 feat docs 2025-10-25 20:14:30 +08:00
sqj
b1c471f15c fix:修复歌单导入失败的问题,歌词翻译匹配逻辑 2025-10-14 21:44:39 +08:00
sqj
f7ecfa1fa9 fix:修复歌单导入失败的问题,歌词翻译匹配逻辑 2025-10-14 21:44:08 +08:00
sqj
d44be6022a fix:优化滚动位置问题,某平台 歌单上限导入失败问题,优化包体积,修复歌曲下载失败 2025-10-11 22:54:10 +08:00
sqj
0c512bccff fix: 修复歌曲无法下载,支持更多音质选择 2025-10-09 20:25:27 +08:00
sqj
b07cc2359a fix: 修复歌曲播放缓存内存泄露问题 feat:歌曲播放出错自动切歌不是暂停 2025-10-09 01:47:07 +08:00
时迁酱
46756a8b09 Merge pull request #12 from GladerJ/main
修复播放新歌曲失败时,原歌曲 UI 状态被错误覆盖的问题。
2025-10-08 16:51:53 +08:00
Glader
deb73fa789 修复播放新歌曲失败时,原歌曲 UI 状态被错误覆盖的问题。
修复播放新歌曲失败时,原歌曲 UI 状态被错误覆盖的问题。
2025-10-08 15:15:30 +08:00
sqj
910ab1ff10 docs: add 赞助名单 2025-10-07 22:24:40 +08:00
sqj
0cfc31de31 feat: 优化搜索联想功能,性能优化设置,网络负载优化设置新增播放列表 **tag** 动画 fix: 修复 2 条 接口失效无法获取搜索联想建议,SMTC问题 2025-10-07 18:14:45 +08:00
sqj
2c0c8be2bf feat: 优化搜索联想功能,性能优化设置,网络负载优化设置新增播放列表 **tag** 动画 fix: 修复 2 条 接口失效无法获取搜索联想建议,SMTC问题 2025-10-07 18:13:53 +08:00
sqj
489e920b69 feat: 搜索联想 fix: 网易云歌单导入数量限制1000的问题 2025-10-06 22:54:10 +08:00
star
fdd548972c fix(local.vue): 轮询获取所有歌单歌曲并聚合 2025-10-04 21:42:06 +08:00
sqj
f81b46b1b4 1. 支持暗黑主题 2. 调整插件页面ui 2025-10-03 23:03:26 +08:00
sqj
e1e2d88c67 1. 支持暗黑主题 2. 调整插件页面ui 2025-10-03 22:34:47 +08:00
sqj
3c0be7a20f 1. 支持暗黑主题 2. 调整插件页面ui 2025-10-03 22:11:57 +08:00
sqj
79e05c884d 1. 支持暗黑主题 2. 调整插件页面ui 2025-10-03 17:38:04 +08:00
sqj
970baf081b 1. 支持暗黑主题 2. 调整插件页面ui 2025-10-03 17:37:34 +08:00
238 changed files with 14586 additions and 21506 deletions

View File

@@ -30,7 +30,7 @@ jobs:
- name: Install Dependencies
run: |
npm i -g yarn
yarn install # 安装项目依赖
yarn # 安装项目依赖
- name: Build Electron App for windows
if: matrix.os == 'windows-latest' # 只在Windows上运行

View File

@@ -6,7 +6,9 @@
Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,**仅提供插件运行框架与播放功能**,不直接存储、提供任何音乐源文件。用户需通过自行选择、安装合规插件获取音乐相关数据,项目旨在为开发者提供桌面应用技术实践与学习案例,为用户提供合规的音乐播放工具框架。
<img src="assets/image-20250827175023917.png" alt="image-20250827175023917" style="zoom: 33%;" /><img src="assets/image-20250827175109430.png" alt="image-20250827175109430" style="zoom:33%;" />
<img src="assets/image-20251003173109619.png" alt="image-20251003173109619" style="zoom:33%;" />
![image-20251003173654569](assets/image-20251003173654569.png)
## Star History
@@ -24,6 +26,9 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工
## 项目结构
<details>
<summary>点击查看目录结构</summary>
```ast
CeruMuisc/
├── .github/
@@ -209,6 +214,8 @@ CeruMuisc/
└── yarn.lock
```
</details>
## 主要功能
- 提供插件加载与管理功能,支持通过合规插件获取公开音乐信息
@@ -288,7 +295,11 @@ CeruMuisc/
## 联系方式
如有技术问题或合作意向(仅限技术交流),请通过 Gitee 私信联系项目维护者。
如有技术问题或合作意向
可通过如下方式联系
- QQ: 2115295703
- 微信13600973542
- 邮箱sqj@shiqianjiang.cn
## 项目开发者
@@ -350,8 +361,3 @@ CeruMuisc/
若您认可本项目的技术价值,欢迎通过以下方式支持开发者(仅用于项目技术维护与迭代):
<img src="assets/image-20250827175356006.png" alt="赞助方式1" style="zoom:33%;" /><img src="assets/image-20250827175547444.png" alt="赞助方式2" style="zoom: 33%;" />
## 联系
关于项目问题也可联系
邮箱sqj@shiqianjiang.cn

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 870 KiB

View File

@@ -5,6 +5,19 @@ 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: {
@@ -77,8 +90,7 @@ export default defineConfig({
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 870 KiB

View File

@@ -1,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**: 初始版本,支持基础的音乐数据获取功能

View File

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

View File

@@ -2,29 +2,55 @@
## 日志
- ###### 2025-10-3 (v1.3.12)
- ###### 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
1. debug:flac格式使用ffmpeg
2. 修复高音质下载失效
- ###### 2025-9-26 (v1.3.8)
1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩

View File

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

View File

@@ -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. 贡献的代码需无第三方版权纠纷,且不违反开源许可协议。
## 联系方式

View File

@@ -6,8 +6,12 @@ asar: true
files:
- '!**/.vscode/*'
- '!src/*'
- '!website/*'
- '!scripts/*'
- '!assets/*'
- '!docs/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md,.idea,.kiro,.codebuddy}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
@@ -81,4 +85,4 @@ publish:
provider: generic
url: https://update.ceru.shiqianjiang.cn
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
mirror: https://npmmirror.com/mirrors/electron/

View File

@@ -6,8 +6,8 @@ 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({
main: {
plugins: [externalizeDepsPlugin()],
@@ -15,6 +15,14 @@ export default defineConfig({
alias: {
'@common': resolve('src/common')
}
},
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/main/index.ts'),
lyric: resolve(__dirname, 'src/web/lyric.html')
}
}
}
},
preload: {
@@ -37,13 +45,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
})

15117
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "ceru-music",
"version": "1.3.12",
"version": "1.5.0",
"description": "一款简洁优雅的音乐播放器",
"main": "./out/main/index.js",
"author": "sqj,wldss,star",
@@ -47,9 +47,8 @@
"@pixi/filter-bulge-pinch": "^5.1.1",
"@pixi/filter-color-matrix": "^7.4.3",
"@pixi/sprite": "^7.4.3",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/howler": "^2.2.12",
"@types/needle": "^3.3.0",
"NeteaseCloudMusicApi": "^4.27.0",
"animate.css": "^4.1.1",
"axios": "^1.11.0",
"color-extraction": "^1.0.8",
@@ -57,8 +56,7 @@
"dompurify": "^3.2.6",
"electron-log": "^5.4.3",
"electron-updater": "^6.3.9",
"ffmpeg-static": "^5.2.0",
"fluent-ffmpeg": "^2.1.3",
"howler": "^2.2.4",
"hpagent": "^1.2.0",
"iconv-lite": "^0.7.0",
"jss": "^10.10.0",
@@ -69,8 +67,9 @@
"mitt": "^3.0.1",
"needle": "^3.3.1",
"node-fetch": "2",
"node-id3": "^0.2.9",
"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",
@@ -88,12 +87,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",
@@ -110,4 +111,4 @@
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.3"
}
}
}

692
playlist-converter.html Normal file
View File

@@ -0,0 +1,692 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>歌单格式转换器</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
font-weight: 300;
}
.header p {
font-size: 1.1em;
opacity: 0.9;
}
.content {
padding: 40px;
}
.upload-section {
background: #f8f9ff;
border: 3px dashed #4facfe;
border-radius: 15px;
padding: 40px;
text-align: center;
margin-bottom: 30px;
transition: all 0.3s ease;
cursor: pointer;
}
.upload-section:hover {
border-color: #00f2fe;
background: #f0f8ff;
transform: translateY(-2px);
}
.upload-section.dragover {
border-color: #00f2fe;
background: #e8f4ff;
transform: scale(1.02);
}
.upload-icon {
font-size: 4em;
color: #4facfe;
margin-bottom: 20px;
}
.upload-text {
font-size: 1.3em;
color: #333;
margin-bottom: 10px;
}
.upload-subtext {
color: #666;
font-size: 0.9em;
}
.file-input {
display: none;
}
.preview-section {
display: none;
margin-bottom: 30px;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.preview-title {
font-size: 1.5em;
color: #333;
font-weight: 500;
}
.song-count {
background: #4facfe;
color: white;
padding: 5px 15px;
border-radius: 20px;
font-size: 0.9em;
}
.preview-content {
background: #f8f9ff;
border-radius: 15px;
padding: 20px;
max-height: 400px;
overflow-y: auto;
}
.song-item {
background: white;
border-radius: 10px;
padding: 15px;
margin-bottom: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
transition: all 0.3s ease;
}
.song-item:hover {
transform: translateX(5px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.song-number {
background: #4facfe;
color: white;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-right: 15px;
font-size: 0.9em;
}
.song-info {
flex: 1;
}
.song-name {
font-weight: 600;
color: #333;
margin-bottom: 5px;
}
.song-artist {
color: #666;
font-size: 0.9em;
}
.controls {
display: flex;
gap: 15px;
margin-bottom: 30px;
flex-wrap: wrap;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 25px;
font-size: 1em;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
.btn-secondary {
background: #f1f3f4;
color: #333;
}
.btn-success {
background: linear-gradient(135deg, #56ab2f 0%, #a8e6cf 100%);
color: white;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.result-section {
display: none;
background: #f8f9ff;
border-radius: 15px;
padding: 30px;
text-align: center;
}
.result-icon {
font-size: 4em;
margin-bottom: 20px;
}
.result-success {
color: #28a745;
}
.result-error {
color: #dc3545;
}
.result-message {
font-size: 1.2em;
margin-bottom: 20px;
color: #333;
}
.download-link {
display: inline-block;
background: linear-gradient(135deg, #56ab2f 0%, #a8e6cf 100%);
color: white;
padding: 12px 30px;
border-radius: 25px;
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
}
.download-link:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.loading {
display: none;
text-align: center;
padding: 40px;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #4facfe;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.error-details {
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 10px;
padding: 15px;
margin-top: 15px;
text-align: left;
font-family: 'Courier New', monospace;
font-size: 0.9em;
color: #c53030;
max-height: 200px;
overflow-y: auto;
}
@media (max-width: 768px) {
.container {
margin: 10px;
border-radius: 15px;
}
.content {
padding: 20px;
}
.controls {
flex-direction: column;
}
.btn {
width: 100%;
justify-content: center;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎵 澜音歌单格式转换器</h1>
<p>将洛雪音乐平台的歌单转换为澜音音乐的加密格式</p>
</div>
<div class="content">
<div class="upload-section" id="uploadSection">
<div class="upload-icon">📁</div>
<div class="upload-text">点击或拖拽洛雪歌单文件到此处</div>
<div class="upload-subtext">支持 *.json 和 *.lxmc 格式的歌单文件</div>
<input type="file" id="fileInput" class="file-input" accept=".json,.lxmc" />
</div>
<div class="preview-section" id="previewSection">
<div class="preview-header">
<div class="preview-title">歌单预览</div>
<div class="song-count" id="songCount">0 首歌曲</div>
</div>
<div class="preview-content" id="previewContent">
<!-- 歌曲列表将在这里显示 -->
</div>
</div>
<div class="controls" id="controls" style="display: none">
<button class="btn btn-primary" id="convertBtn">
<span>🔐</span>
转换并加密
</button>
<button class="btn btn-secondary" id="resetBtn">
<span>🔄</span>
重新选择
</button>
</div>
<div class="loading" id="loadingSection">
<div class="spinner"></div>
<div>正在转换中...</div>
</div>
<div class="result-section" id="resultSection">
<div class="result-icon" id="resultIcon"></div>
<div class="result-message" id="resultMessage"></div>
<a href="#" class="download-link" id="downloadLink" style="display: none">
<span>📥</span>
下载澜音歌单
</a>
<div class="error-details" id="errorDetails" style="display: none"></div>
</div>
</div>
</div>
<script>
// 全局变量
let currentPlaylist = null
let convertedPlaylist = null
const SECRET_KEY = 'CeruMusic-PlaylistSecretKey'
// Gzip解压函数
async function decompressGzip(arrayBuffer) {
try {
// 使用浏览器的 DecompressionStream API
const stream = new Response(arrayBuffer).body.pipeThrough(new DecompressionStream('gzip'))
const decompressed = await new Response(stream).arrayBuffer()
return new TextDecoder().decode(decompressed)
} catch (error) {
throw new Error('Gzip解压失败: ' + error.message)
}
}
// DOM元素
const uploadSection = document.getElementById('uploadSection')
const fileInput = document.getElementById('fileInput')
const previewSection = document.getElementById('previewSection')
const previewContent = document.getElementById('previewContent')
const songCount = document.getElementById('songCount')
const controls = document.getElementById('controls')
const convertBtn = document.getElementById('convertBtn')
const resetBtn = document.getElementById('resetBtn')
const loadingSection = document.getElementById('loadingSection')
const resultSection = document.getElementById('resultSection')
const resultIcon = document.getElementById('resultIcon')
const resultMessage = document.getElementById('resultMessage')
const downloadLink = document.getElementById('downloadLink')
const errorDetails = document.getElementById('errorDetails')
// 文件上传处理
uploadSection.addEventListener('click', () => fileInput.click())
uploadSection.addEventListener('dragover', (e) => {
e.preventDefault()
uploadSection.classList.add('dragover')
})
uploadSection.addEventListener('dragleave', () => {
uploadSection.classList.remove('dragover')
})
uploadSection.addEventListener('drop', (e) => {
e.preventDefault()
uploadSection.classList.remove('dragover')
const files = e.dataTransfer.files
if (files.length > 0) {
handleFile(files[0])
}
})
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0]
if (file) {
handleFile(file)
}
})
// 按钮事件
convertBtn.addEventListener('click', convertPlaylist)
resetBtn.addEventListener('click', resetConverter)
// 处理文件
function handleFile(file) {
const fileName = file.name.toLowerCase()
if (fileName.endsWith('.json')) {
// 处理JSON文件
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = JSON.parse(e.target.result)
currentPlaylist = content
displayPreview(content)
} catch (error) {
showError('JSON文件解析失败: ' + error.message)
}
}
reader.readAsText(file)
} else if (fileName.endsWith('.lxmc')) {
// 处理LXMC文件gzip压缩
const reader = new FileReader()
reader.onload = async (e) => {
try {
const arrayBuffer = e.target.result
const decompressed = await decompressGzip(arrayBuffer)
const content = JSON.parse(decompressed)
currentPlaylist = content
displayPreview(content)
} catch (error) {
showError('LXMC文件处理失败: ' + error.message)
}
}
reader.readAsArrayBuffer(file)
} else {
showError('请选择JSON或LXMC格式的文件')
return
}
}
// 显示预览
function displayPreview(playlist) {
previewContent.innerHTML = ''
const playlistData =
playlist.type === 'playListPart_v2' && playlist.data ? playlist.data : playlist
const songList = playlistData.list
if (!songList || !Array.isArray(songList)) {
showError('无效的歌单格式:缺少歌曲列表')
return
}
// 显示歌曲列表
songList.forEach((song, index) => {
const songItem = document.createElement('div')
songItem.className = 'song-item'
songItem.innerHTML = `
<div class="song-number">${index + 1}</div>
<div class="song-info">
<div class="song-name">${song.name || '未知歌曲'}</div>
<div class="song-artist">${song.singer || '未知艺术家'}${song.interval || '0:00'}</div>
</div>
`
previewContent.appendChild(songItem)
})
songCount.textContent = `${songList.length} 首歌曲`
previewSection.style.display = 'block'
controls.style.display = 'flex'
uploadSection.style.display = 'none'
}
// 转换歌单
function convertPlaylist() {
if (!currentPlaylist) return
showLoading()
try {
// 转换格式
const convertedSongs = convertToTargetFormat(currentPlaylist)
// 加密
const encryptedData = encryptPlaylist(convertedSongs)
convertedPlaylist = {
original: currentPlaylist,
converted: convertedSongs,
encrypted: encryptedData
}
showSuccess(convertedPlaylist)
} catch (error) {
showError('转换失败: ' + error.message, error.stack)
}
}
// 转换为目标格式
function convertToTargetFormat(originalPlaylist) {
if (originalPlaylist.type === 'playListPart_v2' && originalPlaylist.data) {
originalPlaylist = originalPlaylist.data
}
if (!originalPlaylist.list || !Array.isArray(originalPlaylist.list)) {
throw new Error('原始歌单格式无效')
}
return originalPlaylist.list.map((song) => {
// 从meta中提取额外信息
const meta = song.meta || {}
return {
songmid: meta.hash || meta.songId,
singer: song.singer || '未知艺术家',
name: song.name || '未知歌曲',
albumName: meta.albumName || '未知专辑',
albumId: meta.albumId,
source: song.source || 'unknown',
interval: song.interval || '0:00',
img: meta.picUrl || '',
lrc: null,
types: meta.qualitys,
_types: meta._qualitys,
typeUrl: {},
url: ''
}
})
}
// 转换音质类型
function convertQualityTypes(qualities) {
const types = {}
if (!qualities || !Array.isArray(qualities)) {
types['128k'] = { size: '未知' }
return types
}
qualities.forEach((quality) => {
if (typeof quality.type !== 'string') return
const q = quality.type.toLowerCase()
if (q.includes('flac')) {
if (q.includes('24')) {
types['flac24bit'] = { size: '未知' }
} else {
types['flac'] = { size: '未知' }
}
} else if (q.includes('320')) {
types['320k'] = { size: '未知' }
} else if (q.includes('128')) {
types['128k'] = { size: '未知' }
} else {
types[q] = { size: '未知' }
}
})
return Object.keys(types).length > 0 ? types : { '128k': { size: '未知' } }
}
// 生成随机ID
function generateRandomId() {
return Math.random().toString(36).substr(2, 9)
}
// 加密歌单
function encryptPlaylist(songs) {
const dataToEncrypt = JSON.stringify(songs)
return CryptoJS.AES.encrypt(dataToEncrypt, SECRET_KEY).toString()
}
// 显示加载状态
function showLoading() {
loadingSection.style.display = 'block'
controls.style.display = 'none'
resultSection.style.display = 'none'
}
// 显示成功结果
function showSuccess(result) {
loadingSection.style.display = 'none'
resultSection.style.display = 'block'
resultIcon.className = 'result-icon result-success'
resultIcon.textContent = '✅'
resultMessage.textContent = '歌单转换成功!'
// 创建下载链接
const encryptedData = result.encrypted
const blob = new Blob([encryptedData], {
type: 'application/octet-stream'
})
const url = URL.createObjectURL(blob)
downloadLink.href = url
downloadLink.download = `cerumusic-playlist-${new Date().toISOString().slice(0, 10)}.cpl`
downloadLink.style.display = 'inline-block'
// 清理之前的URL
if (downloadLink.dataset.url) {
URL.revokeObjectURL(downloadLink.dataset.url)
}
downloadLink.dataset.url = url
}
// 显示错误信息
function showError(message, details = null) {
loadingSection.style.display = 'none'
resultSection.style.display = 'block'
resultIcon.className = 'result-icon result-error'
resultIcon.textContent = '❌'
resultMessage.textContent = message
if (details) {
errorDetails.textContent = details
errorDetails.style.display = 'block'
}
downloadLink.style.display = 'none'
}
// 重置转换器
function resetConverter() {
currentPlaylist = null
convertedPlaylist = null
uploadSection.style.display = 'block'
previewSection.style.display = 'none'
controls.style.display = 'none'
loadingSection.style.display = 'none'
resultSection.style.display = 'none'
fileInput.value = ''
previewContent.innerHTML = ''
errorDetails.style.display = 'none'
// 清理下载链接
if (downloadLink.dataset.url) {
URL.revokeObjectURL(downloadLink.dataset.url)
delete downloadLink.dataset.url
}
}
// 页面卸载时清理资源
window.addEventListener('beforeunload', () => {
if (downloadLink.dataset.url) {
URL.revokeObjectURL(downloadLink.dataset.url)
}
})
</script>
</body>
</html>

View File

@@ -0,0 +1,10 @@
export interface lyricConfig {
fontSize: number
mainColor: string
shadowColor: string
// 窗口位置
x?: number
y?: number
width?: number
height?: number
}

View File

@@ -0,0 +1,9 @@
export default interface localList {
singer: string
name: string
albumName: string
interval: string
duration: string
img: string
lrc: null | string
}

View File

@@ -11,5 +11,6 @@ export default interface PlayList {
lrc: null | string
types: string[]
_types: Record<string, any>
typeUrl: Record<string, any>
typeUrl?: Record<string, any>
url?: string
}

View File

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

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

@@ -0,0 +1,107 @@
import InitPluginService from './plugins'
import '../services/musicSdk/index'
import aiEvents from '../events/ai'
// import initLocalMusicEvents from './localMusic'
import { app, powerSaveBlocker } from 'electron'
import { type BrowserWindow, ipcMain } from 'electron'
export default function InitEventServices(mainWindow: BrowserWindow) {
InitPluginService()
aiEvents(mainWindow)
// initLocalMusicEvents()
basisEvent(mainWindow)
}
function basisEvent(mainWindow: BrowserWindow) {
let psbId: number | null = null
// 复用主进程创建的托盘
let tray: any = (global as any).__ceru_tray__ || null
let isQuitting = false
// 托盘菜单与图标由主进程统一创建,这里不再重复创建
// 播放/暂停由主进程托盘菜单触发 'music-control' 事件
// 应用退出前的清理
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()
// 显示托盘通知(可选)
tray = (global as any).__ceru_tray__ || tray
if (tray && tray.displayBalloon) {
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()
// 显示托盘通知
tray = (global as any).__ceru_tray__ || tray
if (tray && tray.displayBalloon) {
tray.displayBalloon({
title: 'Ceru Music',
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
})
}
}
})
// 阻止系统息屏 IPC开启/关闭)
ipcMain.handle('power-save-blocker:start', () => {
if (psbId == null) {
psbId = powerSaveBlocker.start('prevent-display-sleep')
}
return psbId
})
ipcMain.handle('power-save-blocker:stop', () => {
if (psbId != null && powerSaveBlocker.isStarted(psbId)) {
powerSaveBlocker.stop(psbId)
}
psbId = null
return true
})
// 获取应用版本号
ipcMain.handle('get-app-version', () => {
return app.getVersion()
})
}

View File

@@ -0,0 +1,378 @@
import { ipcMain, dialog, app } from 'electron'
import fs from 'fs'
import fsp from 'fs/promises'
import path from 'node:path'
import crypto from 'crypto'
import { localMusicIndexService } from '../services/LocalMusicIndex'
// remove static import to avoid runtime failures if native module is missing
const AUDIO_EXTS = new Set(['.mp3', '.flac', '.wav', '.aac', '.m4a', '.ogg', '.wma'])
function genId(input: string) {
return crypto.createHash('md5').update(input).digest('hex')
}
async function walkDir(dir: string, results: string[]) {
try {
const items = await fsp.readdir(dir, { withFileTypes: true })
for (const item of items) {
const full = path.join(dir, item.name)
if (item.isDirectory()) {
await walkDir(full, results)
} else {
const ext = path.extname(item.name).toLowerCase()
if (AUDIO_EXTS.has(ext)) results.push(full)
}
}
} catch {}
}
function readTags(filePath: string) {
try {
const taglib = require('node-taglib-sharp')
const f = taglib.File.createFromPath(filePath)
const tag = f.tag
const title = tag.title || ''
const album = tag.album || ''
const performers = Array.isArray(tag.performers) ? tag.performers : []
let img = ''
if (Array.isArray(tag.pictures) && tag.pictures.length > 0) {
try {
const buf = tag.pictures[0].data
const mime = tag.pictures[0].mimeType || 'image/jpeg'
img = `data:${mime};base64,${Buffer.from(buf).toString('base64')}`
} catch {}
}
let lrc: string | null = null
try {
const raw = tag.lyrics || ''
if (raw && typeof raw === 'string') {
lrc = normalizeLyricsToLrc(raw)
}
} catch {}
f.dispose()
return { title, album, performers, img, lrc }
} catch {
return { title: '', album: '', performers: [], img: '', lrc: null }
}
}
// 将两种逐字/行内时间歌词统一转换为标准LRC仅保留行时间标签
function normalizeLyricsToLrc(input: string): string {
const lines = String(input).split('\n')
const msFormat = (timeMs: number) => {
if (!Number.isFinite(timeMs)) return ''
const m = Math.floor(timeMs / 60000)
const s = Math.floor((timeMs % 60000) / 1000)
const ms = Math.floor(timeMs % 1000)
return `[${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(3, '0')}]`
}
const out: string[] = []
for (const line of lines) {
if (!line.trim()) {
out.push(line)
continue
}
const off = /^\[offset:[+-]?\d+\]$/i.exec(line.trim())
if (off) {
out.push(line.trim())
continue
}
const mNew = /^\[(\d+),(\d+)\](.*)$/.exec(line)
if (mNew) {
const startMs = parseInt(mNew[1])
let text = mNew[3] || ''
text = text.replace(/\(\d+,\d+(?:,\d+)?\)/g, '')
text = text.replace(/<\d{2}:\d{2}\.\d{3}>/g, '')
if (/(<\d{2}:\d{2}\.\d{3}>)|(\(\d+,\d+(?:,\d+)?\))/.test(mNew[3] || '')) {
text = text.replace(/[()]/g, '')
}
text = text.replace(/\s+/g, ' ').trim()
const tag = msFormat(startMs)
out.push(`${tag}${text}`)
continue
}
const mOld = /^\[(\d{2}:\d{2}\.\d{3})\](.*)$/.exec(line)
if (mOld) {
let text = mOld[2] || ''
text = text.replace(/\(\d+,\d+(?:,\d+)?\)/g, '')
text = text.replace(/<\d{2}:\d{2}\.\d{3}>/g, '')
if (/(<\d{2}:\d{2}\.\d{3}>)|(\(\d+,\d+(?:,\d+)?\))/.test(mOld[2] || '')) {
text = text.replace(/[()]/g, '')
}
text = text.replace(/\s+/g, ' ').trim()
const tag = `[${mOld[1]}]`
out.push(`${tag}${text}`)
continue
}
out.push(line)
}
return out.join('\n')
}
// function timeToMs(s: string): number {
// const m = /(\d{2}):(\d{2})\.(\d{3})/.exec(s)
// if (!m) return NaN
// return parseInt(m[1]) * 60000 + parseInt(m[2]) * 1000 + parseInt(m[3])
// }
// function normalizeLyricsToCrLyric(input: string): string {
// const raw = String(input).replace(/\r/g, '')
// const lines = raw.split('\n')
// let offset = 0
// const res: string[] = []
// for (let i = 0; i < lines.length; i++) {
// const line = lines[i]
// if (!line.trim()) {
// res.push(line)
// continue
// }
// const off = /^\[offset:([+-]?\d+)\]$/i.exec(line.trim())
// if (off) {
// offset = parseInt(off[1]) || 0
// res.push(line)
// continue
// }
// const yrcLike = /\[\d+,\d+\]/.test(line) && /\(\d+,\d+,\d+\)/.test(line)
// if (yrcLike) {
// res.push(line)
// continue
// }
// const mLine = /^\[(\d{2}:\d{2}\.\d{3})\](.*)$/.exec(line)
// if (!mLine) {
// res.push(line)
// continue
// }
// const lineStart = timeToMs(mLine[1]) + offset
// let rest = mLine[2]
// rest = rest.replace(/\(\d+,\d+(?:,\d+)?\)/g, '')
// const segs: { start: number; text: string }[] = []
// const re = /<(\d{2}:\d{2}\.\d{3})>([^<]*)/g
// let m: RegExpExecArray | null
// while ((m = re.exec(rest))) {
// const start = timeToMs(m[1]) + offset
// const text = m[2] || ''
// if (text) segs.push({ start, text })
// }
// if (segs.length === 0) {
// res.push(line)
// continue
// }
// let nextLineStart: number | null = null
// for (let j = i + 1; j < lines.length; j++) {
// const ml = /^\[(\d{2}:\d{2}\.\d{3})\]/.exec(lines[j])
// if (ml) {
// nextLineStart = timeToMs(ml[1]) + offset
// break
// }
// const skip = lines[j].trim()
// if (!skip || /^\[offset:/.test(skip)) continue
// break
// }
// const tokens: string[] = []
// for (let k = 0; k < segs.length; k++) {
// const cur = segs[k]
// const nextStart =
// k < segs.length - 1 ? segs[k + 1].start : (nextLineStart ?? cur.start + 1000)
// const span = Math.max(1, nextStart - cur.start)
// const chars = Array.from(cur.text).filter((ch) => !/\s/.test(ch))
// if (chars.length <= 1) {
// if (chars.length === 1) tokens.push(`(${cur.start},${span},0)` + chars[0])
// } else {
// const per = Math.max(1, Math.floor(span / chars.length))
// for (let c = 0; c < chars.length; c++) {
// const cs = cur.start + c * per
// const cd = c === chars.length - 1 ? Math.max(1, nextStart - cs) : per
// tokens.push(`(${cs},${cd},0)` + chars[c])
// }
// }
// }
// const lineEnd =
// nextLineStart ??
// segs[segs.length - 1].start +
// Math.max(
// 1,
// (nextLineStart ?? segs[segs.length - 1].start + 1000) - segs[segs.length - 1].start
// )
// const ld = Math.max(0, lineEnd - lineStart)
// res.push(`[${lineStart},${ld}]` + tokens.join(' '))
// }
// return res.join('\n')
// }
ipcMain.handle('local-music:select-dirs', async () => {
const res = await dialog.showOpenDialog({ properties: ['openDirectory', 'multiSelections'] })
if (res.canceled) return []
return res.filePaths
})
ipcMain.handle('local-music:scan', async (_e, dirs: string[]) => {
if (!Array.isArray(dirs) || dirs.length === 0) {
return []
}
const existsDirs = dirs.filter((d) => {
try {
return fs.existsSync(d)
} catch {
return false
}
})
const files: string[] = []
try {
for (const d of existsDirs) await walkDir(d, files)
const list = files.map((p) => {
let tags = {
title: '',
album: '',
performers: [] as string[],
img: '',
lrc: null as null | string
}
try {
tags = readTags(p)
} catch {}
const base = path.basename(p)
const noExt = base.replace(path.extname(base), '')
let name = tags.title || ''
let singer = ''
if (!name) {
const segs = noExt
.split(/[-_]|\s{2,}/)
.map((s) => s.trim())
.filter(Boolean)
if (segs.length >= 2) {
singer = segs[0]
name = segs.slice(1).join(' ')
} else {
name = noExt
}
} else {
singer =
Array.isArray(tags.performers) && tags.performers.length > 0 ? tags.performers[0] : ''
}
const songmid = genId(p)
const item = {
songmid,
singer: singer || '未知艺术家',
name: name || '未知曲目',
albumName: tags.album || '未知专辑',
albumId: 0,
source: 'local',
interval: '',
img: tags.img || '',
lrc: tags.lrc || null,
types: [],
_types: {},
typeUrl: {},
url: 'file://' + p,
path: p
}
return item
})
await localMusicIndexService.setDirs(existsDirs)
await localMusicIndexService.upsertSongs(list)
try {
return JSON.stringify(list)
} catch {
return '[]'
}
} catch (e) {
return '[]'
}
})
ipcMain.handle('local-music:write-tags', async (_e, payload: any) => {
const { filePath, songInfo, tagWriteOptions } = payload || {}
if (!filePath || !fs.existsSync(filePath)) return { success: false, message: '文件不存在' }
try {
const taglib = require('node-taglib-sharp')
const songFile = taglib.File.createFromPath(filePath)
taglib.Id3v2Settings.forceDefaultVersion = true
taglib.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
if (tagWriteOptions?.lyrics && songInfo?.lrc) {
const normalized = normalizeLyricsToLrc(songInfo.lrc)
songFile.tag.lyrics = normalized
}
if (tagWriteOptions?.cover && songInfo?.img) {
try {
if (songInfo.img.startsWith('data:')) {
const m = songInfo.img.match(/^data:(.*?);base64,(.*)$/)
if (m) {
// const mime = m[1]
const buf = Buffer.from(m[2], 'base64')
const tmp = path.join(path.dirname(filePath), genId(filePath) + '.cover')
await fsp.writeFile(tmp, buf)
const pic = taglib.Picture.fromPath(tmp)
songFile.tag.pictures = [pic]
try {
await fsp.unlink(tmp)
} catch {}
}
}
} catch {}
}
songFile.save()
songFile.dispose()
const songmid = genId(filePath)
await localMusicIndexService.upsertSong({
songmid,
singer: songInfo?.singer || '未知艺术家',
name: songInfo?.name || '未知曲目',
albumName: songInfo?.albumName || '未知专辑',
albumId: 0,
source: 'local',
interval: '',
img: songInfo?.img || '',
lrc: songInfo?.lrc || null,
types: [],
_types: {},
typeUrl: {},
url: 'file://' + filePath,
path: filePath
})
return { success: true }
} catch (e: any) {
return { success: false, message: e?.message || '写入失败' }
}
})
ipcMain.handle('local-music:get-dirs', async () => {
return localMusicIndexService.getDirs()
})
ipcMain.handle('local-music:set-dirs', async (_e, dirs: string[]) => {
await localMusicIndexService.setDirs(Array.isArray(dirs) ? dirs : [])
return { success: true }
})
ipcMain.handle('local-music:get-list', async () => {
return localMusicIndexService.getAllSongs()
})
ipcMain.handle('local-music:get-url', async (_e, id: string | number) => {
const u = localMusicIndexService.getUrlById(id)
if (!u) return { error: '未找到本地文件' }
return u
})
ipcMain.handle('local-music:clear-index', async () => {
try {
const fn = (localMusicIndexService as any).clearSongs
if (typeof fn === 'function') {
await fn.call(localMusicIndexService)
return { success: true }
}
const dirs = localMusicIndexService.getDirs()
const file = require('node:path').join(app.getPath('userData'), 'local-music-index.json')
const data = { songs: {}, dirs: Array.isArray(dirs) ? dirs : [], updatedAt: Date.now() }
await require('fs/promises').writeFile(file, JSON.stringify(data, null, 2))
return { success: true }
} catch (e: any) {
return { success: false, message: e?.message || '清空失败' }
}
})

176
src/main/events/lyric.ts Normal file
View File

@@ -0,0 +1,176 @@
import { BrowserWindow, ipcMain, screen } from 'electron'
import { isAbsolute, relative, resolve } from 'path'
import { lyricConfig } from '@common/types/config'
import { configManager } from '../services/ConfigManager'
import lyricWindow from '../windows/lyric-window'
const lyricStore = {
get: () =>
configManager.get<lyricConfig>('lyric', {
fontSize: 30,
mainColor: '#73BCFC',
shadowColor: 'rgba(255, 255, 255, 0.5)',
x: screen.getPrimaryDisplay().workAreaSize.width / 2 - 400,
y: screen.getPrimaryDisplay().workAreaSize.height - 90,
width: 800,
height: 180
}),
set: (value: lyricConfig) => configManager.set<lyricConfig>('lyric', value)
}
/**
* 歌词相关 IPC
*/
const initLyricIpc = (mainWin?: BrowserWindow | null): void => {
// const mainWin = mainWindow.getWin()
const lyricWin = lyricWindow.getWin()
// 切换桌面歌词
ipcMain.on('change-desktop-lyric', (_event, val: boolean) => {
if (!lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed()) return
if (val) {
lyricWin?.show()
lyricWin?.setAlwaysOnTop(true, 'screen-saver')
} else lyricWin?.hide()
})
ipcMain.on('win-show', () => {
mainWin?.show()
})
// 音乐名称更改
ipcMain.on('play-song-change', (_, title) => {
if (!title) return
if (!lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed()) return
lyricWin?.webContents.send('play-song-change', title)
})
// 音乐歌词更改
ipcMain.on('play-lyric-change', (_, lyricData) => {
if (!lyricData) return
if (!lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed()) return
lyricWin?.webContents.send('play-lyric-change', lyricData)
})
// 当前行索引变化(用于立即高亮切换)
ipcMain.on('play-lyric-index', (_, index: number) => {
if (index === undefined || index === null) return
if (!lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed()) return
lyricWin?.webContents.send('play-lyric-index', index)
})
// 当前行进度(用于控制 30% 时机的延迟替换)
ipcMain.on('play-lyric-progress', (_, payload: { index: number; progress: number }) => {
if (!payload || !lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed())
return
lyricWin?.webContents.send('play-lyric-progress', payload)
})
// 播放状态更改(播放/暂停)
ipcMain.on('play-status-change', (_, status: boolean) => {
if (!lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed()) return
lyricWin?.webContents.send('play-status-change', status)
})
// 获取窗口位置
ipcMain.handle('get-window-bounds', () => {
return lyricWin?.getBounds()
})
// 同步获取窗口位置(回退)
ipcMain.on('get-window-bounds-sync', (event) => {
event.returnValue = lyricWin?.getBounds()
})
// 获取屏幕尺寸
ipcMain.handle('get-screen-size', () => {
const { width, height } = screen.getPrimaryDisplay().workAreaSize
return { width, height }
})
// 同步获取屏幕尺寸(回退)
ipcMain.on('get-screen-size-sync', (event) => {
const { width, height } = screen.getPrimaryDisplay().workAreaSize
event.returnValue = { width, height }
})
// 移动窗口
ipcMain.on('move-window', (_, x, y, width, height) => {
lyricWin?.setBounds({ x, y, width, height })
// 保存配置
lyricStore.set({ ...lyricStore.get(), x, y, width, height })
// 保持置顶
lyricWin?.setAlwaysOnTop(true, 'screen-saver')
})
// 更新高度
ipcMain.on('update-window-height', (_, height) => {
if (!lyricWin) return
const { width } = lyricWin.getBounds()
// 更新窗口高度
lyricWin.setBounds({ width, height })
})
// 获取配置
ipcMain.handle('get-desktop-lyric-option', () => {
return lyricStore.get()
})
// 同步获取配置(用于 invoke 不可用的回退)
ipcMain.on('get-desktop-lyric-option-sync', (event) => {
event.returnValue = lyricStore.get()
})
// 保存配置
ipcMain.on('set-desktop-lyric-option', (_, option, callback: boolean = false) => {
lyricStore.set(option)
// 触发窗口更新
if (callback && lyricWin) {
lyricWin.webContents.send('desktop-lyric-option-change', option)
}
mainWin?.webContents.send('desktop-lyric-option-change', option)
})
// 发送主程序事件
ipcMain.on('send-main-event', (_, name, val) => {
mainWin?.webContents.send(name, val)
})
// 关闭桌面歌词
ipcMain.on('closeDesktopLyric', () => {
lyricWin?.hide()
mainWin?.webContents.send('closeDesktopLyric')
})
// 锁定/解锁桌面歌词
let lyricLockState = false
ipcMain.on('toogleDesktopLyricLock', (_, isLock: boolean) => {
if (!lyricWin) return
lyricLockState = !!isLock
// 是否穿透
if (lyricLockState) {
lyricWin.setIgnoreMouseEvents(true, { forward: true })
} else {
lyricWin.setIgnoreMouseEvents(false)
}
// 广播到桌面歌词窗口与主窗口,保持两端状态一致
lyricWin.webContents.send('toogleDesktopLyricLock', lyricLockState)
mainWin?.webContents.send('toogleDesktopLyricLock', lyricLockState)
})
// 查询当前桌面歌词锁定状态
ipcMain.handle('get-lyric-lock-state', () => lyricLockState)
// 检查是否是子文件夹
ipcMain.handle('check-if-subfolder', (_, localFilesPath: string[], selectedDir: string) => {
const resolvedSelectedDir = resolve(selectedDir)
const allPaths = localFilesPath.map((p) => resolve(p))
return allPaths.some((existingPath) => {
const relativePath = relative(existingPath, resolvedSelectedDir)
return relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath)
})
})
}
export default initLyricIpc

View File

@@ -11,6 +11,12 @@
import { BrowserWindow } from 'electron'
let mainWindow: BrowserWindow | null = null
export function initPluginNotice(mainWindowInstance: BrowserWindow): void {
mainWindow = mainWindowInstance
}
export interface PluginNoticeData {
type: 'error' | 'info' | 'success' | 'warn' | 'update'
data: {
@@ -97,10 +103,9 @@ function getDefaultMessage(type: string, data: any, pluginName: string): string
*/
export function sendPluginNotice(noticeData: PluginNoticeData, pluginName?: string): void {
try {
console.log(`[CeruMusic] 插件通知: ${noticeData.type}`, noticeData.data)
// console.log(`[CeruMusic] 插件通知: ${noticeData.type}`, noticeData.data)
// 获取主窗口实例
const mainWindow = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed())
if (!mainWindow) {
console.warn('[CeruMusic] 未找到主窗口,无法发送通知')
return
@@ -152,7 +157,6 @@ export function sendPluginNotice(noticeData: PluginNoticeData, pluginName?: stri
getDefaultMessage(noticeData.type, noticeData.data, baseNoticeData.pluginName),
actions: [{ text: '我知道了', type: 'confirm', primary: true }]
}
mainWindow.webContents.send('plugin-notice', infoNotice)
}
} catch (error: any) {

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

View File

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

View File

@@ -1,13 +1,31 @@
import { app, shell, BrowserWindow, ipcMain, Tray, Menu, screen } from 'electron'
import {
app,
shell,
BrowserWindow,
ipcMain,
screen,
Rectangle,
Display,
Tray,
Menu
} 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 lyricWindow from './windows/lyric-window'
import './events/musicCache'
import './events/songList'
import './events/directorySettings'
import './events/pluginNotice'
import initLyricIpc from './events/lyric'
import { initPluginNotice } from './events/pluginNotice'
import './events/localMusic'
// 获取单实例锁
const gotTheLock = app.requestSingleInstanceLock()
@@ -26,27 +44,34 @@ 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
let tray: Tray | null = null
let trayLyricLocked = false
function createTray(): void {
// 创建系统托盘
const trayIconPath = path.join(__dirname, '../../resources/logo.png')
tray = new Tray(trayIconPath)
function updateTrayMenu() {
const lyricWin = lyricWindow.getWin()
const isVisible = !!lyricWin && lyricWin.isVisible()
const toggleLyricLabel = isVisible ? '隐藏桌面歌词' : '显示桌面歌词'
const toggleLockLabel = trayLyricLocked ? '解锁桌面歌词' : '锁定桌面歌词'
// 创建托盘菜单
const contextMenu = Menu.buildFromTemplate([
{
label: '显示窗口',
label: toggleLyricLabel,
click: () => {
const target = !isVisible
ipcMain.emit('change-desktop-lyric', null, target)
}
},
{
label: toggleLockLabel,
click: () => {
const next = !trayLyricLocked
ipcMain.emit('toogleDesktopLyricLock', null, next)
}
},
{ type: 'separator' },
{
label: '显示主窗口',
click: () => {
if (mainWindow) {
mainWindow.show()
@@ -57,8 +82,6 @@ function createTray(): void {
{
label: '播放/暂停',
click: () => {
// 这里可以添加播放控制逻辑
console.log('music-control')
mainWindow?.webContents.send('music-control')
}
},
@@ -66,46 +89,102 @@ function createTray(): void {
{
label: '退出',
click: () => {
isQuitting = true
app.quit()
}
}
])
tray?.setContextMenu(contextMenu)
}
tray.setContextMenu(contextMenu)
function setupTray() {
// 全局单例防重复(热重载/多次执行保护)
const g: any = global as any
if (g.__ceru_tray__) {
try {
g.__ceru_tray__.destroy()
} catch {}
g.__ceru_tray__ = null
}
if (tray) {
try {
tray.destroy()
} catch {}
tray = null
}
const iconPath = path.join(__dirname, '../../resources/logo.ico')
tray = new Tray(iconPath)
tray.setToolTip('Ceru Music')
updateTrayMenu()
// 双击托盘图标显示窗口
// 左键单击切换主窗口显示
tray.on('click', () => {
if (mainWindow) {
if (mainWindow.isVisible()) {
mainWindow.hide()
} else {
mainWindow.show()
mainWindow.focus()
}
if (!mainWindow) return
if (mainWindow.isVisible()) {
mainWindow.hide()
} else {
mainWindow.show()
mainWindow.focus()
}
})
// 防重复注册 IPC 监听(仅注册一次)
if (!g.__ceru_tray_ipc_bound__) {
ipcMain.on('toogleDesktopLyricLock', (_e, isLock: boolean) => {
trayLyricLocked = !!isLock
updateTrayMenu()
})
ipcMain.on('change-desktop-lyric', () => {
updateTrayMenu()
})
g.__ceru_tray_ipc_bound__ = true
}
// 记录全局托盘句柄
g.__ceru_tray__ = tray
app.once('before-quit', () => {
try {
tray?.destroy()
} catch {}
tray = null
g.__ceru_tray__ = null
})
}
/**
* 根据窗口当前所在的显示器,动态更新窗口的最大尺寸限制。
* 这样可以确保窗口在任何显示器上都能正常最大化或全屏。
* @param {BrowserWindow} win - 要更新的窗口实例
*/
function updateWindowMaxLimits(win: BrowserWindow | null): void {
if (!win) return
// 1. 获取窗口的当前边界 (bounds)
const currentBounds: Rectangle = win.getBounds()
// 2. 查找包含该边界的显示器
const currentDisplay: Display = screen.getDisplayMatching(currentBounds)
// 3. 获取该显示器的完整尺寸 (full screen size)
const { width: currentScreenWidth, height: currentScreenHeight } = currentDisplay.size
// 4. 应用新的最大尺寸限制
// 移除 maxWidth/maxHeight 上的硬限制,使其能够最大化到当前屏幕的尺寸。
// 注意:设置为 0, 0 意味着没有最小限制,我们只关注最大限制。
win.setMaximumSize(currentScreenWidth, currentScreenHeight)
}
function createWindow(): void {
// return
// 获取保存的窗口位置和大小
const savedBounds = configManager.getWindowBounds()
// 获取屏幕尺寸
const primaryDisplay = screen.getPrimaryDisplay()
// 使用完整屏幕尺寸而不是工作区域,以支持真正的全屏模式
const { width: screenWidth, height: screenHeight } = primaryDisplay.size
// 默认窗口配置
const defaultOptions = {
width: 1100,
height: 750,
minWidth: 1100,
minHeight: 670,
maxWidth: screenWidth,
maxHeight: screenHeight,
show: false,
center: !savedBounds, // 如果有保存的位置,则不居中
autoHideMenuBar: true,
@@ -131,24 +210,30 @@ function createWindow(): void {
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()
// 获取当前屏幕尺寸
const { screen } = require('electron')
// 获取当前屏幕尺寸 (已在文件顶部导入 screen无需 require)
const currentDisplay = screen.getDisplayMatching(bounds)
// 使用 workAreaSize 避免窗口超出任务栏/Dock
const { width: screenWidth, height: screenHeight } = currentDisplay.workAreaSize
// 确保窗口不超过屏幕尺寸
// 确保窗口不超过屏幕工作区域尺寸
let needResize = false
const newBounds = { ...bounds }
@@ -174,29 +259,12 @@ function createWindow(): void {
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)
initPluginNotice(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']) {
@@ -206,84 +274,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-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 }
}
})
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
@@ -300,16 +290,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
@@ -317,63 +297,11 @@ 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()
lyricWindow.create()
initLyricIpc(mainWindow)
// 仅在主进程初始化一次托盘
setupTray()
// 注册自动更新事件
registerAutoUpdateEvents()
@@ -399,13 +327,7 @@ app.whenReady().then(() => {
// 当所有窗口关闭时不退出应用,因为我们有系统托盘
app.on('window-all-closed', () => {
// 在 macOS 上,应用通常会保持活跃状态
// 在其他平台上,我们也保持应用运行,因为有系统托盘
})
// 应用退出前的清理
app.on('before-quit', () => {
isQuitting = true
// 保持应用常驻,通过系统托盘管理
})
// In this file you can include the rest of your app's specific main process
@@ -413,57 +335,14 @@ app.on('before-quit', () => {
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)
}

47
src/main/logger/index.ts Normal file
View File

@@ -0,0 +1,47 @@
// 日志输出
import { existsSync, mkdirSync } from 'fs'
import { join } from 'path'
import { app } from 'electron'
import log from 'electron-log'
// 日志文件路径
const logDir = join(app.getPath('logs'))
// 是否存在日志目录
if (!existsSync(logDir)) mkdirSync(logDir)
// 获取日期 - YYYY-MM-DD
const dateString = new Date().toISOString().slice(0, 10)
const logFilePath = join(logDir, `${dateString}.log`)
console.log(logFilePath, '546444444444444444444444444444444444')
// 配置日志系统
log.transports.console.useStyles = true // 颜色输出
log.transports.file.level = 'info' // 仅记录 info 及以上级别
log.transports.file.resolvePathFn = (): string => logFilePath // 日志文件路径
log.transports.file.maxSize = 2 * 1024 * 1024 // 文件最大 2MB
// 日志格式化
// log.transports.file.format = "[{y}-{m}-{d} {h}:{i}:{s}] [{level}] [{scope}] {text}";
// 绑定默认事件
const defaultLog = log.scope('default')
console.log = defaultLog.log
console.info = defaultLog.info
console.warn = defaultLog.warn
console.error = defaultLog.error
// 分作用域导出
export { defaultLog }
export const ipcLog = log.scope('ipc')
export const trayLog = log.scope('tray')
export const thumbarLog = log.scope('thumbar')
export const storeLog = log.scope('store')
export const updateLog = log.scope('update')
export const systemLog = log.scope('system')
export const configLog = log.scope('config')
export const windowsLog = log.scope('windows')
export const processLog = log.scope('process')
export const preloadLog = log.scope('preload')
export const rendererLog = log.scope('renderer')
export const shortcutLog = log.scope('shortcut')
export const serverLog = log.scope('server')

View File

@@ -60,6 +60,7 @@ export class ConfigManager {
// 设置配置项
public set<T>(key: string, value: T): void {
this.config[key] = value
this.saveConfig()
}
// 删除配置项

View File

@@ -0,0 +1,113 @@
import path from 'node:path'
import fs from 'fs'
import fsp from 'fs/promises'
import crypto from 'crypto'
// import { configManager } from './ConfigManager'
export interface MusicItem {
hash?: string
singer: string
name: string
albumName: string
albumId: number
source: string
interval: string
songmid: number | string
img: string
lrc: null | string
types: string[]
_types: Record<string, any>
typeUrl: Record<string, any>
url?: string
path?: string
}
type IndexSchema = {
songs: Record<string, MusicItem>
dirs: string[]
updatedAt: number
}
function md5(input: string) {
return crypto.createHash('md5').update(input).digest('hex')
}
export class LocalMusicIndexService {
private indexFile: string
private data: IndexSchema = { songs: {}, dirs: [], updatedAt: Date.now() }
constructor() {
const userData = require('electron').app.getPath('userData')
this.indexFile = path.join(userData, 'local-music-index.json')
this.load()
}
private load() {
try {
if (fs.existsSync(this.indexFile)) {
const raw = fs.readFileSync(this.indexFile, 'utf-8')
const obj = JSON.parse(raw)
if (obj && typeof obj === 'object') this.data = obj as IndexSchema
}
} catch {
this.data = { songs: {}, dirs: [], updatedAt: Date.now() }
}
}
private async save() {
try {
const dir = path.dirname(this.indexFile)
await fsp.mkdir(dir, { recursive: true })
await fsp.writeFile(this.indexFile, JSON.stringify(this.data, null, 2))
} catch {}
}
getDirs() {
return [...(this.data.dirs || [])]
}
async setDirs(dirs: string[]) {
this.data.dirs = Array.from(new Set(dirs.filter(Boolean)))
this.data.updatedAt = Date.now()
await this.save()
}
getAllSongs(): MusicItem[] {
return Object.values(this.data.songs)
}
getSongById(id: string | number): MusicItem | null {
const key = String(id)
return this.data.songs[key] || null
}
getUrlById(id: string | number): string | null {
const s = this.getSongById(id)
if (!s) return null
if (s.url && typeof s.url === 'string') return s.url
if (s.path && typeof s.path === 'string') return 'file://' + s.path
return null
}
async upsertSong(item: MusicItem) {
const key = String(item.songmid ?? md5(String(item.path || '')))
item.songmid = key
item.hash = md5(`${item.name}-${item.singer}-${item.source}`)
this.data.songs[key] = item
this.data.updatedAt = Date.now()
await this.save()
}
async upsertSongs(items: MusicItem[]) {
for (const it of items) {
const key = String(it.songmid ?? md5(String(it.path || '')))
it.songmid = key
it.hash = md5(`${it.name}-${it.singer}-${it.source}`)
this.data.songs[key] = it
}
this.data.updatedAt = Date.now()
await this.save()
}
}
export const localMusicIndexService = new LocalMusicIndexService()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,6 +39,7 @@ export interface GetMusicUrlArg {
pluginId: string
songInfo: MusicItem
quality: string
isCache?: boolean
}
export interface GetMusicPicArg {
@@ -100,3 +101,6 @@ export interface DownloadSingleSongArgs extends GetMusicUrlArg {
path?: string
tagWriteOptions?: TagWriteOptions
}
// 搜索联想结果的类型定义
export type TipSearchResult = string[]

View File

@@ -493,7 +493,7 @@ class CeruMusicPluginHost {
}, timeout)
try {
console.log(`${CONSTANTS.LOG_PREFIX} 发起请求: ${options.method || 'GET'} ${url}`)
// console.log(`${CONSTANTS.LOG_PREFIX} 发起请求: ${options.method || 'GET'} ${url}`)
const fetchOptions = {
method: 'GET',
@@ -504,7 +504,7 @@ class CeruMusicPluginHost {
const response = await fetch(url, fetchOptions)
clearTimeout(timeoutId)
console.log(`${CONSTANTS.LOG_PREFIX} 请求响应: ${response.status} ${response.statusText}`)
// console.log(`${CONSTANTS.LOG_PREFIX} 请求响应: ${response.status} ${response.statusText}`)
const body = await this._parseResponseBody(response)
const headers = this._extractHeaders(response)
@@ -515,11 +515,11 @@ class CeruMusicPluginHost {
headers
}
console.log(`${CONSTANTS.LOG_PREFIX} 请求完成:`, {
url,
status: response.status,
bodyType: typeof body
})
// console.log(`${CONSTANTS.LOG_PREFIX} 请求完成:`, {
// url,
// status: response.status,
// bodyType: typeof body
// })
return result
} catch (error: any) {

View File

@@ -175,7 +175,6 @@ export default class PlayListSongs {
if (!Array.isArray(songs) || songs.length === 0) {
return
}
// 验证和过滤有效歌曲
const validSongs = songs.filter(PlayListUtils.isValidSong)
if (validSongs.length === 0) {

View File

@@ -0,0 +1,331 @@
import { File, Picture, Id3v2Settings } from 'node-taglib-sharp'
import path from 'node:path'
import axios from 'axios'
import fs from 'fs'
import fsPromise from 'fs/promises'
import { configManager } from '../services/ConfigManager'
import { pipeline } from 'node:stream/promises'
import { fileURLToPath } from 'url'
const fileLock: Record<string, boolean> = {}
/**
* 转换LRC格式
* 将带有字符位置信息的LRC格式转换为标准的逐字时间戳格式
* @param lrcContent 原始LRC内容
* @returns 转换后的LRC内容
*/
function convertLrcFormat(lrcContent: string): string {
if (!lrcContent) return ''
const lines = lrcContent.split('\n')
const convertedLines: string[] = []
for (const line of lines) {
// 跳过空行
if (!line.trim()) {
convertedLines.push(line)
continue
}
// 检查是否是新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
const newFormatMatch = line.match(/^\[(\d+),(\d+)\](.*)$/)
if (newFormatMatch) {
const [, startTimeMs, , content] = newFormatMatch
const convertedLine = convertNewFormat(parseInt(startTimeMs), content)
convertedLines.push(convertedLine)
continue
}
// 检查是否是旧格式:[mm:ss.xxx]字符(偏移,持续时间)
const oldFormatMatch = line.match(/^\[(\d{2}:\d{2}\.\d{3})\](.*)$/)
if (oldFormatMatch) {
const [, timestamp, content] = oldFormatMatch
// 如果内容中没有位置信息,直接返回原行
if (!content.includes('(') || !content.includes(')')) {
convertedLines.push(line)
continue
}
const convertedLine = convertOldFormat(timestamp, content)
convertedLines.push(convertedLine)
continue
}
// 其他行直接保留
convertedLines.push(line)
}
return convertedLines.join('\n')
}
/**
* 将毫秒时间戳格式化为 mm:ss.xxx 格式
* @param timeMs 毫秒时间戳
* @returns 格式化的时间字符串
*/
function formatTimestamp(timeMs: number): string {
const minutes = Math.floor(timeMs / 60000)
const seconds = Math.floor((timeMs % 60000) / 1000)
const milliseconds = timeMs % 1000
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`
}
// 根据图片 URL 和 Content-Type 解析扩展名,默认返回 .jpg
function resolveCoverExt(imgUrl: string, contentType?: string): string {
const validExts = new Set(['.jpg', '.jpeg', '.png', '.webp', '.bmp'])
let urlExt: string | undefined
try {
const pathname = new URL(imgUrl).pathname
const i = pathname.lastIndexOf('.')
if (i !== -1) {
urlExt = pathname.substring(i).toLowerCase()
}
} catch {}
if (urlExt && validExts.has(urlExt)) {
return urlExt === '.jpeg' ? '.jpg' : urlExt
}
if (contentType) {
if (contentType.includes('image/png')) return '.png'
if (contentType.includes('image/webp')) return '.webp'
if (contentType.includes('image/jpeg') || contentType.includes('image/jpg')) return '.jpg'
if (contentType.includes('image/bmp')) return '.bmp'
}
return '.jpg'
}
/**
* 转换新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
*/
function convertNewFormat(baseTimeMs: number, content: string): string {
const baseTimestamp = formatTimestamp(baseTimeMs)
let convertedContent = `<${baseTimestamp}>`
// 匹配模式:(开始时间,字符持续时间,0)字符
const charPattern = /\((\d+),(\d+),(\d+)\)([^(]*?)(?=\(|$)/g
let match
let isFirstChar = true
while ((match = charPattern.exec(content)) !== null) {
const [, charStartMs, , , char] = match
const charTimeMs = parseInt(charStartMs)
const charTimestamp = formatTimestamp(charTimeMs)
if (isFirstChar) {
// 第一个字符直接添加
convertedContent += char.trim()
isFirstChar = false
} else {
convertedContent += `<${charTimestamp}>${char.trim()}`
}
}
return `[${baseTimestamp}]${convertedContent}`
}
/**
* 转换旧格式:[mm:ss.xxx]字符(偏移,持续时间)
*/
function convertOldFormat(timestamp: string, content: string): string {
// 解析基础时间戳(毫秒)
const [minutes, seconds] = timestamp.split(':')
const [sec, ms] = seconds.split('.')
const baseTimeMs = parseInt(minutes) * 60 * 1000 + parseInt(sec) * 1000 + parseInt(ms)
let convertedContent = `<${timestamp}>`
// 匹配所有字符(偏移,持续时间)的模式
const charPattern = /([^()]+)\((\d+),(\d+)\)/g
let match
let lastIndex = 0
let isFirstChar = true
while ((match = charPattern.exec(content)) !== null) {
const [fullMatch, char, offsetMs, _durationMs] = match
const charTimeMs = baseTimeMs + parseInt(offsetMs)
const charTimestamp = formatTimestamp(charTimeMs)
// 添加匹配前的普通文本
if (match.index > lastIndex) {
const beforeText = content.substring(lastIndex, match.index)
if (beforeText.trim()) {
convertedContent += beforeText
}
}
// 添加带时间戳的字符
if (isFirstChar) {
// 第一个字符直接添加,不需要额外的时间戳
convertedContent += char
isFirstChar = false
} else {
convertedContent += `<${charTimestamp}>${char}`
}
lastIndex = match.index + fullMatch.length
}
// 添加剩余的普通文本
if (lastIndex < content.length) {
const remainingText = content.substring(lastIndex)
if (remainingText.trim()) {
convertedContent += remainingText
}
}
return `[${timestamp}]${convertedContent}`
}
// 获取自定义下载目录
const getDownloadDirectory = (): string => {
// 使用配置管理服务获取下载目录
const directories = configManager.getDirectories()
return directories.downloadDir
}
// 从URL中提取文件扩展名如果没有则默认为mp3
const getFileExtension = (url: string): string => {
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname
const lastDotIndex = pathname.lastIndexOf('.')
if (lastDotIndex !== -1 && lastDotIndex < pathname.length - 1) {
const extension = pathname.substring(lastDotIndex + 1).toLowerCase()
// 验证是否为常见的音频格式
const validExtensions = ['mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'wma']
if (validExtensions.includes(extension)) {
return extension
}
}
} catch (error) {
console.warn('解析URL失败使用默认扩展名:', error)
}
return 'mp3' // 默认扩展名
}
export default async function download(
url: string,
songInfo: any,
tagWriteOptions: any
): Promise<any> {
const fileExtension = getFileExtension(url)
const downloadDir = getDownloadDirectory()
const songPath = path.join(
downloadDir,
`${songInfo.name}-${songInfo.singer}-${songInfo.source}.${fileExtension}`
.replace(/[/\\:*?"<>|]/g, '')
.replace(/^\.+/, '')
.replace(/\.+$/, '')
.trim()
)
if (fileLock[songPath]) {
throw new Error('歌曲正在下载中')
} else {
fileLock[songPath] = true
}
try {
if (fs.existsSync(songPath)) {
return {
message: '歌曲已存在'
}
}
await fsPromise.mkdir(path.dirname(songPath), { recursive: true })
if (url.startsWith('file://')) {
const filePath = fileURLToPath(url)
const readStream = fs.createReadStream(filePath)
const writeStream = fs.createWriteStream(songPath)
await pipeline(readStream, writeStream)
} else {
const songDataRes = await axios({
method: 'GET',
url: url,
responseType: 'stream'
})
await pipeline(songDataRes.data, fs.createWriteStream(songPath))
}
} finally {
delete fileLock[songPath]
}
// 写入标签信息(使用 node-taglib-sharp
if (tagWriteOptions && fs.existsSync(songPath)) {
try {
const baseName = path.basename(songPath, path.extname(songPath))
const dirName = path.dirname(songPath)
let coverExt = '.jpg'
let coverPath = path.join(dirName, `${baseName}${coverExt}`)
let coverDownloaded = false
// 下载封面仅当启用且有URL
if (tagWriteOptions.cover && songInfo?.img) {
try {
const coverRes = await axios.get(songInfo.img, {
responseType: 'arraybuffer',
timeout: 10000
})
const ct =
(coverRes.headers && (coverRes.headers['content-type'] as string | undefined)) ||
undefined
coverExt = resolveCoverExt(songInfo.img, ct)
coverPath = path.join(dirName, `${baseName}${coverExt}`)
await fsPromise.writeFile(coverPath, Buffer.from(coverRes.data))
coverDownloaded = true
} catch (e) {
console.warn('下载封面失败:', e instanceof Error ? e.message : e)
}
}
// 读取歌曲文件并设置标签
const songFile = File.createFromPath(songPath)
// 使用默认 ID3v2.3
Id3v2Settings.forceDefaultVersion = true
Id3v2Settings.defaultVersion = 3
songFile.tag.title = songInfo?.name || '未知曲目'
songFile.tag.album = songInfo?.albumName || '未知专辑'
const artists = songInfo?.singer ? [songInfo.singer] : ['未知艺术家']
songFile.tag.performers = artists
songFile.tag.albumArtists = artists
// 写入歌词(转换为标准 LRC
if (tagWriteOptions.lyrics && songInfo?.lrc) {
const convertedLrc = convertLrcFormat(songInfo.lrc)
songFile.tag.lyrics = convertedLrc
}
// 写入封面
if (tagWriteOptions.cover && coverDownloaded) {
const songCover = Picture.fromPath(coverPath)
songFile.tag.pictures = [songCover]
}
// 保存并释放
songFile.save()
songFile.dispose()
// 删除临时封面
if (coverDownloaded) {
try {
await fsPromise.unlink(coverPath)
} catch {}
}
} catch (error) {
console.warn('写入音乐元信息失败:', error)
}
}
return {
message: '下载成功',
path: songPath
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { httpFetch } from '../../request'
import { dateFormat, formatPlayCount } from '../index'
import { dateFormat, formatPlayCount } from '../../index'
import { filterMusicInfoList } from './musicInfo'
import { createSignature } from './musicSearch'
import { createHttpFetch } from './utils/index'
@@ -17,14 +17,14 @@ export default {
sortList: [
{
name: '推荐',
id: '15127315'
id: '15127315',
// id: '1',
},
{
name: '最新',
id: '15127272'
id: '15127272',
// id: '2',
}
},
],
regExps: {
list: /<li><div class="thumb">.+?<\/li>/g,
@@ -32,7 +32,7 @@ export default {
/.+data-original="(.+?)".*data-id="(\d+)".*<div class="song-list-name"><a\s.*?>(.+?)<\/a>.+<i class="iconfont cf-bofangliang"><\/i>(.+?)<\/div>/,
// https://music.migu.cn/v3/music/playlist/161044573?page=1
listDetailLink: /^.+\/playlist\/(\d+)(?:\?.*|&.*$|#.*$|$)/
listDetailLink: /^.+\/playlist\/(\d+)(?:\?.*|&.*$|#.*$|$)/,
},
tagsUrl: 'https://app.c.nf.migu.cn/MIGUM3.0/v1.0/template/musiclistplaza-taglist/release',
// tagsUrl: 'https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/indexTagPage.do?needAll=0',
@@ -43,6 +43,7 @@ export default {
// : `https://music.migu.cn/v3/music/playlist?sort=${sortId}&page=${page}&from=migu`
// }
// return `https://music.migu.cn/v3/music/playlist?tagId=${tagId}&page=${page}&from=migu`
// https://app.c.nf.migu.cn/pc/v1.0/template/musiclistplaza-listbytag/release?pageNumber=1&templateVersion=2&tagId=1003449727
if (tagId == null) {
// return `https://app.c.nf.migu.cn/MIGUM2.0/v2.0/content/getMusicData.do?count=${this.limit_list}&start=${page}&templateVersion=5&type=1`
// return `https://c.musicapp.migu.cn/MIGUM2.0/v2.0/content/getMusicData.do?count=${this.limit_list}&start=${page}&templateVersion=5&type=${sortId}`
@@ -58,7 +59,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 +75,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 +86,7 @@ export default {
page,
limit: this.limit_song,
total: body.totalCount,
source: 'mg'
source: 'mg',
}
})
},
@@ -97,7 +98,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 +110,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 +123,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 +154,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 +166,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 +206,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 +234,7 @@ export default {
grade: item.grade,
total: item.contentCount,
desc: item.summary,
source: 'mg'
source: 'mg',
}))
},
@@ -254,7 +255,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 +264,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 +314,7 @@ export default {
name: item.name,
img: item.musicListPicUrl,
total: item.musicNum,
source: 'mg'
source: 'mg',
})
})
return list
@@ -331,8 +332,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 +343,10 @@ export default {
list,
limit,
total: parseInt(body.songListResultData.totalCount),
source: 'mg'
source: 'mg',
}
})
}
},
}
// getList

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { httpFetch } from '../../request'
import { formatPlayTime, sizeFormate } from '../index'
import { formatPlayTime, sizeFormate } from '../../index'
import { formatSingerName } from '../utils'
export default {
@@ -15,32 +15,56 @@ 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: {
ct: 11,
cv: '1003006',
v: '1003006',
ct: '11',
cv: '14090508',
v: '14090508',
tmeAppID: 'qqmusic',
phonetype: 'EBG-AN10',
deviceScore: '553.47',
devicelevel: '50',
newdevicelevel: '20',
rom: 'HuaWei/EMOTION/EmotionUI_14.2.0',
os_ver: '12',
phonetype: '0',
devicelevel: '31',
tmeAppID: 'qqmusiclight',
nettype: 'NETWORK_WIFI'
OpenUDID: '0',
OpenUDID2: '0',
QIMEI36: '0',
udid: '0',
chid: '0',
aid: '0',
oaid: '0',
taid: '0',
tid: '0',
wid: '0',
uid: '0',
sid: '0',
modeSwitch: '6',
teenMode: '0',
ui_mode: '2',
nettype: '1020',
v4ip: '',
},
req: {
module: 'music.search.SearchCgiService',
method: 'DoSearchForQQMusicLite',
method: 'DoSearchForQQMusicMobile',
param: {
query: str,
search_type: 0,
num_per_page: limit,
query: str,
page_num: page,
num_per_page: limit,
highlight: 0,
nqc_flag: 0,
grp: 1
}
}
}
multi_zhida: 0,
cat: 2,
grp: 1,
sin: 0,
sem: 0,
},
},
},
})
// 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 +80,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 +158,7 @@ export default {
: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${albumId}.jpg`,
types,
_types,
typeUrl: {}
typeUrl: {},
})
})
// console.log(list)
@@ -123,7 +168,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 +179,8 @@ export default {
allPage: this.allPage,
limit,
total: this.total,
source: 'tx'
source: 'tx',
})
})
}
},
}

View File

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

View File

@@ -1,6 +1,7 @@
import { httpFetch } from '../../request'
import { decodeName, formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index'
import { decodeName, formatPlayTime, dateFormat, formatPlayCount } from '../../index'
import { formatSingerName } from '../utils'
import { getBatchMusicQualityInfo } from './quality_detail'
export default {
_requestObj_tags: null,
@@ -12,12 +13,12 @@ export default {
sortList: [
{
name: '最热',
id: 5
id: 5,
},
{
name: '最新',
id: 2
}
id: 2,
},
],
regExps: {
hotTagHtml: /class="c_bg_link js_tag_item" data-id="\w+">.+?<\/a>/g,
@@ -26,7 +27,7 @@ export default {
// https://y.qq.com/n/yqq/playlist/7217720898.html
// https://i.y.qq.com/n2/m/share/details/taoge.html?platform=11&appshare=android_qq&appversion=9050006&id=7217720898&ADTAG=qfshare
listDetailLink: /\/playlist\/(\d+)/,
listDetailLink2: /id=(\d+)/
listDetailLink2: /id=(\d+)/,
},
tagsUrl:
'https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8&notice=0&platform=wk_v15.json&needNewCode=0&data=%7B%22tags%22%3A%7B%22method%22%3A%22get_all_categories%22%2C%22param%22%3A%7B%22qq%22%3A%22%22%7D%2C%22module%22%3A%22playlist.PlaylistAllCategoriesServer%22%7D%7D',
@@ -45,10 +46,10 @@ export default {
category_id: id,
size: this.limit_list,
page: page - 1,
use_page: 1
use_page: 1,
},
module: 'playlist.PlayListCategoryServer'
}
module: 'playlist.PlayListCategoryServer',
},
})
)}`
}
@@ -62,10 +63,10 @@ export default {
sin: this.limit_list * (page - 1),
size: this.limit_list,
order: sortId,
cur_page: page
cur_page: page,
},
module: 'playlist.PlayListPlazaServer'
}
module: 'playlist.PlayListPlazaServer',
},
})
)}`
},
@@ -95,17 +96,17 @@ export default {
})
},
filterInfoHotTag(html) {
const hotTag = html.match(this.regExps.hotTagHtml)
let hotTag = html.match(this.regExps.hotTagHtml)
const hotTags = []
if (!hotTag) return hotTags
hotTag.forEach((tagHtml) => {
const result = tagHtml.match(this.regExps.hotTag)
let result = tagHtml.match(this.regExps.hotTag)
if (!result) return
hotTags.push({
id: parseInt(result[1]),
name: result[2],
source: 'tx'
source: 'tx',
})
})
return hotTags
@@ -118,8 +119,8 @@ export default {
parent_name: type.group_name,
id: item.id,
name: item.name,
source: 'tx'
}))
source: 'tx',
})),
}))
},
@@ -130,7 +131,9 @@ export default {
this._requestObj_list = httpFetch(this.getListUrl(sortId, tagId, page))
// console.log(this.getListUrl(sortId, tagId, page))
return this._requestObj_list.promise.then(({ body }) => {
if (body.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum)
if (body.code !== this.successCode) {
return this.getList(sortId, tagId, page, ++tryNum)
}
return tagId
? this.filterList2(body.playlist.data, page)
: this.filterList(body.playlist.data, page)
@@ -149,12 +152,12 @@ export default {
// grade: item.favorcnt / 10,
total: item.song_ids?.length,
desc: decodeName(item.desc).replace(/<br>/g, '\n'),
source: 'tx'
source: 'tx',
})),
total: data.total,
page,
limit: this.limit_list,
source: 'tx'
source: 'tx',
}
},
filterList2({ content }, page) {
@@ -169,12 +172,12 @@ export default {
img: basic.cover.medium_url || basic.cover.default_url,
// grade: basic.favorcnt / 10,
desc: decodeName(basic.desc).replace(/<br>/g, '\n'),
source: 'tx'
source: 'tx',
})),
total: content.total_cnt,
page,
limit: this.limit_list,
source: 'tx'
source: 'tx',
}
},
@@ -184,7 +187,7 @@ export default {
const requestObj_listDetailLink = httpFetch(link)
const {
headers: { location },
statusCode
statusCode,
} = await requestObj_listDetailLink.promise
// console.log(headers)
if (statusCode > 400) return this.handleParseId(link, ++retryNum)
@@ -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,17 +321,17 @@ export default {
// grade: item.favorcnt / 10,
total: item.song_count,
desc: decodeName(decodeName(item.introduction)).replace(/<br>/g, '\n'),
source: 'tx'
source: 'tx',
}
}),
limit,
total: body.data.sum,
source: 'tx'
source: 'tx',
}
})
}
},
}
// getList
// getTags
// getListDetail
// getListDetail

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

45
src/main/windows/index.ts Normal file
View File

@@ -0,0 +1,45 @@
import { BrowserWindow, BrowserWindowConstructorOptions, app } from 'electron'
import { windowsLog } from '../logger'
import { join } from 'path'
import icon from '../../../resources/logo.png?asset'
export const createWindow = (
options: BrowserWindowConstructorOptions = {}
): BrowserWindow | null => {
try {
const defaultOptions: BrowserWindowConstructorOptions = {
title: app.getName(),
width: 1280,
height: 720,
frame: false, // 创建后是否显示窗口
center: true, // 窗口居中
icon, // 窗口图标
autoHideMenuBar: true, // 隐藏菜单栏
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
// 禁用渲染器沙盒
sandbox: false,
// 禁用同源策略
webSecurity: false,
// 允许 HTTP
allowRunningInsecureContent: true,
// 禁用拼写检查
spellcheck: false,
// 启用 Node.js
nodeIntegration: true,
nodeIntegrationInWorker: true,
// 关闭上下文隔离,确保在窗口中注入 window.electron
contextIsolation: false,
backgroundThrottling: false
}
}
// 合并参数
options = Object.assign(defaultOptions, options)
// 创建窗口
const win = new BrowserWindow(options)
return win
} catch (error) {
windowsLog.error(error)
return null
}
}

View File

@@ -0,0 +1,95 @@
import { BrowserWindow, screen } from 'electron'
import { createWindow } from './index'
import { configManager } from '../services/ConfigManager'
import { join } from 'path'
import { lyricConfig } from '@common/types/config'
const lyricStore = {
get: () =>
configManager.get<lyricConfig>('lyric', {
fontSize: 30,
mainColor: '#73BCFC',
shadowColor: 'rgba(255, 255, 255, 0.5)',
x: screen.getPrimaryDisplay().workAreaSize.width / 2 - 400,
y: screen.getPrimaryDisplay().workAreaSize.height - 90,
width: 800,
height: 180
}),
set: (value: lyricConfig) => configManager.set<lyricConfig>('lyric', value)
}
class LyricWindow {
private win: BrowserWindow | null = null
constructor() {}
/**
* 主窗口事件
* @returns void
*/
private event(): void {
if (!this.win) return
// 歌词窗口缩放
this.win?.on('resized', () => {
const bounds = this.win?.getBounds()
if (bounds) {
const { width, height } = bounds
console.log('歌词窗口缩放:', width, height)
lyricStore.set({
...lyricStore.get(),
width,
height
})
}
})
this.win?.on('closed', () => {
this.win = null
})
}
/**
* 创建主窗口
* @returns BrowserWindow | null
*/
create(): BrowserWindow | null {
const { width, height, x, y } = lyricStore.get()
this.win = createWindow({
width: width || 800,
height: height || 180,
minWidth: 440,
minHeight: 120,
maxWidth: 1600,
maxHeight: 300,
show: false,
// 窗口位置
x,
y,
transparent: true,
backgroundColor: 'rgba(0, 0, 0, 0)',
alwaysOnTop: true,
resizable: true,
movable: true,
// 不在任务栏显示
skipTaskbar: true,
// 窗口不能最小化
minimizable: false,
// 窗口不能最大化
maximizable: false,
// 窗口不能进入全屏状态
fullscreenable: false
})
if (!this.win) return null
// 加载地址(开发环境用项目根目录,生产用打包后的相对路径)
this.win.loadFile(join(__dirname, '../main/src/web/lyric.html'))
// 窗口事件
this.event()
return this.win
}
/**
* 获取窗口
* @returns BrowserWindow | null
*/
getWin(): BrowserWindow | null {
return this.win
}
}
export default new LyricWindow()

View File

@@ -11,7 +11,6 @@ interface CustomAPI {
onMusicCtrl: (callback: (event: Event, args: any) => void) => () => void
music: {
request: (api: string, args: any) => Promise<any>
requestSdk: <T extends keyof MainApi>(
method: T,
args: {
@@ -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: {
@@ -125,6 +126,16 @@ interface CustomAPI {
pluginNotice: {
onPluginNotice: (listener: (...args: any[]) => void) => () => void
}
localMusic: {
selectDirs: () => Promise<string[]>
scan: (dirs: string[]) => Promise<any[]>
writeTags: (
filePath: string,
songInfo: any,
tagWriteOptions: any
) => Promise<{ success: boolean; message?: string }>
}
}
declare global {

View File

@@ -8,6 +8,11 @@ const api = {
console.log('preload: 发送 window-minimize 事件')
ipcRenderer.send('window-minimize')
},
// 阻止系统息屏
powerSaveBlocker: {
start: () => ipcRenderer.invoke('power-save-blocker:start'),
stop: () => ipcRenderer.invoke('power-save-blocker:stop')
},
maximize: () => {
console.log('preload: 发送 window-maximize 事件')
ipcRenderer.send('window-maximize')
@@ -29,7 +34,6 @@ const api = {
},
// 音乐相关方法
music: {
request: (api: string, args: any) => ipcRenderer.invoke('service-music-request', api, args),
requestSdk: (api: string, args: any) =>
ipcRenderer.invoke('service-music-sdk-request', api, args)
},
@@ -112,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'),
@@ -182,6 +190,29 @@ const api = {
ipcRenderer.invoke('directory-settings:get-directory-size', dirPath)
},
// 本地音乐管理
localMusic: {
selectDirs: () => ipcRenderer.invoke('local-music:select-dirs'),
scan: async (dirs: string[]) => {
const res = await ipcRenderer.invoke('local-music:scan', dirs)
if (typeof res === 'string') {
try {
return JSON.parse(res)
} catch {
return []
}
}
return Array.isArray(res) ? res : []
},
writeTags: (filePath: string, songInfo: any, tagWriteOptions: any) =>
ipcRenderer.invoke('local-music:write-tags', { filePath, songInfo, tagWriteOptions }),
getDirs: () => ipcRenderer.invoke('local-music:get-dirs'),
setDirs: (dirs: string[]) => ipcRenderer.invoke('local-music:set-dirs', dirs),
getList: () => ipcRenderer.invoke('local-music:get-list'),
getUrlById: (id: string | number) => ipcRenderer.invoke('local-music:get-url', id),
clearIndex: () => ipcRenderer.invoke('local-music:clear-index')
},
// 插件通知相关
pluginNotice: {
onPluginNotice(callback: (data: string) => any) {
@@ -199,14 +230,14 @@ const api = {
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('electron', { ...electronAPI, ipcRenderer })
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
}
} else {
// @ts-ignore (define in dts)
window.electron = electronAPI
window.electron = { ...electronAPI, ipcRenderer }
// @ts-ignore (define in dts)
window.api = api
}

View File

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

View File

@@ -12,12 +12,24 @@ declare module 'vue' {
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']
DesktopLyricStyle: typeof import('./src/components/Settings/DesktopLyricStyle.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']
NButton: typeof import('naive-ui')['NButton']
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
NCard: typeof import('naive-ui')['NCard']
NDataTable: typeof import('naive-ui')['NDataTable']
NDropdown: typeof import('naive-ui')['NDropdown']
NIcon: typeof import('naive-ui')['NIcon']
NModal: typeof import('naive-ui')['NModal']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSpin: typeof import('naive-ui')['NSpin']
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']
@@ -26,37 +38,34 @@ declare module 'vue' {
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']
TBadge: typeof import('tdesign-vue-next')['Badge']
TButton: typeof import('tdesign-vue-next')['Button']
TCard: typeof import('tdesign-vue-next')['Card']
TCheckbox: typeof import('tdesign-vue-next')['Checkbox']
TColorPicker: typeof import('tdesign-vue-next')['ColorPicker']
TContent: typeof import('tdesign-vue-next')['Content']
TDialog: typeof import('tdesign-vue-next')['Dialog']
TDivider: typeof import('tdesign-vue-next')['Divider']
TDrawer: typeof import('tdesign-vue-next')['Drawer']
TDropdown: typeof import('tdesign-vue-next')['Dropdown']
TEmpty: typeof import('tdesign-vue-next')['Empty']
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']
TInputNumber: typeof import('tdesign-vue-next')['InputNumber']
TitleBarControls: typeof import('./src/components/TitleBarControls.vue')['default']
TLayout: typeof import('tdesign-vue-next')['Layout']
TLoading: typeof import('tdesign-vue-next')['Loading']
TOption: typeof import('tdesign-vue-next')['Option']
TRadioButton: typeof import('tdesign-vue-next')['RadioButton']
TRadioGroup: typeof import('tdesign-vue-next')['RadioGroup']
TSelect: typeof import('tdesign-vue-next')['Select']
TSlider: typeof import('tdesign-vue-next')['Slider']
TSwitch: typeof import('tdesign-vue-next')['Switch']
TTabPanel: typeof import('tdesign-vue-next')['TabPanel']
TTabs: typeof import('tdesign-vue-next')['Tabs']
TTag: typeof import('tdesign-vue-next')['Tag']
TTextarea: typeof import('tdesign-vue-next')['Textarea']
TTooltip: typeof import('tdesign-vue-next')['Tooltip']

View File

@@ -10,9 +10,10 @@
-->
<script setup lang="ts">
import { onMounted } from 'vue'
import { onMounted, ref } from 'vue'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { useAutoUpdate } from './composables/useAutoUpdate'
import { NConfigProvider, darkTheme, NGlobalStyle } from 'naive-ui'
const userInfo = LocalUserDetailStore()
const { checkForUpdates } = useAutoUpdate()
@@ -27,6 +28,27 @@ onMounted(() => {
userInfo.init()
setupSystemThemeListener()
loadSavedTheme()
syncNaiveTheme()
window.addEventListener('theme-changed', () => syncNaiveTheme())
// 全局键盘/托盘播放控制安装(解耦出组件)
import('@renderer/utils/audio/globalControls')
.then((m) => m.installGlobalMusicControls())
.catch(() => {})
import('@renderer/utils/audio/globaPlayList').then((m) => m.initPlayback?.()).catch(() => {})
import('@renderer/utils/lyrics/desktopLyricBridge')
.then((m) => m.installDesktopLyricBridge())
.catch(() => {})
// 全局监听来自主进程的播放控制事件,确保路由切换也可响应
const forward = (name: string, val?: any) => {
window.dispatchEvent(new CustomEvent('global-music-control', { detail: { name, val } }))
}
window.electron?.ipcRenderer?.on?.('play', () => forward('play'))
window.electron?.ipcRenderer?.on?.('pause', () => forward('pause'))
window.electron?.ipcRenderer?.on?.('toggle', () => forward('toggle'))
window.electron?.ipcRenderer?.on?.('playPrev', () => forward('playPrev'))
window.electron?.ipcRenderer?.on?.('playNext', () => forward('playNext'))
// 应用启动后延迟3秒检查更新避免影响启动速度
setTimeout(() => {
@@ -43,6 +65,31 @@ const themes = [
{ name: 'orange', label: '橙色', color: '#fb9458' }
]
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 loadSavedTheme = () => {
const savedTheme = localStorage.getItem('selected-theme')
const savedDarkMode = localStorage.getItem('dark-mode')
@@ -86,6 +133,9 @@ const applyTheme = (themeName, darkMode = false) => {
// 保存到本地存储
localStorage.setItem('selected-theme', themeName)
localStorage.setItem('dark-mode', darkMode.toString())
// 同步 Naive UI 主题
syncNaiveTheme()
}
// 检测系统主题偏好
@@ -113,20 +163,23 @@ const setupSystemThemeListener = () => {
</script>
<template>
<div class="page">
<router-view v-slot="{ Component }">
<Transition
:enter-active-class="`animate__animated animate__fadeIn pagesApp`"
:leave-active-class="`animate__animated animate__fadeOut pagesApp`"
>
<component :is="Component" />
</Transition>
</router-view>
<GlobalAudio />
<FloatBall />
<PluginNoticeDialog />
<UpdateProgress />
</div>
<NConfigProvider :theme="naiveTheme" :theme-overrides="themeOverrides">
<NGlobalStyle />
<div class="page">
<router-view v-slot="{ Component }">
<Transition
:enter-active-class="`animate__animated animate__fadeIn pagesApp`"
:leave-active-class="`animate__animated animate__fadeOut pagesApp`"
>
<component :is="Component" />
</Transition>
</router-view>
<GlobalAudio />
<FloatBall />
<PluginNoticeDialog />
<UpdateProgress />
</div>
</NConfigProvider>
</template>
<style>
.pagesApp {

View File

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

After

Width:  |  Height:  |  Size: 251 B

View File

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

After

Width:  |  Height:  |  Size: 266 B

View File

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

After

Width:  |  Height:  |  Size: 459 B

View File

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

After

Width:  |  Height:  |  Size: 543 B

View File

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

After

Width:  |  Height:  |  Size: 459 B

View File

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

After

Width:  |  Height:  |  Size: 583 B

View File

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

After

Width:  |  Height:  |  Size: 478 B

View File

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

After

Width:  |  Height:  |  Size: 393 B

View File

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

After

Width:  |  Height:  |  Size: 489 B

View File

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

After

Width:  |  Height:  |  Size: 269 B

View File

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

After

Width:  |  Height:  |  Size: 575 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M8.7 15.9L4.8 12l3.9-3.9a.984.984 0 0 0 0-1.4a.984.984 0 0 0-1.4 0l-4.59 4.59a.996.996 0 0 0 0 1.41l4.59 4.6c.39.39 1.01.39 1.4 0a.984.984 0 0 0 0-1.4m6.6 0l3.9-3.9l-3.9-3.9a.984.984 0 0 1 0-1.4a.984.984 0 0 1 1.4 0l4.59 4.59c.39.39.39 1.02 0 1.41l-4.59 4.6a.984.984 0 0 1-1.4 0a.984.984 0 0 1 0-1.4"/></svg>

After

Width:  |  Height:  |  Size: 420 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V7q0-.425.288-.712T4 6t.713.288T5 7v13h10q.425 0 .713.288T16 21t-.288.713T15 22z"/></svg>

After

Width:  |  Height:  |  Size: 334 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M11.01 3.05C6.51 3.54 3 7.36 3 12a9 9 0 0 0 9 9c4.63 0 8.45-3.5 8.95-8c.09-.79-.78-1.42-1.54-.95A5.403 5.403 0 0 1 11.1 7.5c0-1.06.31-2.06.84-2.89c.45-.67-.04-1.63-.93-1.56"/></svg>

After

Width:  |  Height:  |  Size: 293 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6zM19 4h-3.5l-1-1h-5l-1 1H5v2h14z"/></svg>

After

Width:  |  Height:  |  Size: 193 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M5 19q-.825 0-1.412-.587T3 17V8q-.425 0-.712-.288T2 7t.288-.712T3 6h3v-.5q0-.425.288-.712T7 4.5h2q.425 0 .713.288T10 5.5V6h3q.425 0 .713.288T14 7t-.288.713T13 8v9q0 .825-.587 1.413T11 19zm11-1q-.425 0-.712-.288T15 17t.288-.712T16 16h2q.425 0 .713.288T19 17t-.288.713T18 18zm0-4q-.425 0-.712-.288T15 13t.288-.712T16 12h4q.425 0 .713.288T21 13t-.288.713T20 14zm0-4q-.425 0-.712-.288T15 9t.288-.712T16 8h5q.425 0 .713.288T22 9t-.288.713T21 10z"/></svg>

After

Width:  |  Height:  |  Size: 561 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M7 14h2q.425 0 .713-.288T10 13t-.288-.712T9 12H7q-.425 0-.712.288T6 13t.288.713T7 14m12-2q-1.25 0-2.125-.875T16 9t.875-2.125T19 6q.275 0 .525.05t.475.125V2q0-.425.288-.712T21 1h2q.425 0 .713.288T24 2t-.288.713T23 3h-1v6q0 1.25-.875 2.125T19 12M7 11h5q.425 0 .713-.288T13 10t-.288-.712T12 9H7q-.425 0-.712.288T6 10t.288.713T7 11m0-3h5q.425 0 .713-.288T13 7t-.288-.712T12 6H7q-.425 0-.712.288T6 7t.288.713T7 8M6 18l-2.3 2.3q-.475.475-1.088.213T2 19.575V4q0-.825.588-1.412T4 2h11q.775 0 1.363.475T16.95 3.7q0 .35-.162.625t-.438.45q-1.1.675-1.725 1.8T14 9q0 1.35.663 2.5t1.837 1.825q.55.325.875.863t.325 1.187q0 1.125-.788 1.875T15 18z"/></svg>

After

Width:  |  Height:  |  Size: 752 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" viewBox="0 0 24 24" focusable="false" aria-hidden="true"><g><g><path d="M5.9999978125,4.0176934375L18.0000078125,4.0176934375C18.4970078125,4.0176934375,18.9000078125,3.6147534375,18.9000078125,3.1176944375C18.9000078125,2.6206384375,18.4970078125,2.2176944613,18.0000078125,2.2176944613L5.9999978125,2.2176944613C5.5029478125,2.2176944613,5.0999978125,2.6206384375,5.0999978125,3.1176944375C5.0999978125,3.6147534375,5.5029478125,4.0176934375,5.9999978125,4.0176934375ZM3.1960448125,16.8951734375L2.8022507125,11.1750534375Q2.6749484125,9.3258934375,2.7196047125,8.6725834375Q2.8020806125,7.4659834375,3.4361238125,6.7867934375Q4.0701678125,6.1076034375,5.2682578125,5.9424434375Q5.9169578125,5.8530234375,7.7704578125,5.8530234375L16.2294078125,5.8530234375Q18.0829078125,5.8530234375,18.7316078125,5.9424434375Q19.9297078125,6.1076034375,20.5638078125,6.7867934375Q21.1978078125,7.4659934375,21.2803078125,8.6725834375Q21.3249078125,9.3259034375,21.1976078125,11.1750234375L20.8039078125,16.8951734375Q20.6913078125,18.5299734375,20.5753078125,19.1062734375Q20.3615078125,20.1678734375,19.7462078125,20.7422734375Q19.1309078125,21.3166734375,18.0572078125,21.4569734375Q17.474207812499998,21.5331734375,15.8356078125,21.5331734375L8.1642578125,21.5331734375Q6.5256978125,21.5331734375,5.9427178125,21.4569734375Q4.8689478125,21.3166734375,4.2536678125,20.7422734375Q3.6383718125,20.1678734375,3.4246308125000002,19.1062734375Q3.3085848125,18.5299734375,3.1960448125,16.8951734375ZM14.6375078125,8.530113437499999C14.7011078125,8.5104734375,14.7673078125,8.5004834375,14.8339078125,8.5004834375C15.2018078125,8.5004834375,15.4999078125,8.7986734375,15.4999078125,9.1665134375L15.4999078125,15.5220734375L15.4819078125,15.5220734375C15.4827078125,15.5418734375,15.4831078125,15.5618734375,15.4831078125,15.5818734375C15.4831078125,16.379873437500002,14.8362078125,17.0268734375,14.0382078125,17.0268734375C13.2402078125,17.0268734375,12.5932578125,16.379873437500002,12.5932578125,15.5818734375C12.5932578125,14.7838734375,13.2402078125,14.1369734375,14.0382078125,14.1369734375C14.2953078125,14.1369734375,14.5367078125,14.2040734375,14.7459078125,14.3218734375L14.7459078125,11.0771534375L10.3793178125,12.4171734375L10.3793178125,16.8837734375C10.386277812500001,16.9412734375,10.3898678125,16.9997734375,10.3898678125,17.0591734375C10.3898678125,17.857173437500002,9.742947812499999,18.5041734375,8.9449278125,18.5041734375C8.1469178125,18.5041734375,7.4999978125,17.857173437500002,7.4999978125,17.0591734375C7.4999978125,16.2611734375,8.1469178125,15.6142734375,8.9449278125,15.6142734375C9.190897812500001,15.6142734375,9.422507812500001,15.6756734375,9.6252478125,15.7840734375L9.6252478125,10.5687534375C9.6252478125,10.2765934375,9.8156578125,10.0185334375,10.0948278125,9.932363437500001L14.6375078125,8.530113437499999Z" fill-rule="evenodd" fill="currentColor" fill-opacity="1"></path></g></g></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none" fill-rule="evenodd"><path d="M24 0v24H0V0zM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"/><path fill="currentColor" d="M13.06 16.06a1.5 1.5 0 0 1-2.12 0l-5.658-5.656a1.5 1.5 0 1 1 2.122-2.121L12 12.879l4.596-4.596a1.5 1.5 0 0 1 2.122 2.12l-5.657 5.658Z"/></g></svg>

After

Width:  |  Height:  |  Size: 848 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M16.59 9H15V4c0-.55-.45-1-1-1h-4c-.55 0-1 .45-1 1v5H7.41c-.89 0-1.34 1.08-.71 1.71l4.59 4.59c.39.39 1.02.39 1.41 0l4.59-4.59c.63-.63.19-1.71-.7-1.71M5 19c0 .55.45 1 1 1h12c.55 0 1-.45 1-1s-.45-1-1-1H6c-.55 0-1 .45-1 1"/></svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="m11.565 13.873l-2.677-2.677q-.055-.055-.093-.129q-.037-.073-.037-.157q0-.168.11-.289q.112-.121.294-.121h5.677q.181 0 .292.124t.111.288q0 .042-.13.284l-2.677 2.677q-.093.093-.2.143t-.235.05t-.235-.05t-.2-.143"/></svg>

After

Width:  |  Height:  |  Size: 328 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M17.9 17.39c-.26-.8-1.01-1.39-1.9-1.39h-1v-3a1 1 0 0 0-1-1H8v-2h2a1 1 0 0 0 1-1V7h2a2 2 0 0 0 2-2v-.41a7.984 7.984 0 0 1 2.9 12.8M11 19.93c-3.95-.49-7-3.85-7-7.93c0-.62.08-1.22.21-1.79L9 15v1a2 2 0 0 0 2 2m1-16A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2"/></svg>

After

Width:  |  Height:  |  Size: 401 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M14 11c0 .55-.45 1-1 1H4c-.55 0-1-.45-1-1s.45-1 1-1h9c.55 0 1 .45 1 1M3 7c0 .55.45 1 1 1h9c.55 0 1-.45 1-1s-.45-1-1-1H4c-.55 0-1 .45-1 1m7 8c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1s.45 1 1 1h5c.55 0 1-.45 1-1m8.01-2.13l.71-.71a.996.996 0 0 1 1.41 0l.71.71c.39.39.39 1.02 0 1.41l-.71.71zm-.71.71l-5.16 5.16c-.09.09-.14.21-.14.35v1.41c0 .28.22.5.5.5h1.41c.13 0 .26-.05.35-.15l5.16-5.16z"/></svg>

After

Width:  |  Height:  |  Size: 500 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M19 3H5c-1.11 0-2 .89-2 2v4h2V5h14v14H5v-4H3v4a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-8.92 12.58L11.5 17l5-5l-5-5l-1.42 1.41L12.67 11H3v2h9.67z"/></svg>

After

Width:  |  Height:  |  Size: 273 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5M12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5s5 2.24 5 5s-2.24 5-5 5m0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3s3-1.34 3-3s-1.34-3-3-3"/></svg>

After

Width:  |  Height:  |  Size: 329 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M20.8 17v-1.5c0-1.4-1.4-2.5-2.8-2.5s-2.8 1.1-2.8 2.5V17c-.6 0-1.2.6-1.2 1.2v3.5c0 .7.6 1.3 1.2 1.3h5.5c.7 0 1.3-.6 1.3-1.2v-3.5c0-.7-.6-1.3-1.2-1.3m-1.3 0h-3v-1.5c0-.8.7-1.3 1.5-1.3s1.5.5 1.5 1.3zM15 12c-.9.7-1.5 1.6-1.7 2.7c-.4.2-.8.3-1.3.3c-1.7 0-3-1.3-3-3s1.3-3 3-3s3 1.3 3 3m-3 7.5c-5 0-9.3-3.1-11-7.5c1.7-4.4 6-7.5 11-7.5s9.3 3.1 11 7.5c-.2.5-.5 1-.7 1.5C21.5 12 19.8 11 18 11c-.4 0-.7.1-1.1.1C16.5 8.8 14.5 7 12 7c-2.8 0-5 2.2-5 5s2.2 5 5 5h.3q-.3.6-.3 1.2z"/></svg>

After

Width:  |  Height:  |  Size: 584 B

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