Compare commits

...
50 Commits
Author SHA1 Message Date
21619e72dc fix: 再次修复 tx 源搜索偶现风控 (#2848)
Build / Windows (push) Waiting to run
Build / Windows_7 (push) Waiting to run
Build / Mac (push) Waiting to run
Build / Linux (push) Waiting to run
* fix: 修复 tx 源搜索偶现风控

将搜索接口从移动端 DoSearchForQQMusicMobile 切换到 PC 端
DoSearchForQQMusicDesktop,更新 comm 参数与 searchid 生成方式,
并兼容新旧返回结构(music.search.SearchCgiService / req)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* update

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: lyswhut <lyswhut@qq.com>
2026-06-21 10:30:06 +08:00
啊呜 33742bca6b Win文件名中存在#特殊字符时无法正常播放 (#2862)
(cherry picked from commit 890b6ea3da9dc08ae47f3c07ae03f88231b36319)
2026-06-14 20:34:42 +08:00
lyswhut e0575fc179 fix: 移除请求函数中的不必要的类型注释 2026-06-14 20:14:01 +08:00
lyswhut 97fe5fa496 更新依赖&版本号(2.12.3-beta.5) 2026-06-14 19:41:56 +08:00
lyswhut 97533cd4aa fix: 修复在某些情况下添加列表、歌曲时可能导致保存的顺序不对的问题(#2842) 2026-06-10 09:24:34 +08:00
lyswhut a7672168c6 修复旧版请求函数中的重定向选项 2026-06-07 11:45:59 +08:00
lyswhut 5c46ca648d 修复某些 tx 歌单打开失败的问题 2026-06-07 11:45:59 +08:00
lyswhut 38c7bc0d0d 修复 mg 图片、歌词获取 2026-06-07 11:45:59 +08:00
cb2798cba9 refactor: 将QRC解密替换为纯JS实现并简化kw歌词获取 (#2845)
* 简化kw歌词获取 && kw歌词解密逻辑移动

将酷我歌词解密从主进程 native/IPC 方案迁移到渲染进程纯 JS 实现,
移除 kw_decodeLyric 主进程入口与 handle_kw_decode_lyric IPC 通道。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* tx歌词解密改用js实现

将 QRC 歌词的 3DES 解密由主进程 native 插件(qrc_decode.node)按原 C++
算法 1:1 移植为渲染进程纯 JS 实现,移除 tx_decodeLyric 主进程入口、
handle_tx_decode_lyric IPC 通道,以及 build 配置中对 qrc_decode.node
的引用与各平台预构建二进制。

注意:QRC 使用的是非标准 DES 变体(S2[23]=15、S4[53]=10,标准为
14、1),新实现已逐字节对照原 native 二进制保持一致。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* update

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: lyswhut <lyswhut@qq.com>
2026-06-07 11:14:17 +08:00
lyswhut f0342b852b 更新版本号(2.12.3-beta.4) 2026-06-04 21:43:22 +08:00
lyswhut 97346617a5 更新版本号(2.12.3-beta.3) 2026-06-04 21:22:23 +08:00
lyswhut ecdc018fa1 Linux 点击托盘时将显示主界面(#2840) 2026-06-04 20:54:55 +08:00
lyswhut cbcbe3e8c8 更新版本号(2.12.3-beta.2) 2026-05-23 11:01:51 +08:00
lyswhut b12c5ed12b 同步服务在连接时允许URL重定向 2026-05-23 11:00:21 +08:00
3gf8jv4dv 96921b255f fix(l10n): update import songlist dialog text (#2797)
refs https://github.com/lyswhut/lx-music-doc/issues/64
refs https://github.com/lyswhut/lx-music-desktop/issues/2711
2026-05-17 14:42:49 +08:00
lyswhut 2df1077ee4 更新依赖&版本号(2.12.3-beta.1) 2026-05-15 12:36:43 +08:00
lyswhut 4937ddb09c 修复某些情况下开放 API 获取到的音量为0 的问题(#2790) 2026-05-15 12:15:04 +08:00
lyswhut 4b00a06a88 优化kw歌单列表数据显示 2026-05-15 12:13:13 +08:00
lyswhut be48e78a6f 更新依赖&版本号(2.12.3-beta.0) 2026-05-05 15:31:34 +08:00
lyswhut b7a3663078 修复 kg 搜索结果显示问题(#2782) 2026-05-05 14:50:34 +08:00
lyswhut 9c364b482e 更新依赖 2026-05-01 13:12:19 +08:00
lyswhut 355c5e584b 修复音调调节问题 2026-05-01 13:05:57 +08:00
lyswhut f956fd93d5 将 actions/cache 版本从 v6 降级至 v5 2026-05-01 02:28:50 +08:00
lyswhut 69e285d3ca 升级 GitHub Actions 依赖至最新版本 2026-05-01 02:26:18 +08:00
lyswhut 728fed2ed3 发布 v2.12.2 2026-05-01 01:31:36 +08:00
lyswhut bc0d2264a3 更新依赖 2026-05-01 01:30:51 +08:00
lyswhut ba8a6bf76c 修复音乐名称和歌手信息格式化(#2733) 2026-04-23 18:08:44 +08:00
lyswhut 2cf84a796d 更新依赖&版本号(2.12.2-beta.1) 2026-04-10 12:16:30 +08:00
lyswhut ba62ae27af 修复 tx 偶现搜索失败的问题 2026-04-10 09:11:29 +08:00
lyswhut e35895d752 移除 babel 2026-04-10 09:10:58 +08:00
lyswhut cc192cab44 fix: 修复 tx 搜索结果显示异常的问题(#2753) 2026-04-09 12:47:28 +08:00
lyswhut e36792b76a 更新依赖&版本号(2.12.2-beta.0) 2026-03-31 18:14:35 +08:00
lyswhut 61231ab99e feat: 优化搜索功能,增强歌曲匹配逻辑 2026-03-31 18:11:31 +08:00
lyswhut ae2ee0e61e 更新依赖 2026-03-23 09:29:30 +08:00
Stanislav Chernov a339f0fd0c fix: 音源导入去重 (#2681)
* fix: 音源导入添加去重逻辑
修复音源导入时未对重复数据进行去重的问题。现在会根据url去重,避免导入重复的音源。
Fixes lyswhut/lx-music-desktop#2649

* fix: check script content instead of name for dedup

Allow duplicate source names (intentional behavior).
Only reject import when script content is identical to an existing
source, and report which source name conflicts.
2026-03-11 14:52:52 +08:00
lyswhut b9c3c3506e 修复桌面歌词的鼠标悬停隐藏功能,优化鼠标事件处理逻辑 2026-03-07 14:11:44 +08:00
lyswhut 44d96264fb 修复某些情况下可能出现播放歌曲没有声音的问题 (#2693) 2026-03-07 14:06:12 +08:00
19eab0418a fix(mac): 修复 Mac 睡眠导致音乐播放中断的问题 (#2692)
* fix(mac): prevent sleep on mac during playback

* Remove space between async and parentheses in functions

---------

Co-authored-by: ydm <ydm@49os.com>
Co-authored-by: lyswhut <lyswhut@qq.com>
2026-03-07 13:56:55 +08:00
Little_100andlyswhut 0695bbaa70 修复桌面歌词的 鼠标移入歌词区域时提高透明度 设置不稳定的问题 (#2679)
Co-authored-by: lyswhut <lyswhut@qq.com>
2026-03-07 13:52:35 +08:00
lyswhut 8d6b207833 发布 v2.12.1 2026-02-16 21:11:25 +08:00
lyswhut 3371a42822 更新依赖 2026-02-16 21:03:26 +08:00
lyswhut fe712318b8 更新依赖&版本号(2.12.1-beta.3) 2026-01-31 12:44:16 +08:00
ikun0014andlyswhut 593d8327b2 fix: 网易云部分歌曲无法被搜索 (#2666)
* fix: 网易云部分歌曲无法被搜索

将搜索请求由原有 eapiRequest 改为通过 httpFetch 调用 /eapi/search/song/list/page 并使用 eapi 加密表单;调整导入(使用 eapi)、移除部分注释和冗余代码。更新结果解析以兼容新响应结构(使用 item.baseInfo.simpleSongData、result.data.resources 和 result.data.totalCount 等字段),并保留原有的分页与重试逻辑。

* update

* update

---------

Co-authored-by: lyswhut <lyswhut@qq.com>
2026-01-31 12:36:28 +08:00
lyswhut d5c54b770f 更新依赖&版本号(2.12.1-beta.2) 2026-01-01 11:58:30 +08:00
lyswhut 32a9a21be1 优化托盘图标行为:在非 Windows 系统中,点击托盘图标时不再显示主窗口 2025-12-31 09:53:47 +08:00
lyswhut e0735f8928 更新依赖&版本号(2.12.1-beta.1) 2025-12-21 13:46:31 +08:00
lyswhut c6dc962134 修复某些情况下搜索框的搜索按钮布局错位的问题(#2622) 2025-12-21 13:45:40 +08:00
lyswhut e068103e4d 修复构建 2025-12-12 12:52:31 +08:00
lyswhut 6eb5ade9ea 更新依赖&版本号(2.12.1-beta.0) 2025-12-12 12:37:16 +08:00
lyswhut c40168cacd 更新构建流程,集成 undici 库并优化请求处理 2025-12-12 12:28:04 +08:00
95 changed files with 4489 additions and 6278 deletions
-26
View File
@@ -1,26 +0,0 @@
{
"presets": [
"@babel/preset-typescript",
[
"@babel/preset-env",
{
"corejs": "3",
"useBuiltIns": "usage"
}
]
// [
// "minify",
// {
// "builtIns": false,
// "evaluate": false,
// "mangle": false
// }
// ]
],
"plugins": [
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-transform-modules-umd",
"@babel/plugin-transform-runtime",
"@babel/plugin-transform-class-properties"
]
}
-1
View File
@@ -55,7 +55,6 @@ const vueRule = {
exports.base = {
extends: ['standard'],
rules: baseRule,
parser: '@babel/eslint-parser',
}
exports.html = {
+2 -2
View File
@@ -5,7 +5,7 @@ runs:
using: composite
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '22'
@@ -16,7 +16,7 @@ runs:
# https://docs.npmjs.com/cli/v10/configuring-npm/folders#cache
- name: Cache node modules
id: cache-npm
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ env.NPM_CACHE }}
key: ${{ runner.os }}-npm-cache-${{ hashFiles('package-lock.json') }}
+57 -23
View File
@@ -14,7 +14,7 @@ jobs:
# runs-on: ubuntu-latest
# steps:
# - name: Check out git repository
# uses: actions/checkout@v4
# uses: actions/checkout@v6
# - name: Install Node.js
# uses: actions/setup-node@v4
@@ -46,7 +46,7 @@ jobs:
# needs: CheckCode
steps:
- name: Check out git repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Get npm cache directory
shell: pwsh
@@ -65,7 +65,7 @@ jobs:
- name: Build Package Setup x64
run: npm run pack:win:setup:x64
- name: Upload Artifact Setup x64
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: lx-music-desktop-x64-Setup
path: build/*-x64-Setup.exe
@@ -73,7 +73,7 @@ jobs:
- name: Build Package 7z x64
run: npm run pack:win:7z:x64
- name: Upload Artifact 7z x64
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: lx-music-desktop-win_x64-green
path: build/*win_x64-green.7z
@@ -81,7 +81,7 @@ jobs:
- name: Build Package Setup arm64
run: npm run pack:win:setup:arm64
- name: Upload Artifact Setup arm64
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: lx-music-desktop-arm64-Setup
path: build/*-arm64-Setup.exe
@@ -89,22 +89,56 @@ jobs:
- name: Build Package 7z arm64
run: npm run pack:win:7z:arm64
- name: Upload Artifact 7z arm64
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: lx-music-desktop-win_arm64-green
path: build/*win_arm64-green.7z
- name: Generate file MD5
run: |
cd build
Get-FileHash *.exe,*.7z -Algorithm MD5 | Format-List
- name: Install old electron
run: npm install electron@22
Windows_7:
name: Windows_7
runs-on: windows-latest
env:
BUILD_WIN7: true
# needs: CheckCode
steps:
- name: Check out git repository
uses: actions/checkout@v6
- name: Install python setuptools
run: pip.exe install setuptools
- name: Get npm cache directory
shell: pwsh
run: echo "NPM_CACHE=$(npm config get cache)" >> $env:GITHUB_ENV
- name: Setup Node Env
env:
NPM_CACHE: ${{ env.NPM_CACHE }}
uses: ./.github/actions/setup
- name: Prepare win7 undici env
run: |
npm install undici@5
Set-Content -Path .\src\common\utils\request.ts -Value "export * from './request_node16'"
- name: Build src code
env:
BUILD_WIN7: true
run: |
git status --porcelain
npm run build
- name: Prepare win7 electron env
run: |
npm install electron@22
pip.exe install setuptools
- name: Build Package win7 Setup x64
run: npm run pack:win7:setup:x64
- name: Upload Artifact win7 Setup x64
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: lx-music-desktop-win7_x64-Setup
path: build/*win7_x64-Setup.exe
@@ -112,7 +146,7 @@ jobs:
- name: Build Package win7 7z x64
run: npm run pack:win7:7z:x64
- name: Upload Artifact win7 7z x64
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: lx-music-desktop-win7_x64-green
path: build/*win7_x64-green.7z
@@ -120,7 +154,7 @@ jobs:
- name: Build Package win7 7z x86
run: npm run pack:win7:7z:x86
- name: Upload Artifact win7 7z x86
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: lx-music-desktop-win7_x86-green
path: build/*win7_x86-green.7z
@@ -136,7 +170,7 @@ jobs:
# needs: CheckCode
steps:
- name: Check out git repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install python setuptools
run: brew install python-setuptools
@@ -161,14 +195,14 @@ jobs:
npm run pack:mac:dmg:arm64
- name: Upload Artifact dmg
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: lx-music-desktop-mac-dmg
path: |
build/*.dmg
!build/*-arm64.dmg
- name: Upload Artifact dmg
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: lx-music-desktop-mac-dmg-arm64
path: build/*-arm64.dmg
@@ -187,7 +221,7 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y rpm libarchive-tools
- name: Check out git repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Get npm cache directory
shell: bash
@@ -206,7 +240,7 @@ jobs:
- name: Build Package deb amd64
run: npm run pack:linux:deb:amd64
- name: Upload Artifact deb amd64
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: lx-music-desktop-deb-amd64
path: build/*_amd64.deb
@@ -214,7 +248,7 @@ jobs:
- name: Build Package deb arm64
run: npm run pack:linux:deb:arm64
- name: Upload Artifact deb arm64
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: lx-music-desktop-deb-arm64
path: build/*_arm64.deb
@@ -222,7 +256,7 @@ jobs:
- name: Build Package deb armv7l
run: npm run pack:linux:deb:armv7l
- name: Upload Artifact deb armv7l
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: lx-music-desktop-deb-armv7l
path: build/*_armv7l.deb
@@ -230,7 +264,7 @@ jobs:
- name: Build Package x64 appImage
run: npm run pack:linux:appImage
- name: Upload Artifact x64 appImage
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: lx-music-desktop-x64-appImage
path: build/*_x64.AppImage
@@ -238,7 +272,7 @@ jobs:
- name: Build Package x64 rpm
run: npm run pack:linux:rpm
- name: Upload Artifact x64 rpm
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: lx-music-desktop-x64-rpm
path: build/*.x64.rpm
@@ -246,7 +280,7 @@ jobs:
- name: Build Package x64 pacman
run: npm run pack:linux:pacman
- name: Upload Artifact x64 pacman
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: lx-music-desktop-x64-pacman
path: build/*_x64.pacman
+2 -2
View File
@@ -11,10 +11,10 @@ jobs:
steps:
- name: Check out git repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 22
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Repository Dispatch
uses: peter-evans/repository-dispatch@v2
uses: peter-evans/repository-dispatch@v4
with:
token: ${{ secrets.PAT }}
repository: lyswhut/lx-music-desktop-version-info
+12 -5
View File
@@ -14,7 +14,7 @@ jobs:
# runs-on: ubuntu-latest
# steps:
# - name: Check out git repository
# uses: actions/checkout@v4
# uses: actions/checkout@v6
# - name: Install Node.js
# uses: actions/setup-node@v4
@@ -46,7 +46,7 @@ jobs:
# needs: CheckCode
steps:
- name: Check out git repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Get npm cache directory
shell: pwsh
@@ -87,7 +87,7 @@ jobs:
# needs: CheckCode
steps:
- name: Check out git repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Get npm cache directory
shell: pwsh
@@ -98,7 +98,14 @@ jobs:
NPM_CACHE: ${{ env.NPM_CACHE }}
uses: ./.github/actions/setup
- name: Prepare win7 undici env
run: |
npm install undici@5
Set-Content -Path .\src\common\utils\request.ts -Value "export * from './request_node16'"
- name: Build src code
env:
BUILD_WIN7: true
run: |
git status --porcelain
npm run build
@@ -128,7 +135,7 @@ jobs:
# needs: CheckCode
steps:
- name: Check out git repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install python3 setuptools
run: brew install python-setuptools
@@ -171,7 +178,7 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y rpm libarchive-tools
- name: Check out git repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Get npm cache directory
shell: bash
+5 -1
View File
@@ -14,6 +14,8 @@ module.exports = {
'@types/node',
'electron-debug',
'eslint-webpack-plugin',
'typescript',
'undici',
'eslint-plugin-vue',
'vue-eslint-parser',
@@ -35,11 +37,13 @@ module.exports = {
// target: 'minor',
// filter: [
// // 'electron',
// 'electron',
// 'eslint',
// 'eslint-webpack-plugin',
// 'electron-debug',
// 'typescript',
// '@types/node',
// 'undici',
// 'eslint-plugin-vue',
// 'vue-eslint-parser',
+34
View File
@@ -6,6 +6,40 @@ Project versioning adheres to [Semantic Versioning](http://semver.org/).
Commit convention is based on [Conventional Commits](http://conventionalcommits.org).
Change log format is based on [Keep a Changelog](http://keepachangelog.com/).
## [2.12.2](https://github.com/lyswhut/lx-music-desktop/compare/v2.12.1...v2.12.2) - 2026-05-01
我们很高兴地宣布新项目 Any Listen 的桌面版已发布,目前已支持列表跟随本地文件自动更新、加载并播放WebDAV上的歌曲等功能,更多功能仍在积极开发中,桌面版与Web版将同步更新。
对于有播放本地音乐或播放服务器上音乐需求的人可以试试,若遇到任何问题可以发 issue 反馈。
### 优化
- 优化歌单内歌曲搜索结果排序 (#2734)
### 修复
- 修复桌面歌词的 鼠标移入歌词区域时提高透明度 设置不稳定的问题(#2679, @Little100)
- 修复某些情况下可能播放没有声音的问题(#2693)
- 修复 tx 搜索结果显示异常的问题(#2753)
- 修复音乐名称和歌手信息格式化问题(#2733)
### 其他
- 更新 Electron 到 40.8.3
## [2.12.1](https://github.com/lyswhut/lx-music-desktop/compare/v2.12.0...v2.12.1) - 2026-02-16
我们很高兴地宣布新项目 Any Listen 的桌面版已发布,目前已支持列表跟随本地文件自动更新、加载并播放WebDAV上的歌曲等功能,更多功能仍在积极开发中,桌面版与Web版将同步更新。
对于有播放本地音乐或播放服务器上音乐需求的人可以试试,若遇到任何问题可以发 issue 反馈。
### 优化
- 优化托盘图标行为:在非 Windows 系统中,点击托盘图标时不再显示主窗口
### 修复
- 修复音量条在调整音量时实际音量与显示的数值不一致的问题(#2606)
- 修复某些情况下搜索框的搜索按钮布局错位的问题(#2622)
## [2.12.0](https://github.com/lyswhut/lx-music-desktop/compare/v2.11.0...v2.12.0) - 2025-11-29
我们很高兴地宣布新项目 Any Listen 的桌面版已发布,目前已支持列表跟随本地文件自动更新、加载并播放WebDAV上的歌曲等功能,更多功能仍在积极开发中,桌面版与Web版将同步更新。
-28
View File
@@ -10,23 +10,6 @@ const better_sqlite3_fileNameMap = {
[Arch.armv7l]: 'linux-arm',
}
const qrc_decode_fileNameMap = {
win32: {
[Arch.x64]: 'win32-x64',
[Arch.ia32]: 'win32-ia32',
[Arch.arm64]: 'win32-arm64',
},
linux: {
[Arch.x64]: 'linux-x64',
[Arch.arm64]: 'linux-arm64',
[Arch.armv7l]: 'linux-arm',
},
darwin: {
[Arch.x64]: 'darwin-x64',
[Arch.arm64]: 'darwin-arm64',
},
}
const replaceSqliteLib = async(electronNodeAbi, arch) => {
// console.log(await fs.readdir(path.join(context.appOutDir, './resources/')))
// if (context.electronPlatformName != 'linux' || context.arch != Arch.arm64) return
@@ -40,22 +23,11 @@ const replaceSqliteLib = async(electronNodeAbi, arch) => {
await fsPromises.copyFile(filePath, targetPath)
}
const replaceQrcDecodeLib = async(electronNodeAbi, platform, arch) => {
console.log('replace qrc_decode lib...', platform, electronNodeAbi, qrc_decode_fileNameMap[platform][arch])
const filePath = path.join(__dirname, `./lib/qrc_decode_electron-v${electronNodeAbi}-${qrc_decode_fileNameMap[platform][arch]}.node`)
const targetPath = path.join(__dirname, '../build/Release/qrc_decode.node')
const targetDir = path.dirname(targetPath)
if (fs.existsSync(targetDir)) await fsPromises.unlink(targetPath).catch(_ => _)
else await fsPromises.mkdir(targetDir, { recursive: true })
await fsPromises.copyFile(filePath, targetPath)
}
module.exports = async(context) => {
const { electronPlatformName, arch } = context
const electronVersion = context.packager?.info?._framework?.version ?? require('../package.json').devDependencies.electron.replace(/^[^\d]*?(\d+)/, '$1')
const electronNodeAbi = nodeAbi.getAbi(electronVersion, 'electron')
await replaceQrcDecodeLib(electronNodeAbi, electronPlatformName, arch)
if (electronPlatformName !== 'linux' || process.env.FORCE) return
const bindingFilePath = path.join(__dirname, '../node_modules/better-sqlite3/binding.gyp')
const bindingBakFilePath = path.join(__dirname, '../node_modules/better-sqlite3/binding.gyp.bak')
-1
View File
@@ -35,7 +35,6 @@ const options = {
'node_modules/node-gyp-build',
'node_modules/bufferutil',
'node_modules/utf-8-validate',
'build/Release/qrc_decode.node',
'dist/**/*',
],
asar: {
-1
View File
@@ -25,7 +25,6 @@ const unzip = async(filePath) => {
}
const files = [
'qrc_decode',
'better_sqlite3',
]
const moveFile = async(filePath) => {
-3
View File
@@ -1,8 +1,6 @@
const path = require('path')
const ESLintPlugin = require('eslint-webpack-plugin')
const isDev = process.env.NODE_ENV === 'development'
module.exports = {
target: 'electron-main',
output: {
@@ -18,7 +16,6 @@ module.exports = {
'electron-font-manager': 'electron-font-manager',
bufferutil: 'bufferutil',
'utf-8-validate': 'utf-8-validate',
'qrc_decode.node': isDev ? path.join(__dirname, '../../build/Release/qrc_decode.node') : path.join('../build/Release/qrc_decode.node'),
},
resolve: {
alias: {
@@ -19,15 +19,6 @@ module.exports = merge(baseConfig, {
externals: [
// ...Object.keys(dependencies || {}).filter(d => !whiteListedModules.includes(d)),
],
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/,
},
],
},
plugins: [
new webpack.DefinePlugin({
'process.env': {
@@ -18,15 +18,6 @@ module.exports = merge(baseConfig, {
externals: [
// ...Object.keys(dependencies || {}).filter(d => !whiteListedModules.includes(d)),
],
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/,
},
],
},
plugins: [
new webpack.DefinePlugin({
'process.env': {
+7 -10
View File
@@ -19,7 +19,13 @@ const gitInfo = {
}
try {
if (!execSync('git status --porcelain').toString().trim()) {
let isClean = !execSync('git status --porcelain').toString().trim()
if (process.env.BUILD_WIN7) {
console.warn('BUILD_WIN7 is set, skipping git status check.')
console.log('Workspace status:', execSync('git status --porcelain').toString().trim())
isClean = true
}
if (isClean) {
gitInfo.commit_id = execSync('git log -1 --pretty=format:"%H"').toString().trim()
gitInfo.commit_date = execSync('git log -1 --pretty=format:"%ad" --date=iso-strict').toString().trim()
} else if (process.env.IS_CI) {
@@ -33,15 +39,6 @@ module.exports = merge(baseConfig, {
externals: [
// ...Object.keys(dependencies || {}).filter(d => !whiteListedModules.includes(d)),
],
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/,
},
],
},
plugins: [
new CopyWebpackPlugin({
patterns: [
+6 -6
View File
@@ -1,12 +1,12 @@
{
"compilerOptions": {
"baseUrl": "./",
"rootDir": "./",
"paths": {
"@main/*": ["src/main/*"],
"@renderer/*": ["src/renderer/*"],
"@lyric/*": ["src/renderer-lyric/*"],
"@static/*": ["src/static/*"],
"@common/*": ["src/common/*"],
"@main/*": ["./src/main/*"],
"@renderer/*": ["./src/renderer/*"],
"@lyric/*": ["./src/renderer-lyric/*"],
"@static/*": ["./src/static/*"],
"@common/*": ["./src/common/*"],
}
},
"vueCompilerOptions": {
+2959 -5491
View File
File diff suppressed because it is too large Load Diff
+47 -51
View File
@@ -1,6 +1,6 @@
{
"name": "lx-music-desktop",
"version": "2.12.0",
"version": "2.12.3-beta.5",
"description": "一个免费的音乐查找助手",
"main": "./dist/main.js",
"scripts": {
@@ -107,96 +107,87 @@
},
"homepage": "https://github.com/lyswhut/lx-music-desktop#readme",
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/eslint-parser": "^7.28.5",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-modules-umd": "^7.27.1",
"@babel/plugin-transform-runtime": "^7.28.5",
"@babel/preset-env": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@babel/runtime": "^7.28.4",
"@tsconfig/recommended": "^1.0.13",
"@types/better-sqlite3": "^7.6.13",
"@types/needle": "^3.3.0",
"@types/node": "^20.19.25",
"@types/node": "^20.19.41",
"@types/tunnel": "^0.0.7",
"@types/ws": "8.5.4",
"@vue/language-plugin-pug": "^3.1.5",
"babel-loader": "^10.0.0",
"browserslist": "^4.28.0",
"@vue/language-plugin-pug": "^3.3.5",
"browserslist": "^4.28.2",
"chalk": "^4.1.2",
"changelog-parser": "^3.0.1",
"copy-webpack-plugin": "^13.0.1",
"core-js": "^3.47.0",
"copy-webpack-plugin": "^14.0.0",
"core-js": "^3.49.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.2",
"css-loader": "^7.1.4",
"css-minimizer-webpack-plugin": "^8.0.0",
"del": "^6.1.1",
"electron": "37.6.1",
"electron-builder": "^26.3.2",
"electron": "42.4.0",
"electron-builder": "^26.15.3",
"electron-debug": "^3.2.0",
"electron-devtools-installer": "github:lyswhut/electron-devtools-installer#64596d615c1fc891eefd8aef1dfcb2c87aaadf03",
"electron-to-chromium": "^1.5.262",
"electron-updater": "6.7.2",
"electron-to-chromium": "^1.5.372",
"electron-updater": "6.8.9",
"eslint": "^8.57.1",
"eslint-config-standard": "^17.1.0",
"eslint-config-standard-with-typescript": "^43.0.1",
"eslint-formatter-friendly": "github:lyswhut/eslint-friendly-formatter#2170d1320e2fad13615a9dcf229669f0bb473a53",
"eslint-plugin-html": "^8.1.3",
"eslint-plugin-html": "^8.1.4",
"eslint-plugin-vue": "^9.33.0",
"eslint-plugin-vue-pug": "^0.6.2",
"eslint-webpack-plugin": "^4.2.0",
"html-webpack-plugin": "^5.6.5",
"less": "^4.4.2",
"less-loader": "^12.3.0",
"mini-css-extract-plugin": "^2.9.4",
"html-webpack-plugin": "^5.6.7",
"less": "^4.6.6",
"less-loader": "^13.0.0",
"mini-css-extract-plugin": "^2.10.2",
"node-loader": "^2.1.0",
"postcss": "^8.5.6",
"postcss-loader": "^8.2.0",
"postcss": "^8.5.15",
"postcss-loader": "^8.2.1",
"postcss-pxtorem": "^6.1.0",
"pug": "^3.0.3",
"pug": "^3.0.4",
"pug-plain-loader": "^1.1.0",
"rimraf": "^6.1.2",
"rimraf": "^6.1.3",
"spinnies": "github:lyswhut/spinnies#233305c58694aa3b053e3ab9af9049993f918b9d",
"svg-sprite-loader": "^6.0.11",
"svg-transform-loader": "^2.0.13",
"svgo-loader": "^4.0.0",
"terser": "^5.44.1",
"terser-webpack-plugin": "^5.3.14",
"svgo-loader": "^5.0.0",
"terser": "^5.48.0",
"terser-webpack-plugin": "^5.6.1",
"tree-kill": "^1.2.2",
"ts-loader": "^9.5.4",
"ts-loader": "^9.6.0",
"typescript": "5.9.3",
"vue-eslint-parser": "^9.4.3",
"vue-loader": "^17.4.2",
"webpack": "^5.103.0",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "5.2.2",
"webpack": "^5.107.2",
"webpack-cli": "^7.0.3",
"webpack-dev-server": "5.2.5",
"webpack-hot-middleware": "github:lyswhut/webpack-hot-middleware#329c4375134b89d39da23a56a94db651247c74a1",
"webpack-merge": "^6.0.1"
},
"dependencies": {
"@simonwep/pickr": "^1.9.1",
"better-sqlite3": "^12.5.0",
"bufferutil": "^4.0.9",
"better-sqlite3": "^12.10.1",
"bufferutil": "^4.1.0",
"comlink": "~4.3.1",
"crypto-js": "^4.2.0",
"electron-log": "^5.4.3",
"font-list": "^2.0.1",
"iconv-lite": "^0.7.0",
"electron-log": "^5.4.4",
"font-list": "^2.1.0",
"iconv-lite": "^0.7.2",
"image-size": "^1.1.0",
"jschardet": "^3.1.4",
"long": "^5.3.2",
"message2call": "^0.1.3",
"music-metadata": "^11.10.3",
"music-metadata": "^11.13.0",
"needle": "github:lyswhut/needle#93299ac841b7e9a9f82ca7279b88aaaeda404060",
"node-id3": "^0.2.9",
"sortablejs": "^1.15.6",
"sortablejs": "^1.15.7",
"tunnel": "^0.0.6",
"utf-8-validate": "^6.0.5",
"undici": "^7.27.1",
"utf-8-validate": "^6.0.6",
"vue": "~3.3.13",
"vue-router": "^4.5.1",
"ws": "^8.18.3"
"vue-router": "~4.5.1",
"ws": "^8.21.0"
},
"overrides": {
"got": "^11",
@@ -204,14 +195,19 @@
"node-abi": "latest",
"minimatch": "latest",
"semver": "latest",
"posthtml": "latest",
"node-gyp": "latest",
"sockjs": {
"uuid": "^12"
},
"svg-transform-loader": {
"postcss": "latest"
"postcss": "^8"
},
"svg-sprite-loader": {
"postcss": "latest"
"postcss": "^8"
},
"svg-baker": {
"postcss": "latest"
"postcss": "^8"
},
"braces": "latest",
"node-gyp-build": "latest",
+10 -3
View File
@@ -1,6 +1,13 @@
我们很高兴地宣布新项目 Any Listen 的桌面版已发布,目前已支持列表跟随本地文件自动更新、加载并播放WebDAV上的歌曲等功能,更多功能仍在积极开发中,桌面版与Web版将同步更新。
对于有播放本地音乐或播放服务器上音乐需求的人可以试试,若遇到任何问题可以发 issue 反馈。
### 优化
- 优化kw歌单列表数据显示
- 同步服务在连接时允许URL重定向
- Linux 点击托盘时将显示主界面(#2840)
### 修复
- 修复音量条在调整音量时实际音量与显示的数值不一致的问题(#2606)
- 修复 kg 搜索结果显示问题 (#2782)
- 修复某些情况下开放 API 获取到的音量为 0 的问题 (#2790)
- 修复 mg 图片、歌词获取
- 修复某些 tx 歌单打开失败的问题 (#1060, @ght-000)
- 修复在某些情况下添加歌曲时可能导致保存的歌曲顺序不对的问题(#2842)
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -4
View File
@@ -78,10 +78,6 @@ const modules = {
restart_window: 'restart_window',
// lang_s2t: 'lang_s2t',
handle_kw_decode_lyric: 'handle_kw_decode_lyric',
handle_tx_decode_lyric: 'handle_tx_decode_lyric',
get_lyric_info: 'get_lyric_info',
set_lyric_info: 'set_lyric_info',
set_config: 'set_config',
@@ -166,6 +162,7 @@ const modules = {
key_down: 'key_down',
request_main_window_channel: 'request_main_window_channel',
provide_main_window_channel: 'provide_main_window_channel',
mouse_enter_leave: 'mouse_enter_leave',
},
hotKey: {
enable: 'enable',
+1 -1
View File
@@ -5,7 +5,7 @@
"./types"
],
"paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */
"@common/*": ["common/*"],
"@common/*": ["./*"],
},
},
}
+2 -1
View File
@@ -1,5 +1,6 @@
// 非业务工具方法
import { pathToFileURL } from 'url'
/**
* 获取两个数之间的随机整数,大于等于min,小于max
* @param {*} min
@@ -188,7 +189,7 @@ export const sortInsert = <T>(arr: Array<{ num: number, data: T }>, data: { num:
}
export const encodePath = (path: string) => {
return encodeURI(path.replaceAll('\\', '/'))
return pathToFileURL(path).href
}
+274
View File
@@ -0,0 +1,274 @@
import qs from 'node:querystring'
import {
FormData,
getGlobalDispatcher,
interceptors,
request as nodeRrequest,
ProxyAgent,
setGlobalDispatcher,
type Dispatcher,
} from 'undici'
const defaultOptions: Options = {
timeout: 15000,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36',
},
maxRedirect: 5,
} as const
const redirectDispatcher = interceptors.redirect({ maxRedirections: defaultOptions.maxRedirect })
const dispatchers = [
interceptors.retry({
maxRetries: 3,
minTimeout: 1000,
maxTimeout: 10000,
timeoutFactor: 2,
retryAfter: true,
}),
// interceptors.responseError(),
] as const
let proxyAgent: ProxyAgent | null = null
let globalDispatcher = getGlobalDispatcher()
const buildDispatcher = (redirectDispatcher: Dispatcher.DispatcherComposeInterceptor | null, retryNum = 3) => {
const otherInterceptors =
retryNum == 3
? dispatchers
: [
interceptors.retry({
maxRetries: retryNum,
minTimeout: 1000,
maxTimeout: 6000,
timeoutFactor: 2,
retryAfter: true,
}),
]
if (redirectDispatcher) {
return (proxyAgent ?? globalDispatcher).compose(redirectDispatcher, ...otherInterceptors)
}
return (proxyAgent ?? globalDispatcher).compose(...otherInterceptors)
}
setGlobalDispatcher(buildDispatcher(redirectDispatcher))
export const setProxy = (url?: string) => {
proxyAgent = url ? new ProxyAgent(url) : null
setGlobalDispatcher(buildDispatcher(redirectDispatcher))
}
export const setProxyByHost = (host?: string, port?: string) => {
setProxy(host ? `http://${host}:${port}` : undefined)
}
const CONTENT_TYPE = {
json: 'application/json',
form: 'application/x-www-form-urlencoded',
text: 'text/plain',
formdata: 'multipart/form-data',
xml: 'application/xml',
binary: 'application/octet-stream',
}
type ParamsData = Record<string, string | number | null | undefined | boolean>
export interface Options {
method?:
| 'GET'
| 'HEAD'
| 'POST'
| 'PUT'
| 'DELETE'
| 'OPTIONS'
| 'PATCH'
| 'PROPFIND'
| 'COPY'
| 'MOVE'
| 'MKCOL'
| 'PROPPATCH'
| 'QUOTA'
query?: ParamsData
headers?: Record<string, string | string[]>
timeout?: number
maxRedirect?: number
signal?: AbortController['signal']
json?: Record<string, unknown>
form?: ParamsData
binary?: Buffer | Uint8Array
text?: string
formdata?: Record<string, string | Buffer | Uint8Array>
xml?: string
needBody?: boolean
needRaw?: boolean
retryNum?: number
}
export interface Response<Res> {
headers: {
accept?: string | undefined
'accept-language'?: string | undefined
'accept-patch'?: string | undefined
'accept-ranges'?: string | undefined
'access-control-allow-credentials'?: string | undefined
'access-control-allow-headers'?: string | undefined
'access-control-allow-methods'?: string | undefined
'access-control-allow-origin'?: string | undefined
'access-control-expose-headers'?: string | undefined
'access-control-max-age'?: string | undefined
'access-control-request-headers'?: string | undefined
'access-control-request-method'?: string | undefined
age?: string | undefined
allow?: string | undefined
'alt-svc'?: string | undefined
authorization?: string | undefined
'cache-control'?: string | undefined
connection?: string | undefined
'content-disposition'?: string | undefined
'content-encoding'?: string | undefined
'content-language'?: string | undefined
'content-length'?: string | undefined
'content-location'?: string | undefined
'content-range'?: string | undefined
'content-type'?: string | undefined
cookie?: string | undefined
date?: string | undefined
etag?: string | undefined
expect?: string | undefined
expires?: string | undefined
forwarded?: string | undefined
from?: string | undefined
host?: string | undefined
'if-match'?: string | undefined
'if-modified-since'?: string | undefined
'if-none-match'?: string | undefined
'if-unmodified-since'?: string | undefined
'last-modified'?: string | undefined
location?: string | undefined
origin?: string | undefined
pragma?: string | undefined
'proxy-authenticate'?: string | undefined
'proxy-authorization'?: string | undefined
'public-key-pins'?: string | undefined
range?: string | undefined
referer?: string | undefined
'retry-after'?: string | undefined
'sec-websocket-accept'?: string | undefined
'sec-websocket-extensions'?: string | undefined
'sec-websocket-key'?: string | undefined
'sec-websocket-protocol'?: string | undefined
'sec-websocket-version'?: string | undefined
'set-cookie'?: string[] | undefined
'strict-transport-security'?: string | undefined
tk?: string | undefined
trailer?: string | undefined
'transfer-encoding'?: string | undefined
upgrade?: string | undefined
'user-agent'?: string | undefined
vary?: string | undefined
via?: string | undefined
warning?: string | undefined
'www-authenticate'?: string | undefined
}
body: Res
raw: Uint8Array
statusCode?: number
statusMessage?: string
}
const buildRequestBody = (options: Options) => {
let contentType: string | undefined
let body: string | Buffer | Uint8Array | FormData | undefined
if (options.json != null) {
contentType = CONTENT_TYPE.json
body = JSON.stringify(options.json)
} else if (options.form != null) {
contentType = CONTENT_TYPE.form
body = qs.stringify(options.form)
} else if (options.binary != null) {
contentType = CONTENT_TYPE.binary
body = options.binary
} else if (options.formdata != null) {
const formdata = new FormData()
// eslint-disable-next-line guard-for-in
for (const key in options.formdata) {
formdata.append(key, options.formdata[key])
}
contentType = CONTENT_TYPE.formdata
body = formdata
} else if (options.xml != null) {
contentType = CONTENT_TYPE.xml
body = options.xml
} else if (options.text != null) {
contentType = CONTENT_TYPE.text
body = options.text
}
const headers = options.headers ?? {}
if (headers['Content-Type']) {
contentType = headers['Content-Type'] as string
}
const finalHeaders: NonNullable<Options['headers']> = { ...defaultOptions.headers, ...headers }
if (contentType) finalHeaders['Content-Type'] = contentType
return [finalHeaders, body] as const
}
const buildRequestDispatcher = (options: Options) => {
let dispatcher: Dispatcher.ComposedDispatcher | undefined
if (options.maxRedirect != null) {
if (options.maxRedirect != defaultOptions.maxRedirect) {
if (options.maxRedirect) {
dispatcher = buildDispatcher(interceptors.redirect({ maxRedirections: options.maxRedirect }), options.retryNum)
} else {
dispatcher = buildDispatcher(null, options.retryNum)
}
}
}
return dispatcher
}
export const request = async <T = unknown>(url: string, options: Options = {}): Promise<Response<T>> => {
const method = options.method?.toUpperCase() ?? 'GET'
const timeout = options.timeout ?? defaultOptions.timeout
const [headers, body] = buildRequestBody(options)
// console.log(url, {
// method,
// bodyTimeout: timeout,
// headersTimeout: timeout,
// headers,
// query: options.query,
// body,
// signal: options.signal,
// dispatcher: buildRequestDispatcher(options),
// })
return nodeRrequest(url, {
method,
bodyTimeout: timeout,
headersTimeout: timeout,
headers,
query: options.query,
body,
signal: options.signal,
dispatcher: buildRequestDispatcher(options),
}).then(async(response) => {
if (options.needBody) {
return {
headers: response.headers,
statusCode: response.statusCode,
body: response.body as unknown as T,
} satisfies Omit<Response<T>, 'raw'> as Response<T>
}
if (options.needRaw) {
return {
headers: response.headers,
statusCode: response.statusCode,
raw: await response.body.bytes(),
} satisfies Omit<Response<T>, 'body'> as Response<T>
}
// console.log(response)
let body = (await response.body.text()) as T
if (!headers['Content-Type'] || headers['Content-Type'].includes(CONTENT_TYPE.json)) {
try {
body = JSON.parse(body as string) as T
} catch {}
}
return {
body,
headers: response.headers,
statusCode: response.statusCode,
} satisfies Omit<Response<T>, 'raw'> as Response<T>
})
}
+238
View File
@@ -0,0 +1,238 @@
import qs from 'node:querystring'
import {
FormData,
getGlobalDispatcher,
request as nodeRrequest,
ProxyAgent,
setGlobalDispatcher,
type Dispatcher,
} from 'undici'
const defaultOptions: Options = {
timeout: 15000,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36',
},
maxRedirect: 5,
} as const
let proxyAgent: ProxyAgent | null = null
let globalDispatcher = getGlobalDispatcher()
const buildDispatcher = () => {
return proxyAgent ?? globalDispatcher
}
setGlobalDispatcher(buildDispatcher())
export const setProxy = (url?: string) => {
proxyAgent = url ? new ProxyAgent(url) : null
setGlobalDispatcher(buildDispatcher())
}
export const setProxyByHost = (host?: string, port?: string) => {
console.log(host)
setProxy(host ? `http://${host}:${port}` : undefined)
}
const CONTENT_TYPE = {
json: 'application/json',
form: 'application/x-www-form-urlencoded',
text: 'text/plain',
formdata: 'multipart/form-data',
xml: 'application/xml',
binary: 'application/octet-stream',
}
type ParamsData = Record<string, string | number | null | undefined | boolean>
export interface Options {
method?:
| 'GET'
| 'HEAD'
| 'POST'
| 'PUT'
| 'DELETE'
| 'OPTIONS'
| 'PATCH'
| 'PROPFIND'
| 'COPY'
| 'MOVE'
| 'MKCOL'
| 'PROPPATCH'
| 'QUOTA'
query?: ParamsData
headers?: Record<string, string | string[]>
timeout?: number
maxRedirect?: number
signal?: AbortController['signal']
json?: Record<string, unknown>
form?: ParamsData
binary?: Buffer | Uint8Array
text?: string
formdata?: Record<string, string | Buffer | Uint8Array>
xml?: string
needBody?: boolean
needRaw?: boolean
retryNum?: number
}
export interface Response<Res> {
headers: {
accept?: string | undefined
'accept-language'?: string | undefined
'accept-patch'?: string | undefined
'accept-ranges'?: string | undefined
'access-control-allow-credentials'?: string | undefined
'access-control-allow-headers'?: string | undefined
'access-control-allow-methods'?: string | undefined
'access-control-allow-origin'?: string | undefined
'access-control-expose-headers'?: string | undefined
'access-control-max-age'?: string | undefined
'access-control-request-headers'?: string | undefined
'access-control-request-method'?: string | undefined
age?: string | undefined
allow?: string | undefined
'alt-svc'?: string | undefined
authorization?: string | undefined
'cache-control'?: string | undefined
connection?: string | undefined
'content-disposition'?: string | undefined
'content-encoding'?: string | undefined
'content-language'?: string | undefined
'content-length'?: string | undefined
'content-location'?: string | undefined
'content-range'?: string | undefined
'content-type'?: string | undefined
cookie?: string | undefined
date?: string | undefined
etag?: string | undefined
expect?: string | undefined
expires?: string | undefined
forwarded?: string | undefined
from?: string | undefined
host?: string | undefined
'if-match'?: string | undefined
'if-modified-since'?: string | undefined
'if-none-match'?: string | undefined
'if-unmodified-since'?: string | undefined
'last-modified'?: string | undefined
location?: string | undefined
origin?: string | undefined
pragma?: string | undefined
'proxy-authenticate'?: string | undefined
'proxy-authorization'?: string | undefined
'public-key-pins'?: string | undefined
range?: string | undefined
referer?: string | undefined
'retry-after'?: string | undefined
'sec-websocket-accept'?: string | undefined
'sec-websocket-extensions'?: string | undefined
'sec-websocket-key'?: string | undefined
'sec-websocket-protocol'?: string | undefined
'sec-websocket-version'?: string | undefined
'set-cookie'?: string[] | undefined
'strict-transport-security'?: string | undefined
tk?: string | undefined
trailer?: string | undefined
'transfer-encoding'?: string | undefined
upgrade?: string | undefined
'user-agent'?: string | undefined
vary?: string | undefined
via?: string | undefined
warning?: string | undefined
'www-authenticate'?: string | undefined
}
body: Res
raw: Uint8Array
statusCode?: number
statusMessage?: string
}
const buildRequestBody = (options: Options) => {
let contentType: string | undefined
let body: string | Buffer | Uint8Array | FormData | undefined
if (options.json != null) {
contentType = CONTENT_TYPE.json
body = JSON.stringify(options.json)
} else if (options.form != null) {
contentType = CONTENT_TYPE.form
body = qs.stringify(options.form)
} else if (options.binary != null) {
contentType = CONTENT_TYPE.binary
body = options.binary
} else if (options.formdata != null) {
const formdata = new FormData()
// eslint-disable-next-line guard-for-in
for (const key in options.formdata) {
formdata.append(key, options.formdata[key])
}
contentType = CONTENT_TYPE.formdata
body = formdata
} else if (options.xml != null) {
contentType = CONTENT_TYPE.xml
body = options.xml
} else if (options.text != null) {
contentType = CONTENT_TYPE.text
body = options.text
}
const headers = options.headers ?? {}
if (headers['Content-Type']) {
contentType = headers['Content-Type'] as string
}
const finalHeaders: NonNullable<Options['headers']> = { ...defaultOptions.headers, ...headers }
if (contentType) finalHeaders['Content-Type'] = contentType
return [finalHeaders, body] as const
}
const buildRequestDispatcher = (options: Options) => {
return buildDispatcher()
}
export const request = async <T = unknown>(url: string, options: Options = {}): Promise<Response<T>> => {
const method = (options.method?.toUpperCase() ?? 'GET') as Dispatcher.RequestOptions['method']
const timeout = options.timeout ?? defaultOptions.timeout
const [headers, body] = buildRequestBody(options)
// console.log(url, {
// method,
// bodyTimeout: timeout,
// headersTimeout: timeout,
// headers,
// query: options.query,
// body,
// signal: options.signal,
// dispatcher: buildRequestDispatcher(options),
// })
return nodeRrequest(url, {
method,
bodyTimeout: timeout,
headersTimeout: timeout,
headers,
query: options.query,
body,
signal: options.signal,
dispatcher: buildRequestDispatcher(options),
maxRedirections: options.maxRedirect ?? defaultOptions.maxRedirect,
}).then(async(response) => {
if (options.needBody) {
return {
headers: response.headers,
statusCode: response.statusCode,
body: response.body as unknown as T,
} satisfies Omit<Response<T>, 'raw'> as Response<T>
}
if (options.needRaw) {
return {
headers: response.headers,
statusCode: response.statusCode,
raw: new Uint8Array(await response.body.arrayBuffer()),
} satisfies Omit<Response<T>, 'body'> as unknown as Response<T>
}
// console.log(response)
let body = (await response.body.text()) as T
if (!headers['Content-Type'] || headers['Content-Type'].includes(CONTENT_TYPE.json)) {
try {
body = JSON.parse(body as string) as T
} catch {}
}
return {
body,
headers: response.headers,
statusCode: response.statusCode,
} satisfies Omit<Response<T>, 'raw'> as Response<T>
})
}
+4
View File
@@ -148,3 +148,7 @@ export const clipFileNameLength = (name: string) => {
return name.length > MAX_FILE_NAME_LENGTH ? name.substring(0, MAX_FILE_NAME_LENGTH) : name
}
export const formatMusicName = (format: string, name: string, singer: string) => {
// return format.replace(/歌名|歌手/g, match => match === '歌名' ? name : singer)
return format.replace('歌手', singer).replace('歌名', name)
}
+1 -1
View File
@@ -613,7 +613,7 @@
"songlist__import_input_tip_1": "Cross-service playlists are not supported. Please confirm whether the playlist to be opened corresponds to the current chosen service",
"songlist__import_input_tip_2": "If you encounter a playlist link that cannot be opened. Please send us your feedback",
"songlist__import_input_tip_3": "Unable to open Kugou playlist with playlist ID or link from lite edition, but support to open it with Kugou code or link from main edition",
"songlist__import_input_tip_4": "NetEase Cloud Music's \"I Like\" playlist requires a token to open. For details, see ",
"songlist__import_input_tip_4": "NetEase’s “Private Radar” playlists require a token to access. Tencent’s personal playlists require your profile be public to access. For details, see ",
"songlist__import_input_title": "Open shared playlist",
"songlist__open_list": "Open \"{name}\" playlist",
"songlist__tag_info_hot_tag": "Top Tags",
+1 -1
View File
@@ -613,7 +613,7 @@
"songlist__import_input_tip_1": "不支持跨源打开歌单,请确认要打开的歌单与当前选择的歌单来源是否对应",
"songlist__import_input_tip_2": "若遇到无法打开的歌单链接,欢迎反馈",
"songlist__import_input_tip_3": "酷狗源歌单不支持用歌单 ID 或概念版链接打开,但支持用普通版链接或酷狗码打开",
"songlist__import_input_tip_4": "网易源的「我喜欢」歌单需要 Token 才能打开,详情看 ",
"songlist__import_input_tip_4": "网易源「私人雷达」歌单需要 Token 才能打开,企鹅源个人歌单需要将个人主页公开才能打开,请参阅 ",
"songlist__import_input_title": "打开分享的歌单",
"songlist__open_list": "打开「{name}」歌单",
"songlist__tag_info_hot_tag": "热门标签",
+1 -1
View File
@@ -613,7 +613,7 @@
"songlist__import_input_tip_1": "不支援跨平台開啟歌單,請確認要開啟的歌單與目前歌單的來源平台是否對應",
"songlist__import_input_tip_2": "若遇到無法開啟的歌單連結,歡迎回報",
"songlist__import_input_tip_3": "酷狗音樂歌單不支援用歌單 ID 與概念版連結開啟,但支援用普通版連結與酷狗碼開啟",
"songlist__import_input_tip_4": "網易雲音樂的「我喜歡」歌單需要 Token 才能開啟,詳情看 ",
"songlist__import_input_tip_4": "網易雲音樂「私人雷達」歌單需要 Token 才能開啟,企鵝音樂個人歌單需要將個人資料頁公開才能開啟,詳情看 ",
"songlist__import_input_title": "開啟分享的歌單",
"songlist__open_list": "開啟「{name}」歌單",
"songlist__tag_info_hot_tag": "熱門標籤",
+20 -4
View File
@@ -2,7 +2,7 @@ import path from 'node:path'
import { existsSync, mkdirSync, renameSync } from 'fs'
import { app, shell, screen, nativeTheme, dialog } from 'electron'
import { URL_SCHEME_RXP } from '@common/constants'
import { getTheme, initHotKey, initSetting, parseEnvParams } from './utils'
import { getProxy, getTheme, initHotKey, initSetting, parseEnvParams } from './utils'
import { navigationUrlWhiteList } from '@common/config'
import defaultSetting from '@common/defaultSetting'
import { isExistWindow as isExistMainWindow, showWindow as showMainWindow } from './modules/winMain'
@@ -11,6 +11,7 @@ import { isMac, log } from '@common/utils'
import createWorkers from './worker'
import { migrateDBData } from './utils/migrate'
import { openDirInExplorer } from '@common/utils/electron'
import { setProxyByHost } from '@common/utils/request'
export const initGlobalData = () => {
const envParams = parseEnvParams()
@@ -236,14 +237,29 @@ export const listenerAppEvent = (startApp: () => void) => {
global.lx?.event_app.system_theme_change(shouldUseDarkColors)
})
global.lx.event_app.on('updated_config', (config, setting) => {
if (config.includes('player.volume')) {
const setProxy = () => {
const proxy = getProxy()
if (proxy) {
setProxyByHost(proxy.host, proxy.port ? String(proxy.port) : undefined)
} else setProxyByHost()
}
global.lx.event_app.on('updated_config', (keys, setting) => {
if (keys.includes('network.proxy.enable') || (global.lx.appSetting['network.proxy.enable'] && keys.some(k => k.includes('network.proxy.')))) {
setProxy()
}
if (keys.includes('player.volume')) {
global.lx.event_app.player_status({ volume: Math.trunc(setting['player.volume']! * 100) })
}
if (config.includes('player.isMute')) {
if (keys.includes('player.isMute')) {
global.lx.event_app.player_status({ mute: setting['player.isMute'] })
}
})
global.lx.event_app.on('app_inited', () => {
setProxy()
global.lx.event_app.player_status({ volume: Math.trunc(global.lx.appSetting['player.volume'] * 100) })
global.lx.event_app.player_status({ mute: global.lx.appSetting['player.isMute'] })
})
}
const initTheme = () => {
+1
View File
@@ -151,6 +151,7 @@ let client: LX.Sync.Client.Socket | null
// let listSyncPromise: Promise<void>
export const connect = (urlInfo: LX.Sync.Client.UrlInfo, keyInfo: LX.Sync.ClientKeyInfo) => {
client = new WebSocket(`${urlInfo.wsProtocol}//${urlInfo.hostPath}/socket?i=${encodeURIComponent(keyInfo.clientId)}&t=${encodeURIComponent(aesEncrypt(SYNC_CODE.msgConnect, keyInfo.key))}`, {
followRedirects: true,
}) as LX.Sync.Client.Socket
client.data = {
keyInfo,
+1 -2
View File
@@ -3,10 +3,9 @@ import { httpFetch, type RequestOptions } from '@main/utils/request'
import { decodeData, encodeData } from '../utils'
export const request = async(url: string, options: RequestOptions = { }) => {
return httpFetch(url, {
return httpFetch<string>(url, {
...options,
timeout: options.timeout ?? 10000,
follow_max: 5,
}).then(response => {
return {
text: response.body,
+5 -3
View File
@@ -150,9 +150,11 @@ export const createTray = () => {
// tray.setToolTip('LX Music')
// createMenu()
tray.setIgnoreDoubleClickEvents(true)
tray.on('click', () => {
showMainWindow()
})
if (!isMac) {
tray.on('click', () => {
showMainWindow()
})
}
}
export const destroyTray = () => {
+8 -2
View File
@@ -109,14 +109,20 @@ const inflateScript = async(script: string) => new Promise<string>((resolve, rej
})
export const importApi = async(scriptRaw: string): Promise<LX.UserApi.UserApiInfo> => {
let scriptInfo = parseScriptInfo(scriptRaw)
const script = await deflateScript(scriptRaw)
userApis ??= []
for (const api of userApis) {
const existingScript = scripts.get(api.id)
if (existingScript === script) {
throw new Error(`导入失败,脚本内容与已有的源「${api.name}」相同`)
}
}
const apiInfo = {
id: `user_api_${Math.random().toString().substring(2, 5)}_${Date.now()}`,
...scriptInfo,
allowShowUpdateAlert: true,
}
userApis ??= []
userApis.push(apiInfo)
const script = await deflateScript(scriptRaw)
scripts.set(apiInfo.id, script)
saveData()
return apiInfo
+12 -3
View File
@@ -1,7 +1,8 @@
import { isLinux } from '@common/utils'
import { closeWindow, createWindow, getBounds, isExistWindow, alwaysOnTopTools, setBounds, setIgnoreMouseEvents, setSkipTaskbar } from './main'
import { sendConfigChange } from './rendererEvent'
import { sendConfigChange, sendMouseLeave } from './rendererEvent'
import { buildLyricConfig, getLyricWindowBounds, initWindowSize, watchConfigKeys } from './utils'
import { mouseCheckTools } from './mouseCheckTools'
let isLock: boolean
let isEnable: boolean
@@ -21,14 +22,21 @@ export const setLrcConfig = (keys: Array<keyof LX.AppSetting>, setting: Partial<
isLock = global.lx.appSetting['desktopLyric.isLock']
if (global.lx.appSetting['desktopLyric.isLock']) {
setIgnoreMouseEvents(true, { forward: !isLinux && global.lx.appSetting['desktopLyric.isHoverHide'] })
mouseCheckTools.runCheck(sendMouseLeave)
} else {
setIgnoreMouseEvents(false, { forward: !isLinux && global.lx.appSetting['desktopLyric.isHoverHide'] })
mouseCheckTools.cacnelCheck()
}
}
if (keys.includes('desktopLyric.isHoverHide') && isHoverHide != global.lx.appSetting['desktopLyric.isHoverHide']) {
isHoverHide = global.lx.appSetting['desktopLyric.isHoverHide']
if (!isLinux) {
setIgnoreMouseEvents(global.lx.appSetting['desktopLyric.isLock'], { forward: global.lx.appSetting['desktopLyric.isHoverHide'] })
setIgnoreMouseEvents(global.lx.appSetting['desktopLyric.isLock'], { forward: isHoverHide })
if (isHoverHide) {
mouseCheckTools.runCheck(sendMouseLeave)
} else {
mouseCheckTools.cacnelCheck()
}
}
}
if (keys.includes('desktopLyric.isAlwaysOnTop') && isAlwaysOnTop != global.lx.appSetting['desktopLyric.isAlwaysOnTop']) {
@@ -54,7 +62,7 @@ export const setLrcConfig = (keys: Array<keyof LX.AppSetting>, setting: Partial<
if (keys.includes('desktopLyric.isLockScreen') && isLockScreen != global.lx.appSetting['desktopLyric.isLockScreen']) {
isLockScreen = global.lx.appSetting['desktopLyric.isLockScreen']
if (global.lx.appSetting['desktopLyric.isLockScreen']) {
setBounds(getLyricWindowBounds(getBounds(), {
setBounds(getLyricWindowBounds(getBounds()!, {
x: 0,
y: 0,
w: global.lx.appSetting['desktopLyric.width'],
@@ -77,6 +85,7 @@ export const setLrcConfig = (keys: Array<keyof LX.AppSetting>, setting: Partial<
createWindow()
} else {
alwaysOnTopTools.clearLoop()
mouseCheckTools.cacnelCheck()
closeWindow()
}
}
+2 -2
View File
@@ -172,8 +172,8 @@ export const sendEvent = <T = any>(name: string, params?: T) => {
mainSend(browserWindow, name, params)
}
export const getBounds = (): Electron.Rectangle => {
if (!browserWindow) throw new Error('window is not available')
export const getBounds = (): Electron.Rectangle | null => {
if (!browserWindow) return null
return browserWindow.getBounds()
}
@@ -0,0 +1,57 @@
import { screen } from 'electron'
import { getBounds } from './main'
import { isLinux } from '@common/utils'
export const mouseCheckTools = {
timer: null as NodeJS.Timeout | null,
isMouseInWindow: false,
sendMouseLeave: null as (() => void) | null,
onInBoundsChange(isInBounds: boolean) {
if (isInBounds) return
this.stop()
this.sendMouseLeave?.()
},
checkMousePosition() {
const bounds = getBounds()
if (!bounds) return
const point = screen.getCursorScreenPoint()
const isInBounds = point.x >= bounds.x && point.x <= bounds.x + bounds.width &&
point.y >= bounds.y && point.y <= bounds.y + bounds.height
if (isInBounds !== this.isMouseInWindow) {
this.isMouseInWindow = isInBounds
this.onInBoundsChange(isInBounds)
}
},
start() {
this.timer = setTimeout(() => {
this.timer = null
this.checkMousePosition()
this.start()
}, 500)
},
stop() {
if (!this.timer) return
clearTimeout(this.timer)
this.timer = null
// console.log('stop check')
},
setMouseInWindow(isInWindow: boolean) {
this.isMouseInWindow = isInWindow
},
runCheck(sendMouseLeave: () => void) {
if (isLinux || !global.lx.appSetting['desktopLyric.isLock'] || !global.lx.appSetting['desktopLyric.isHoverHide'] || !this.isMouseInWindow) return
// console.log('run check')
this.sendMouseLeave = sendMouseLeave
this.stop()
this.start()
},
cacnelCheck() {
this.stop()
},
}
+15 -1
View File
@@ -5,6 +5,7 @@ import { buildLyricConfig, getLyricWindowBounds } from './utils'
import { sendNewDesktopLyricClient } from '@main/modules/winMain'
import { getBounds, getMainFrame, sendEvent, setBounds, setResizeable } from './main'
import { MessageChannelMain } from 'electron'
import { mouseCheckTools } from './mouseCheckTools'
export default () => {
@@ -26,7 +27,7 @@ export default () => {
})
mainOn<LX.DesktopLyric.NewBounds>(WIN_LYRIC_RENDERER_EVENT_NAME.set_win_bounds, ({ params: options }) => {
setBounds(getLyricWindowBounds(getBounds(), options))
setBounds(getLyricWindowBounds(getBounds()!, options))
})
mainOn<boolean>(WIN_LYRIC_RENDERER_EVENT_NAME.set_win_resizeable, ({ params: resizable }) => {
@@ -45,6 +46,16 @@ export default () => {
// without going through the main process!
console.log('request_main_window_channel')
})
mainOn<boolean>(WIN_LYRIC_RENDERER_EVENT_NAME.mouse_enter_leave, ({ params: isEnter }) => {
if (isEnter) {
mouseCheckTools.setMouseInWindow(true)
mouseCheckTools.runCheck(sendMouseLeave)
} else {
mouseCheckTools.setMouseInWindow(false)
mouseCheckTools.cacnelCheck()
}
})
}
export const sendConfigChange = (setting: Partial<LX.DesktopLyric.Config>) => {
@@ -55,3 +66,6 @@ export const sendMainWindowInitedEvent = () => {
sendEvent(WIN_LYRIC_RENDERER_EVENT_NAME.main_window_inited)
}
export const sendMouseLeave = () => {
sendEvent(WIN_LYRIC_RENDERER_EVENT_NAME.mouse_enter_leave, false)
}
@@ -3,8 +3,6 @@ import { registerRendererEvents as list } from '@main/modules/commonRenderers/li
import { registerRendererEvents as dislike } from '@main/modules/commonRenderers/dislike'
import app, { sendConfigChange } from './app'
import hotKey from './hotKey'
import kw_decodeLyric from './kw_decodeLyric'
import tx_decodeLyric from './tx_decodeLyric'
import userApi from './userApi'
import sync from './sync'
import data from './data'
@@ -30,8 +28,6 @@ export default () => {
dislike(sendEvent)
app()
hotKey()
kw_decodeLyric()
tx_decodeLyric()
userApi()
sync()
data()
@@ -1,53 +0,0 @@
import { inflate } from 'zlib'
import iconv from 'iconv-lite'
import { mainHandle } from '@common/mainIpc'
import { WIN_MAIN_RENDERER_EVENT_NAME } from '@common/ipcNames'
const handleInflate = async(data: Buffer) => {
return new Promise((resolve: (result: Buffer) => void, reject) => {
inflate(data, (err, result) => {
if (err) {
reject(err)
return
}
resolve(result)
})
})
}
const buf_key = Buffer.from('yeelion')
const buf_key_len = buf_key.length
const decodeLyric = async(buf: Buffer, isGetLyricx: boolean) => {
// const info = buf.slice(0, index).toString()
// if (!info.startsWith('tp=content')) return null
// const isLyric = info.includes('\r\nlrcx=0\r\n')
if (buf.toString('utf8', 0, 10) != 'tp=content') return ''
// const index = buf.indexOf('\r\n\r\n') + 4
const lrcData = await handleInflate(buf.subarray(buf.indexOf('\r\n\r\n') + 4))
if (!isGetLyricx) return iconv.decode(lrcData, 'gb18030')
const buf_str = Buffer.from(lrcData.toString(), 'base64')
const buf_str_len = buf_str.length
const output = new Uint8Array(buf_str_len)
let i = 0
while (i < buf_str_len) {
let j = 0
while (j < buf_key_len && i < buf_str_len) {
output[i] = buf_str[i] ^ buf_key[j]
i++
j++
}
}
return iconv.decode(Buffer.from(output), 'gb18030')
}
export default () => {
mainHandle<{ lrcBase64: string, isGetLyricx: boolean }, string>(WIN_MAIN_RENDERER_EVENT_NAME.handle_kw_decode_lyric, async({ params: { lrcBase64, isGetLyricx } }) => {
const lrc = await decodeLyric(Buffer.from(lrcBase64, 'base64'), isGetLyricx)
return Buffer.from(lrc).toString('base64')
})
}
@@ -1,61 +0,0 @@
import { createInflate, constants as zlibConstants } from 'node:zlib'
// import path from 'path'
import { mainHandle } from '@common/mainIpc'
import { WIN_MAIN_RENDERER_EVENT_NAME } from '@common/ipcNames'
// eslint-disable-next-line @typescript-eslint/dot-notation, @typescript-eslint/quotes
// const require = module[`require`].bind(module)
let qrc_decode: (buf: Buffer, len: number) => Buffer
const inflate = async(lrcBuf: Buffer) => new Promise<string>((resolve, reject) => {
const buffer_builder: Buffer[] = []
const decompress_stream = createInflate()
.on('data', (chunk) => {
buffer_builder.push(chunk)
})
.on('close', () => {
resolve(Buffer.concat(buffer_builder).toString())
})
.on('error', (err: any) => {
// console.log(err)
if (err.errno !== zlibConstants.Z_BUF_ERROR) { // EOF: expected
reject(err)
}
})
// decompress_stream.write(lrcBuf)
decompress_stream.end(lrcBuf)
})
const decode = async(str: string): Promise<string> => {
if (!str) return ''
const buf = Buffer.from(str, 'hex')
return inflate(qrc_decode(buf, buf.length))
}
// 感谢某位不愿透露姓名的大佬提供的C++算法源码,但由于作者不希望公开,所以将会以预构建二进制文件的形式加入代码仓库中
const handleDecode = async(lrc: string, tlrc: string, rlrc: string) => {
if (!qrc_decode) {
// const nativeBindingPath = path.join(__dirname, '../build/Release/qrc_decode.node')
// const nativeBindingPath = process.env.NODE_ENV !== 'production' ? path.join(__dirname, '../build/Release/qrc_decode.node')
// eslint-disable-next-line @typescript-eslint/no-var-requires
const addon = require('qrc_decode.node')
// console.log(addon)
qrc_decode = addon.qrc_decode
}
const [lyric, tlyric, rlyric] = await Promise.all([decode(lrc), decode(tlrc), decode(rlrc)])
return {
lyric,
tlyric,
rlyric,
}
}
export default () => {
mainHandle<{ lrc: string, tlrc: string, rlrc: string }, { lyric: string, tlyric: string, rlyric: string }>(WIN_MAIN_RENDERER_EVENT_NAME.handle_tx_decode_lyric, async({ params: { lrc, tlrc, rlrc } }) => {
return handleDecode(lrc, tlrc, rlrc)
})
}
+4 -4
View File
@@ -5,10 +5,10 @@
"./types"
],
"paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */
"@common/*": ["common/*"],
"@main/*": ["main/*"],
"@static/*": ["static/*"],
"@/*": ["./*"],
"@common/*": ["../common/*"],
"@main/*": ["../main/*"],
"@static/*": ["../static/*"],
"@/*": ["../*"],
},
},
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { encodePath, isUrl, throttle } from '@common/utils'
import { encodePath, isUrl, throttle, isMac } from '@common/utils'
import migrateSetting from '@common/utils/migrateSetting'
import getStore from '@main/utils/store'
import { STORE_NAMES, URL_SCHEME_RXP } from '@common/constants'
@@ -293,7 +293,7 @@ export const setPowerSaveBlocker = (enabled: boolean) => {
let isEnabled = powerSaveBlockerId != null && powerSaveBlocker.isStarted(powerSaveBlockerId)
if (enabled) {
if (isEnabled) return
powerSaveBlockerId = powerSaveBlocker.start('prevent-app-suspension')
powerSaveBlockerId = powerSaveBlocker.start(isMac ? 'prevent-display-sleep' : 'prevent-app-suspension')
} else {
if (!isEnabled) return
powerSaveBlocker.stop(powerSaveBlockerId!)
+4 -131
View File
@@ -1,8 +1,5 @@
import needle, { type NeedleHttpVerbs, type NeedleOptions, type BodyData, type NeedleCallback, type NeedleResponse } from 'needle'
// import progress from 'request-progress'
import { httpOverHttp, httpsOverHttp } from 'tunnel'
import { type ClientRequest } from 'node:http'
import { getProxy } from './index'
import { request, type Options } from '@common/utils/request'
// import fs from 'fs'
export const requestMsg = {
@@ -14,53 +11,6 @@ export const requestMsg = {
cancelRequest: '取消http请求',
} as const
const httpsRxp = /^https:/
const getRequestAgent = (url: string) => {
const proxy = getProxy()
return proxy ? (httpsRxp.test(url) ? httpsOverHttp : httpOverHttp)({ proxy }) : undefined
}
export interface RequestOptions extends NeedleOptions {
method?: NeedleHttpVerbs
body?: BodyData
form?: BodyData
formData?: BodyData
}
export type RequestCallback = NeedleCallback
type RequestResponse = NeedleResponse
const request = (url: string, options: RequestOptions, callback: RequestCallback): ClientRequest => {
let data: BodyData = null
if (options.body) {
data = options.body
} else if (options.form) {
data = options.form
// data.content_type = 'application/x-www-form-urlencoded'
options.json = false
} else if (options.formData) {
data = options.formData
// data.content_type = 'multipart/form-data'
options.json = false
}
options.response_timeout = options.timeout
return needle.request(options.method ?? 'get', url, data, options, (err, resp, body) => {
if (!err) {
body = resp.body = resp.raw.toString()
try {
resp.body = JSON.parse(resp.body)
} catch (_) {}
body = resp.body
}
callback(err, resp, body)
// @ts-expect-error
}).request
}
const defaultHeaders = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36',
}
// var proxyUrl = "http://" + user + ":" + password + "@" + host + ":" + port;
// var proxiedRequest = request.defaults({'proxy': proxyUrl});
@@ -68,70 +18,13 @@ const defaultHeaders = {
// abort: () => void
// }
/**
* promise 形式的请求方法
* @param {*} url
* @param {*} options
*/
const buildHttpPromose = async(url: string, options: RequestOptions): Promise<RequestResponse> => {
return new Promise((resolve, reject) => {
void fetchData(url, options.method, options, (err, resp, body) => {
// options.isShowProgress && window.api.hideProgress()
// debugRequest && console.log(`\n---response------${url}------------`)
// debugRequest && console.log(body)
// obj.requestObj = null
// obj.cancelFn = null
if (err) {
reject(err)
return
}
resolve(resp)
})
// .then(request => {
// // obj.requestObj = ro
// // if (obj.isCancelled) obj.cancelHttp()
// promise.abort = () => {
// request.destroy(new Error('cancelled'))
// }
// })
})
// let obj = {
// isCancelled: false,
// cancelHttp: () => {
// if (!obj.requestObj) return obj.isCancelled = true
// cancelHttp(obj.requestObj)
// obj.requestObj = null
// obj.promise = obj.cancelHttp = null
// obj.cancelFn(new Error(requestMsg.cancelRequest))
// obj.cancelFn = null
// },
// }
// obj.promise = new Promise((resolve, reject) => {
// obj.cancelFn = reject
// debugRequest && console.log(`\n---send request------${url}------------`)
// fetchData(url, options.method, options, (err, resp, body) => {
// // options.isShowProgress && window.api.hideProgress()
// debugRequest && console.log(`\n---response------${url}------------`)
// debugRequest && console.log(body)
// obj.requestObj = null
// obj.cancelFn = null
// if (err) { reject(err); return }
// resolve(resp)
// }).then(ro => {
// obj.requestObj = ro
// if (obj.isCancelled) obj.cancelHttp()
// })
// })
// return obj
}
/**
* 请求超时自动重试
* @param {*} url
* @param {*} options
*/
export const httpFetch = async(url: string, options: RequestOptions = { method: 'get' }) => {
return buildHttpPromose(url, options).catch(async(err: any) => {
export const httpFetch = async<T = unknown> (url: string, options: Options) => {
return request<T>(url, options).catch(async(err: any) => {
// console.log('出错', err)
if (err.message === 'socket hang up') {
// window.globalObj.apiSource = 'temp'
@@ -166,24 +59,4 @@ export const httpFetch = async(url: string, options: RequestOptions = { method:
// return requestPromise
}
const fetchData = async(url: string, method: RequestOptions['method'], {
headers = {},
format = 'json',
timeout = 15000,
...options
}, callback: RequestCallback) => {
// console.log(url, options)
console.log('---start---', url)
headers = Object.assign({}, headers)
return request(url, {
...options,
method,
headers: Object.assign({}, defaultHeaders, headers),
timeout,
agent: getRequestAgent(url),
json: format === 'json',
}, (err, resp, body) => {
callback(err, resp, body)
})
}
export type RequestOptions = Options
@@ -17,6 +17,7 @@ import {
createMusicInfoOrderClearStatement,
createMusicInfoByListAndMusicInfoIdQueryStatement,
createMusicInfoByMusicInfoIdQueryStatement,
createMusicInfoOrderStatement,
} from './statements'
const idFixRxp = /\.0$/
@@ -368,3 +369,14 @@ export const overwriteListData = (lists: LX.DBService.UserListInfo[], musicInfos
})(lists, musicInfos)
}
/**
* 获取列表内音乐的排序
* @param listId 列表id
* @param musicInfoId 音乐id
* @returns 音乐排序信息
*/
export const getMusicInfoOrder = (listId: string, musicInfoId: string) => {
const musicInfoOrderStatement = createMusicInfoOrderStatement()
return musicInfoOrderStatement.get({ listId, musicInfoId })
}
@@ -18,10 +18,12 @@ import {
updateMusicInfoOrder,
updateMusicInfos,
updateUserLists as updateUserListsFromDB,
getMusicInfoOrder,
} from './dbHelper'
let userLists: LX.DBService.UserListInfo[]
let musicLists = new Map<string, LX.Music.MusicInfo[]>()
let rawPoss = new Map<string, number>()
const toDBMusicInfo = (musicInfos: LX.Music.MusicInfo[], listId: string, offset: number = 0): LX.DBService.MusicInfo[] => {
return musicInfos.map((info, index) => {
@@ -41,8 +43,10 @@ const toDBMusicInfo = (musicInfos: LX.Music.MusicInfo[], listId: string, offset:
export const getAllUserList = (): LX.List.UserListInfo[] => {
userLists ??= queryAllUserList()
rawPoss.clear()
return userLists.map(list => {
const { position, ...newList } = list
rawPoss.set(list.id, position)
return newList
})
}
@@ -55,10 +59,15 @@ export const getAllUserList = (): LX.List.UserListInfo[] => {
export const createUserLists = (position: number, lists: LX.List.UserListInfo[]) => {
userLists ??= queryAllUserList()
if (position < 0 || position >= userLists.length) {
// 如果是最末尾,那么取最后一个列表的原始位置 + 1 作为新列表的原始位置,否则新列表的原始位置为 position
// 因为原始 pos 可能比 userLists.length 大,所以不能直接用 userLists.length 作为新列表的原始位置
const order = userLists.length ? (rawPoss.get(userLists.at(-1)!.id) ?? userLists.length) + 1 : 0
const newLists: LX.DBService.UserListInfo[] = lists.map((list, index) => {
const pos = order + index
rawPoss.set(list.id, pos)
return {
...list,
position: position + index,
position: pos,
}
})
insertUserLists(newLists)
@@ -72,6 +81,8 @@ export const createUserLists = (position: number, lists: LX.List.UserListInfo[])
})
insertUserLists(newUserLists, true)
userLists = newUserLists
rawPoss.clear()
for (const list of userLists) rawPoss.set(list.id, list.position)
}
}
@@ -97,6 +108,7 @@ export const createUserLists = (position: number, lists: LX.List.UserListInfo[])
export const removeUserLists = (ids: string[]) => {
deleteUserLists(ids)
userLists &&= queryAllUserList()
for (const id of ids) rawPoss.delete(id)
}
/**
@@ -147,6 +159,8 @@ export const updateUserListsPosition = (position: number, ids: string[]) => {
})
insertUserLists(newUserLists, true)
userLists = newUserLists
rawPoss.clear()
for (const list of userLists) rawPoss.set(list.id, list.position)
}
/**
@@ -210,10 +224,14 @@ export const musicsAdd = (listId: string, musicInfos: LX.Music.MusicInfo[], addM
arrUnshift(targetList, musicInfos)
break
case 'bottom':
default:
insertMusicInfoList(toDBMusicInfo(musicInfos, listId, targetList.length))
default: {
// 如果是添加到最后,那么取最后一个歌曲的原始位置 + 1 作为新歌曲的原始位置,否则新歌曲的原始位置为 position
// 因为原始 pos 可能比 targetList.length 大,所以不能直接用 targetList.length 作为新歌曲的原始位置
const order = targetList.length ? (getMusicInfoOrder(listId, targetList.at(-1)!.id)?.order ?? targetList.length) + 1 : 0
insertMusicInfoList(toDBMusicInfo(musicInfos, listId, order))
arrPush(targetList, musicInfos)
break
}
}
}
@@ -363,6 +381,8 @@ export const listDataOverwrite = (myListData: MakeOptional<LX.List.ListDataFull,
if (userLists) userLists.splice(0, userLists.length, ...dbLists)
else userLists = dbLists
rawPoss.clear()
for (const list of userLists) rawPoss.set(list.id, list.position)
musicLists.clear()
musicLists.set(LIST_IDS.DEFAULT, listData.defaultList)
musicLists.set(LIST_IDS.LOVE, listData.loveList)
@@ -145,6 +145,17 @@ export const createMusicInfoByMusicInfoIdQueryStatement = () => {
}
/**
* 创建根据列表Id与音乐id查询音乐排序语句
* @returns 查询语句
*/
export const createMusicInfoOrderStatement = () => {
const db = getDB()
return db.prepare<[LX.DBService.ListMusicInfoQuery], LX.DBService.MusicInfoOrder>(`SELECT "listId", "musicInfoId", "order"
FROM "main"."my_list_music_info_order"
WHERE "listId"=@listId AND "musicInfoId"=@musicInfoId`)
}
/**
* 创建音乐信息排序插入语句
* @returns 插入语句
+5 -4
View File
@@ -1,6 +1,6 @@
<template>
<div id="container" :class="[{ lock: setting['desktopLyric.isLock'] }, { hide: isHide || (isHoverHide && isMouseEnter) }]">
<div id="main" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave" @mousemove="handleMouseMoveMain">
<div id="container" :class="[{ lock: setting['desktopLyric.isLock'] }, { hide: isHide || isHoverHide }]">
<div id="main">
<transition enter-active-class="animated-fast fadeIn" leave-active-class="animated-fast fadeOut">
<div v-show="!setting['desktopLyric.isLock']" class="control-bar">
<layout-control-bar />
@@ -41,7 +41,7 @@ import usePauseHide from '@lyric/useApp/usePauseHide'
const isShowResize = window.os != 'windows'
useCommon()
const { handleMouseDown, handleTouchDown } = useWindowSize()
const { handleMouseMoveMain, isHoverHide, isMouseEnter } = useHoverHide()
const isHoverHide = useHoverHide()
useLyric()
useTheme()
const isHide = usePauseHide()
@@ -87,7 +87,8 @@ body {
}
&.hide {
opacity: .05;
&:hover {
&:not(.lock):hover {
opacity: 1;
}
}
+4 -4
View File
@@ -3,11 +3,11 @@
"compilerOptions": {
"isolatedModules": true,
"paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */
"@common/*": ["common/*"],
"@common/*": ["../common/*"],
// "@renderer/*": ["renderer/*"],
"@lyric/*": ["renderer-lyric/*"],
"@static/*": ["static/*"],
"@root/*": ["./*"],
"@lyric/*": ["../renderer-lyric/*"],
"@static/*": ["../static/*"],
"@root/*": ["../*"],
},
"typeRoots": [ /* Specify multiple folders that act like './node_modules/@types'. */
"./types"
+23 -80
View File
@@ -1,98 +1,41 @@
import { computed, ref } from '@common/utils/vueTools'
import { computed, ref, onBeforeUnmount } from '@common/utils/vueTools'
import { setting } from '@lyric/store/state'
let mouseCheckTools: {
x: number
y: number
preX: number
preY: number
timeout: NodeJS.Timeout | null
handleCheck: (setShow: () => void) => void
handleMove: (x: number, y: number, setShow: () => void) => void
startTimeout: (setShow: () => void) => void
stopTimeout: () => void
} = {
x: 0,
y: 0,
preX: 0,
preY: 0,
timeout: null,
handleCheck(setShow: () => void) {
let xDiff = Math.abs(this.x - this.preX)
let yDiff = Math.abs(this.y - this.preY)
if (xDiff > 8) {
if (this.x > this.preX) {
if (this.x + xDiff * 1.25 > window.innerWidth - 16) {
setShow()
return
}
} else {
if (this.x - xDiff * 1.25 < 8) {
setShow()
return
}
}
}
if (yDiff > 8) {
if (this.y > this.preY) {
if (this.y + yDiff * 1.25 > window.innerHeight - 16) {
setShow()
}
} else {
if (this.y - yDiff * 1.25 < 8) {
setShow()
}
}
}
// setShow(false)
},
handleMove(x: number, y: number, setShow: () => void) {
// console.log(x, y, this.x, this.y)
this.preX = this.x
this.preY = this.y
this.x = x
this.y = y
this.startTimeout(setShow)
},
startTimeout(setShow: () => void) {
this.stopTimeout()
this.timeout = setTimeout(this.handleCheck.bind(this), 200, setShow)
},
stopTimeout() {
if (!this.timeout) return
clearTimeout(this.timeout)
this.timeout = null
},
}
import { sendMouseEnterLeave, onMouseEnterLeave } from '@lyric/utils/ipc'
export default () => {
const isMouseEnter = ref(false)
const isHoverHide = computed(() => {
return setting['desktopLyric.isLock'] && setting['desktopLyric.isHoverHide']
return setting['desktopLyric.isHoverHide'] && isMouseEnter.value
})
const handleMouseMoveMain = (event: MouseEvent) => {
if (!isHoverHide.value) return
const handleMouseMove = () => {
handleMouseEnter()
mouseCheckTools.handleMove(event.clientX, event.clientY, () => {
handleMouseLeave()
})
}
const handleMouseEnter = () => {
if (!isHoverHide.value || isMouseEnter.value) return
if (isMouseEnter.value) return
isMouseEnter.value = true
sendMouseEnterLeave(true)
}
const handleMouseLeave = () => {
if (!isHoverHide.value) return
if (!isMouseEnter.value) return
isMouseEnter.value = false
mouseCheckTools.stopTimeout()
sendMouseEnterLeave(false)
}
return {
isMouseEnter,
isHoverHide,
handleMouseMoveMain,
}
const removeMouseEnterLeaveListener = onMouseEnterLeave(({ params: isEnter }) => {
isMouseEnter.value = isEnter
})
document.body.addEventListener('mousemove', handleMouseMove)
document.body.addEventListener('mouseenter', handleMouseEnter)
document.body.addEventListener('mouseleave', handleMouseLeave)
onBeforeUnmount(() => {
removeMouseEnterLeaveListener()
document.body.removeEventListener('mousemove', handleMouseMove)
document.body.removeEventListener('mouseenter', handleMouseEnter)
document.body.removeEventListener('mouseleave', handleMouseLeave)
})
return isHoverHide
}
+10
View File
@@ -42,6 +42,16 @@ export const onMainWindowInited = (listener: LX.IpcRendererEventListener): Remov
}
}
export const sendMouseEnterLeave = (isEnter: boolean) => {
rendererSend(WIN_LYRIC_RENDERER_EVENT_NAME.mouse_enter_leave, isEnter)
}
export const onMouseEnterLeave = (listener: LX.IpcRendererEventListenerParams<boolean>): RemoveListener => {
rendererOn<boolean>(WIN_LYRIC_RENDERER_EVENT_NAME.mouse_enter_leave, listener)
return () => {
rendererOff(WIN_LYRIC_RENDERER_EVENT_NAME.mouse_enter_leave, listener)
}
}
/**
* On Theme Change
* @param listener LX.IpcRendererEventListenerParams<shouldUseDarkColors: boolean>
@@ -66,6 +66,7 @@ import {
import { appSetting } from '@renderer/store/setting'
import { togglePlay, playNext, playPrev } from '@renderer/core/player'
import { LIST_IDS } from '@common/constants'
import { formatMusicName } from '@renderer/utils'
export default {
name: 'CorePlayBar',
@@ -112,7 +113,7 @@ export default {
const title = computed(() => {
return musicInfo.name
? appSetting['download.fileName'].replace('歌名', musicInfo.name).replace('歌手', musicInfo.singer)
? formatMusicName(appSetting['download.fileName'], musicInfo.name, musicInfo.singer)
: ''
})
@@ -66,6 +66,7 @@ import {
import { appSetting } from '@renderer/store/setting'
import { togglePlay, playNext, playPrev } from '@renderer/core/player'
import { LIST_IDS } from '@common/constants'
import { formatMusicName } from '@renderer/utils'
export default {
name: 'CorePlayBar',
@@ -112,7 +113,7 @@ export default {
const title = computed(() => {
return musicInfo.name
? appSetting['download.fileName'].replace('歌名', musicInfo.name).replace('歌手', musicInfo.singer)
? formatMusicName(appSetting['download.fileName'], musicInfo.name, musicInfo.singer)
: ''
})
@@ -68,6 +68,7 @@ import {
import { appSetting } from '@renderer/store/setting'
import { togglePlay, playNext, playPrev } from '@renderer/core/player'
import { LIST_IDS } from '@common/constants'
import { formatMusicName } from '@renderer/utils'
export default {
name: 'CorePlayBar',
@@ -114,7 +115,7 @@ export default {
const title = computed(() => {
return musicInfo.name
? appSetting['download.fileName'].replace('歌名', musicInfo.name).replace('歌手', musicInfo.singer)
? formatMusicName(appSetting['download.fileName'], musicInfo.name, musicInfo.singer)
: ''
})
@@ -243,6 +243,7 @@ export default {
// border-bottom: 2px solid var(--color-primary);
// border-color: var(--color-primary);
border: none;
min-width: 0;
outline: none;
// height: @height-toolbar * .7;
+13 -4
View File
@@ -51,7 +51,7 @@ let convolverDynamicsCompressor: DynamicsCompressorNode
let gainNode: GainNode
let panner: PannerNode
let pitchShifterNode: AudioWorkletNode
let pitchShifterNodePitchFactor: AudioParam | null
let pitchShifterNodePitchFactor: AudioParam
let pitchShifterNodeLoadStatus: 'none' | 'loading' | 'unconnect' | 'connected' = 'none'
let pitchShifterNodeTempValue = 1
let defaultChannelCount = 2
@@ -65,6 +65,16 @@ export const createAudio = () => {
audio.autoplay = true
audio.preload = 'auto'
audio.crossOrigin = 'anonymous'
// https://developer.chrome.com/blog/autoplay
audio.addEventListener('playing', () => {
if (audioContext?.state == 'suspended') {
void audioContext.resume().catch((err) => {
console.error('Resume audio context failed:', err)
throw err
})
}
})
}
const initAnalyser = () => {
@@ -322,7 +332,7 @@ const connectPitchShifterNode = () => {
// convolverDynamicsCompressor.connect(pitchShifterNode)
// pitchShifterNode.connect(panner)
pitchShifterNodeLoadStatus = 'connected'
pitchShifterNodePitchFactor!.value = pitchShifterNodeTempValue
pitchShifterNodePitchFactor.value = pitchShifterNodeTempValue
}
const disconnectPitchShifterNode = () => {
console.log('disconnect Pitch Shifter Node')
@@ -331,7 +341,6 @@ const disconnectPitchShifterNode = () => {
lastBiquadFilter.connect(convolver)
lastBiquadFilter.connect(convolverSourceGainNode)
pitchShifterNodeLoadStatus = 'unconnect'
pitchShifterNodePitchFactor = null
audio!.removeEventListener('playing', connectNode)
audio!.removeEventListener('pause', disconnectNode)
@@ -373,7 +382,7 @@ export const setPitchShifter = (val: number) => {
case 'connected':
// a: 1 = 半音
// value = 2 ** (a / 12)
pitchShifterNodePitchFactor!.value = val
pitchShifterNodePitchFactor.value = val
break
case 'unconnect':
connectPitchShifterNode()
+5 -5
View File
@@ -3,11 +3,11 @@
"compilerOptions": {
"isolatedModules": true,
"paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */
"@common/*": ["common/*"],
"@renderer/*": ["renderer/*"],
// "@lyric/*": ["renderer-lyric/*"],
"@static/*": ["static/*"],
"@root/*": ["./*"],
"@common/*": ["../common/*"],
"@renderer/*": ["../renderer/*"],
// "@lyric/*": ["../renderer-lyric/*"],
"@static/*": ["../static/*"],
"@root/*": ["../*"],
},
"typeRoots": [ /* Specify multiple folders that act like './node_modules/@types'. */
"./types"
+3 -2
View File
@@ -1,7 +1,7 @@
import { checkPath, joinPath, extname, basename, readFile, getFileStats } from '@common/utils/nodejs'
import { formatPlayTime } from '@common/utils/common'
import type { IComment } from 'music-metadata/lib/type'
import { decodeKrc } from '@common/utils/lyricUtils/kg'
import { type IAudioMetadata } from 'music-metadata'
export const checkDownloadFileAvailable = async(musicInfo: LX.Download.ListItem, savePath: string): Promise<boolean> => {
return musicInfo.isComplate && !/\.ape$/.test(musicInfo.metadata.fileName) &&
@@ -164,6 +164,7 @@ export const getLocalMusicFilePic = async(path: string) => {
// return lyricInfo
// }
type IComment = NonNullable<IAudioMetadata['common']['comment']> extends Array<infer U> ? U : never
/**
* 获取歌曲文件歌词
@@ -181,7 +182,7 @@ export const getLocalMusicFileLyric = async(path: string): Promise<LX.Music.Lyri
const { confidence, encoding } = detect(lrcBuf)
console.log('lrc file encoding', confidence, encoding)
if (confidence > 0.8) {
const iconv = await import('iconv-lite')
const iconv = (await import('iconv-lite')).default
if (iconv.encodingExists(encoding)) {
const lrc = iconv.decode(lrcBuf, encoding)
if (lrc) {
@@ -49,7 +49,7 @@ export default {
}
return {
singer: decodeName(formatSingerName(rawData.Singers, 'name')),
name: decodeName(rawData.SongName),
name: decodeName(`${rawData.OriSongName}${rawData.Suffix ? ` ${rawData.Suffix}` : ''}`),
albumName: decodeName(rawData.AlbumName),
albumId: rawData.AlbumID,
songmid: rawData.Audioid,
+48 -56
View File
@@ -67,31 +67,31 @@ export default {
}
*/
const buf_key = Buffer.from('yeelion')
const buf_key_len = buf_key.length
const buildParams = (id, isGetLyricx) => {
let params = `user=12345,web,web,web&requester=localhost&req=1&rid=MUSIC_${id}`
if (isGetLyricx) params += '&lrcx=1'
const buf_str = Buffer.from(params)
const buf_str_len = buf_str.length
const output = new Uint16Array(buf_str_len)
let i = 0
while (i < buf_str_len) {
let j = 0
while (j < buf_key_len && i < buf_str_len) {
output[i] = buf_key[j] ^ buf_str[i]
i++
j++
}
}
return Buffer.from(output).toString('base64')
}
// const buf_key = Buffer.from('yeelion')
// const buf_key_len = buf_key.length
// const buildParams = (id, isGetLyricx) => {
// let params = `user=12345,web,web,web&requester=localhost&req=1&rid=MUSIC_${id}`
// if (isGetLyricx) params += '&lrcx=1'
// const buf_str = Buffer.from(params)
// const buf_str_len = buf_str.length
// const output = new Uint16Array(buf_str_len)
// let i = 0
// while (i < buf_str_len) {
// let j = 0
// while (j < buf_key_len && i < buf_str_len) {
// output[i] = buf_key[j] ^ buf_str[i]
// i++
// j++
// }
// }
// return Buffer.from(output).toString('base64')
// }
// console.log(buildParams('207527604', false))
// console.log(buildParams('207527604', true))
const timeExp = /^\[([\d:.]*)\]{1}/g
const existTimeExp = /\[\d{1,2}:.*\d{1,4}\]/
const existTimeExp = /\[\d{1,2}:.*\d{1,4}]/
const lyricxTag = /^<-?\d+,-?\d+>/
export default {
/* sortLrcArr(arr) {
@@ -168,14 +168,6 @@ export default {
if (!isLyricx && lrcT.length > lrc.length * 0.3 && lrc.length - lrcT.length > 6) {
throw new Error('failed')
// if (lrc.length * 0.4 < lrcT.length) { // 翻译数量需大于歌词数量的0.4倍,否则认为没有翻译
// const tItem = lrc.pop()
// tItem.time = lrc[lrc.length - 1].time
// lrcT.push(tItem)
// } else {
// lrc = arr
// lrcT = []
// }
}
return {
@@ -235,35 +227,35 @@ export default {
// },
getLyric(musicInfo, isGetLyricx = true) {
// this.getLyric2(musicInfo)
const requestObj = httpFetch(`http://newlyric.kuwo.cn/newlyric.lrc?${buildParams(musicInfo.songmid, isGetLyricx)}`)
requestObj.promise = requestObj.promise.then(({ statusCode, body, raw }) => {
const requestObj = httpFetch(`http://mlyric.kuwo.cn/mobi.s?f=web&type=lyric&lrcx=${isGetLyricx ? 1 : 0}&rid=${musicInfo.songmid}&encode=utf8`)
requestObj.promise = requestObj.promise.then(async({ statusCode, body, raw }) => {
if (statusCode != 200) return Promise.reject(new Error(JSON.stringify(body)))
return decodeLyric({ lrcBase64: raw.toString('base64'), isGetLyricx }).then(base64Data => {
// let lrcInfo
// try {
// lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString())
// } catch {
// return Promise.reject(new Error('Get lyric failed'))
// }
let lrcInfo
// console.log(Buffer.from(base64Data, 'base64').toString())
try {
lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString())
} catch (err) {
return Promise.reject(new Error('Get lyric failed'))
}
// console.log(lrcInfo)
if (lrcInfo.tlyric) lrcInfo.tlyric = lrcInfo.tlyric.replace(lrcTools.rxps.wordTimeAll, '')
try {
lrcInfo.lxlyric = lrcTools.parse(lrcInfo.lyric)
} catch {
lrcInfo.lxlyric = ''
}
lrcInfo.lyric = lrcInfo.lyric.replace(lrcTools.rxps.wordTimeAll, '')
if (!existTimeExp.test(lrcInfo.lyric)) return Promise.reject(new Error('Get lyric failed'))
// console.log(lrcInfo)
return lrcInfo
})
const lrcText = await decodeLyric(raw, isGetLyricx)
// console.log(lrcText)
// let lrcInfo
// try {
// lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString())
// } catch {
// return Promise.reject(new Error('Get lyric failed'))
// }
let lrcInfo
// console.log(Buffer.from(base64Data, 'base64').toString())
try {
lrcInfo = this.parseLrc(lrcText)
} catch (err) {
return Promise.reject(new Error('Get lyric failed'))
}
// console.log(lrcInfo)
if (lrcInfo.tlyric) lrcInfo.tlyric = lrcInfo.tlyric.replace(lrcTools.rxps.wordTimeAll, '')
try {
lrcInfo.lxlyric = lrcTools.parse(lrcInfo.lyric)
} catch {
lrcInfo.lxlyric = ''
}
lrcInfo.lyric = lrcInfo.lyric.replace(lrcTools.rxps.wordTimeAll, '')
if (!existTimeExp.test(lrcInfo.lyric)) return Promise.reject(new Error('Get lyric failed'))
// console.log(lrcInfo)
return lrcInfo
})
return requestObj
},
+17 -14
View File
@@ -145,22 +145,25 @@ export default {
}))
},
filterList2(rawData) {
// console.log(rawData)
const list = []
const allowedTypes = ['songlist', 'list', 'album']
// digest: 5 = list, 8 = songlist, 13 = album, 4 = artist
rawData.forEach(item => {
if (!item.label) return
list.push(...item.list.map(item => ({
play_count: item.play_count && this.formatPlayCount(item.listencnt),
id: `digest-${item.digest}__${item.id}`,
author: item.uname,
name: item.name,
total: item.total,
// time: item.publish_time,
img: item.img,
grade: item.favorcnt && item.favorcnt / 10,
desc: item.desc,
source: 'kw',
})))
item.list.forEach(item => {
if (!allowedTypes.includes(item.type)) return
list.push({
play_count: item.play_count && this.formatPlayCount(item.listencnt),
id: `digest-${item.digest}__${item.id}`,
author: item.uname,
name: item.name,
total: item.total,
// time: item.publish_time,
img: item.img,
grade: item.favorcnt && item.favorcnt / 10,
desc: item.desc,
source: 'kw',
})
})
})
return list
},
+44 -37
View File
@@ -1,34 +1,43 @@
// import { httpGet, httpFetch } from '../../request'
import { WIN_MAIN_RENDERER_EVENT_NAME } from '@common/ipcNames'
import { rendererInvoke } from '@common/rendererIpc'
import { createCipheriv, createDecipheriv } from 'crypto'
import { toMD5 } from '../utils'
import { inflate } from 'zlib'
// const kw_token = {
// token: null,
// isGetingToken: false,
// }
// const translationMap = {
// "{'": '{"',
// "'}\n": '"}',
// "'}": '"}',
// "':'": '":"',
// "','": '","',
// "':{'": '":{"',
// "':['": '":["',
// "'}],'": '"}],"',
// "':[{'": '":[{"',
// "'},'": '"},"',
// "'},{'": '"},{"',
// "':[],'": '":[],"',
// "':{},'": '":{},"',
// "'}]}": '"}]}',
// }
const handleInflate = async(data) => {
return new Promise((resolve, reject) => {
inflate(data, (err, result) => {
if (err) {
reject(err)
return
}
resolve(result)
})
})
}
// export const objStr2JSON = str => {
// return JSON.parse(str.replace(/(^{'|'}\n$|'}$|':'|','|':\[{'|'}\],'|':{'|'},'|'},{'|':\['|':\[\],'|':{},'|'}]})/g, s => translationMap[s]))
// }
const buf_key = Buffer.from('yeelion')
const buf_key_len = buf_key.length
export const decodeLyric = async(rawData, isGetLyricx) => {
const buf = Buffer.isBuffer(rawData) ? rawData : Buffer.from(rawData)
if (buf.toString('utf8', 0, 10).toLowerCase() !== 'tp=content') return ''
const lrcData = await handleInflate(buf.subarray(buf.indexOf('\r\n\r\n') + 4))
if (!isGetLyricx) return lrcData.toString('utf8')
const buf_str = Buffer.from(lrcData.toString(), 'base64')
const buf_str_len = buf_str.length
const output = new Uint8Array(buf_str_len)
let i = 0
while (i < buf_str_len) {
let j = 0
while (j < buf_key_len && i < buf_str_len) {
output[i] = buf_str[i] ^ buf_key[j]
i++
j++
}
}
return Buffer.from(output).toString('utf8')
}
export const objStr2JSON = str => {
return JSON.parse(str.replace(/('(?=(,\s*')))|('(?=:))|((?<=([:,]\s*))')|((?<={)')|('(?=}))/g, '"'))
@@ -37,13 +46,13 @@ export const objStr2JSON = str => {
export const formatSinger = rawData => rawData.replace(/&/g, '、')
export const matchToken = headers => {
try {
return headers['set-cookie'][0].match(/kw_token=(\w+)/)[1]
} catch (err) {
return null
}
}
// export const matchToken = headers => {
// try {
// return headers['set-cookie'][0].match(/kw_token=(\w+)/)[1]
// } catch (err) {
// return null
// }
// }
// const wait = time => new Promise(resolve => setTimeout(() => resolve(), time))
@@ -63,8 +72,6 @@ export const matchToken = headers => {
// })
// })
export const decodeLyric = base64Data => rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.handle_kw_decode_lyric, base64Data)
// export const tokenRequest = async(url, options = {}) => {
// let token = kw_token.token
// if (!token) token = await getToken()
@@ -88,8 +95,8 @@ export const decodeLyric = base64Data => rendererInvoke(WIN_MAIN_RENDERER_EVENT_
export const lrcTools = {
rxps: {
wordLine: /^(\[\d{1,2}:.*\d{1,4}\])\s*(\S+(?:\s+\S+)*)?\s*/,
tagLine: /\[(ver|ti|ar|al|offset|by|kuwo):\s*(\S+(?:\s+\S+)*)\s*\]/,
wordLine: /^(\[\d{1,2}:.*\d{1,4}])\s*(\S+(?:\s+\S+)*)?\s*/,
tagLine: /\[(ver|ti|ar|al|offset|by|kuwo):\s*(\S+(?:\s+\S+)*)\s*]/,
wordTimeAll: /<(-?\d+),(-?\d+)(?:,-?\d+)?>/g,
wordTime: /<(-?\d+),(-?\d+)(?:,-?\d+)?>/,
},
+1 -1
View File
@@ -75,7 +75,7 @@ const mrcTools = {
},
async getMusicInfo(songInfo) {
return songInfo.mrcUrl == null
? getMusicInfo(songInfo.copyrightId)
? getMusicInfo(songInfo.songmid)
: songInfo
},
getLyric(songInfo) {
+3 -23
View File
@@ -1,28 +1,8 @@
import { httpFetch } from '../../request'
import getSongId from './songId'
import { getMusicInfo } from './musicInfo'
export default {
async getPicUrl(songId, tryNum = 0) {
let requestObj = httpFetch(`http://music.migu.cn/v3/api/music/audioPlayer/getSongPic?songId=${songId}`, {
headers: {
Referer: 'http://music.migu.cn/v3/music/player/audio?from=migu',
},
})
requestObj.promise.then(({ body }) => {
if (body.returnCode !== '000000') {
if (tryNum > 5) return Promise.reject(new Error('图片获取失败'))
let tryRequestObj = this.getPic(songId, ++tryNum)
requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj)
return tryRequestObj.promise
}
let url = body.largePic || body.mediumPic || body.smallPic
if (!/https?:/.test(url)) url = 'http:' + url
return url
})
return requestObj
},
async getPic(songInfo) {
const songId = await getSongId(songInfo)
return this.getPicUrl(songId)
const info = await getMusicInfo(songInfo.songmid)
return info.img
},
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { getMusicInfo } from './musicInfo'
const getSongId = async(mInfo) => {
if (mInfo.songmid != mInfo.copyrightId) return mInfo.songmid
const musicInfo = await getMusicInfo(mInfo.copyrightId)
const musicInfo = await getMusicInfo(mInfo.songmid)
return musicInfo.songmid
}
+6 -3
View File
@@ -1,11 +1,14 @@
import { httpFetch } from '../../request'
import getMusicInfo from './musicInfo'
import { rendererInvoke } from '@common/rendererIpc'
import { WIN_MAIN_RENDERER_EVENT_NAME } from '@common/ipcNames'
import { decodeQrc } from './qrcDecode'
const songIdMap = new Map()
const promises = new Map()
export const decodeLyric = (lrc, tlrc, rlrc) => rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.handle_tx_decode_lyric, { lrc, tlrc, rlrc })
export const decodeLyric = async(lrc, tlrc, rlrc) => ({
lyric: await decodeQrc(lrc),
tlyric: await decodeQrc(tlrc),
rlyric: await decodeQrc(rlrc),
})
const parseTools = {
+49 -62
View File
@@ -1,6 +1,6 @@
import { httpFetch } from '../../request'
import { formatPlayTime, sizeFormate } from '../../index'
import { formatSingerName } from '../utils'
import { signRequest } from './utils'
export default {
limit: 50,
@@ -10,71 +10,58 @@ export default {
successCode: 0,
musicSearch(str, page, limit, retryNum = 0) {
if (retryNum > 5) return Promise.reject(new Error('搜索失败'))
// searchRequest = httpFetch(`https://c.y.qq.com/soso/fcgi-bin/client_search_cp?ct=24&qqmusic_ver=1298&new_json=1&remoteplace=sizer.yqq.song_next&searchid=49252838123499591&t=0&aggr=1&cr=1&catZhida=1&lossless=0&flag_qc=0&p=${page}&n=${limit}&w=${encodeURIComponent(str)}&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8&notice=0&platform=yqq&needNewCode=0`)
// const searchRequest = httpFetch(`https://shc.y.qq.com/soso/fcgi-bin/client_search_cp?ct=24&qqmusic_ver=1298&remoteplace=txt.yqq.top&aggr=1&cr=1&catZhida=1&lossless=0&flag_qc=0&p=${page}&n=${limit}&w=${encodeURIComponent(str)}&cv=4747474&ct=24&format=json&inCharset=utf-8&outCharset=utf-8&notice=0&platform=yqq.json&needNewCode=0&uin=0&hostUin=0&loginUin=0`)
const searchRequest = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
method: 'post',
headers: {
'User-Agent': 'QQMusic 14090508(android 12)',
const searchRequest = signRequest({
comm: {
_channelid: '0',
_os_version: '6.2.9200-2',
ct: '19',
cv: '2151',
guid: '1F70E520B2EAA7D25E11760783C53CA9',
patch: '118',
psrf_access_token_expiresAt: 0,
psrf_qqaccess_token: '',
psrf_qqopenid: '',
psrf_qqunionid: '',
tmeAppID: 'qqmusic',
tmeLoginType: 0,
uin: '0',
wid: '7223299733393904640',
},
body: {
comm: {
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',
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: 'DoSearchForQQMusicMobile',
param: {
search_type: 0,
query: str,
page_num: page,
num_per_page: limit,
highlight: 0,
nqc_flag: 0,
multi_zhida: 0,
cat: 2,
grp: 1,
sin: 0,
sem: 0,
},
'music.search.SearchCgiService': {
module: 'music.search.SearchCgiService',
method: 'DoSearchForQQMusicDesktop',
param: {
grp: 1,
num_per_page: limit,
page_num: page,
query: str,
remoteplace: 'txt.newclient.top',
search_type: 0,
searchid: this.getSearchId(),
},
},
})
// 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 }) => {
return searchRequest.then(({ body }) => {
// console.log(body)
if (body.code != this.successCode || body.req.code != this.successCode) return this.musicSearch(str, page, limit, ++retryNum)
return body.req.data
const req = body?.['music.search.SearchCgiService'] ?? body?.req
if (!req || body.code != this.successCode || req.code != this.successCode) {
return this.musicSearch(str, page, limit, ++retryNum)
}
return req.data
})
},
/**
* PC 客户端版 searchid:32 位大写十六进制 GUID + 5 位补零随机数 = 37 字符。
* 对应 QQ 音乐 PC 端 searchid 形状(服务端只需要唯一的会话 ID,形状一致即可)。
*/
getSearchId() {
let guid = ''
for (let i = 0; i < 32; i++) guid += Math.floor(Math.random() * 16).toString(16)
return guid.toUpperCase() + String(Math.floor(Math.random() * 100000)).padStart(5, '0')
},
handleResult(rawList) {
// console.log(rawList)
if (!rawList || !Array.isArray(rawList)) return []
const list = []
rawList.forEach(item => {
if (!item.file?.media_mid) return
@@ -119,11 +106,12 @@ export default {
}
list.push({
singer: formatSingerName(item.singer, 'name'),
name: item.name + (item.title_extra ?? ''),
// name: item.name + (item.title_extra ?? ''),
name: item.title,
albumName,
albumId,
source: 'tx',
interval: formatPlayTime(item.interval),
interval: item.interval ? formatPlayTime(item.interval) : null,
songId: item.id,
albumMid: item.album?.mid ?? '',
strMediaMid: item.file.media_mid,
@@ -141,11 +129,10 @@ export default {
},
search(str, page = 1, limit) {
if (limit == null) limit = this.limit
// http://newlyric.kuwo.cn/newlyric.lrc?62355680
return this.musicSearch(str, page, limit).then(({ body, meta }) => {
let list = this.handleResult(body.item_song)
let list = this.handleResult(body.song.list)
this.total = meta.estimate_sum
this.total = meta.sum
this.page = page
this.allPage = Math.ceil(this.total / limit)
+252
View File
@@ -0,0 +1,252 @@
import { inflate, constants } from 'zlib'
/**
* QRC 歌词解密(3DES-ECB + zlib inflate)
*
* 由原 C++ native 插件(qrc_decode.node)1:1 移植为纯 JS 实现。
* 加密算法为标准 3DES(DES-EDE3,ECB 模式、无填充),密钥固定。
* 数据流程:hex 字符串 -> 字节 -> 3DES 解密 -> zlib inflate -> UTF-8 文本。
*/
const DES_ENCRYPT = 1
const DES_DECRYPT = 0
// DES S-box。注意:QRC 使用的是非标准 DES 变体,
// 其中 S2[23]=15、S4[53]=10 两处与标准 DES 不同(标准为 14、1)。
// 这两个值与原 native 插件(qrc_decode.node)二进制完全一致,不可改为标准值,
// 否则解密结果错误。下表逐字节对应原 C++ 实现。
const sbox = [
[
14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7, 0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8,
4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0, 15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13,
],
[
15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10, 3, 13, 4, 7, 15, 2, 8, 15, 12, 0, 1, 10, 6, 9, 11, 5,
0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15, 13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9,
],
[
10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8, 13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1,
13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7, 1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12,
],
[
7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15, 13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9,
10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4, 3, 15, 0, 6, 10, 10, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14,
],
[
2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9, 14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6,
4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14, 11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3,
],
[
12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11, 10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8,
9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6, 4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13,
],
[
4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1, 13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6,
1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2, 6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12,
],
[
13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7, 1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2,
7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8, 2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11,
],
]
const key_rnd_shift = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1]
const key_perm_c = [
56, 48, 40, 32, 24, 16, 8, 0, 57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59, 51, 43, 35,
]
const key_perm_d = [
62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 60, 52, 44, 36, 28, 20, 12, 4, 27, 19, 11, 3,
]
const key_compression = [
13, 16, 10, 23, 0, 4, 2, 27, 14, 5, 20, 9, 22, 18, 11, 3, 25, 7, 15, 6, 26, 19, 12, 1, 40, 51, 30, 36, 46,
54, 29, 39, 50, 44, 32, 47, 43, 48, 38, 55, 33, 52, 45, 41, 49, 35, 28, 31,
]
// 全部以无符号 32 位处理,>>> 0 保证溢出行为与 C++ uint32_t 一致
const bitnum = (a, b, c) => (((a[((b / 32) | 0) * 4 + 3 - (((b % 32) / 8) | 0)] >>> (7 - (b % 8))) & 1) << c) >>> 0
const bitnum_intr = (a, b, c) => (((a >>> (31 - b)) & 1) << c) >>> 0
const bitnum_intl = (a, b, c) => (((((a << b) >>> 0) & 0x80000000) >>> 0) >>> c) >>> 0
const sbox_bit = a => (a & 32) | ((a & 31) >>> 1) | ((a & 1) << 4)
// 初始置换:返回 [s0, s1]
const initial_permutation = (input) => {
const s0 = (
bitnum(input, 57, 31) | bitnum(input, 49, 30) | bitnum(input, 41, 29) | bitnum(input, 33, 28) |
bitnum(input, 25, 27) | bitnum(input, 17, 26) | bitnum(input, 9, 25) | bitnum(input, 1, 24) |
bitnum(input, 59, 23) | bitnum(input, 51, 22) | bitnum(input, 43, 21) | bitnum(input, 35, 20) |
bitnum(input, 27, 19) | bitnum(input, 19, 18) | bitnum(input, 11, 17) | bitnum(input, 3, 16) |
bitnum(input, 61, 15) | bitnum(input, 53, 14) | bitnum(input, 45, 13) | bitnum(input, 37, 12) |
bitnum(input, 29, 11) | bitnum(input, 21, 10) | bitnum(input, 13, 9) | bitnum(input, 5, 8) |
bitnum(input, 63, 7) | bitnum(input, 55, 6) | bitnum(input, 47, 5) | bitnum(input, 39, 4) |
bitnum(input, 31, 3) | bitnum(input, 23, 2) | bitnum(input, 15, 1) | bitnum(input, 7, 0)
) >>> 0
const s1 = (
bitnum(input, 56, 31) | bitnum(input, 48, 30) | bitnum(input, 40, 29) | bitnum(input, 32, 28) |
bitnum(input, 24, 27) | bitnum(input, 16, 26) | bitnum(input, 8, 25) | bitnum(input, 0, 24) |
bitnum(input, 58, 23) | bitnum(input, 50, 22) | bitnum(input, 42, 21) | bitnum(input, 34, 20) |
bitnum(input, 26, 19) | bitnum(input, 18, 18) | bitnum(input, 10, 17) | bitnum(input, 2, 16) |
bitnum(input, 60, 15) | bitnum(input, 52, 14) | bitnum(input, 44, 13) | bitnum(input, 36, 12) |
bitnum(input, 28, 11) | bitnum(input, 20, 10) | bitnum(input, 12, 9) | bitnum(input, 4, 8) |
bitnum(input, 62, 7) | bitnum(input, 54, 6) | bitnum(input, 46, 5) | bitnum(input, 38, 4) |
bitnum(input, 30, 3) | bitnum(input, 22, 2) | bitnum(input, 14, 1) | bitnum(input, 6, 0)
) >>> 0
return [s0, s1]
}
// 逆置换:将 s0/s1 写回 8 字节 out
const inverse_permutation = (s0, s1, out) => {
out[3] = (bitnum_intr(s1, 7, 7) | bitnum_intr(s0, 7, 6) | bitnum_intr(s1, 15, 5) | bitnum_intr(s0, 15, 4) | bitnum_intr(s1, 23, 3) | bitnum_intr(s0, 23, 2) | bitnum_intr(s1, 31, 1) | bitnum_intr(s0, 31, 0)) & 0xff
out[2] = (bitnum_intr(s1, 6, 7) | bitnum_intr(s0, 6, 6) | bitnum_intr(s1, 14, 5) | bitnum_intr(s0, 14, 4) | bitnum_intr(s1, 22, 3) | bitnum_intr(s0, 22, 2) | bitnum_intr(s1, 30, 1) | bitnum_intr(s0, 30, 0)) & 0xff
out[1] = (bitnum_intr(s1, 5, 7) | bitnum_intr(s0, 5, 6) | bitnum_intr(s1, 13, 5) | bitnum_intr(s0, 13, 4) | bitnum_intr(s1, 21, 3) | bitnum_intr(s0, 21, 2) | bitnum_intr(s1, 29, 1) | bitnum_intr(s0, 29, 0)) & 0xff
out[0] = (bitnum_intr(s1, 4, 7) | bitnum_intr(s0, 4, 6) | bitnum_intr(s1, 12, 5) | bitnum_intr(s0, 12, 4) | bitnum_intr(s1, 20, 3) | bitnum_intr(s0, 20, 2) | bitnum_intr(s1, 28, 1) | bitnum_intr(s0, 28, 0)) & 0xff
out[7] = (bitnum_intr(s1, 3, 7) | bitnum_intr(s0, 3, 6) | bitnum_intr(s1, 11, 5) | bitnum_intr(s0, 11, 4) | bitnum_intr(s1, 19, 3) | bitnum_intr(s0, 19, 2) | bitnum_intr(s1, 27, 1) | bitnum_intr(s0, 27, 0)) & 0xff
out[6] = (bitnum_intr(s1, 2, 7) | bitnum_intr(s0, 2, 6) | bitnum_intr(s1, 10, 5) | bitnum_intr(s0, 10, 4) | bitnum_intr(s1, 18, 3) | bitnum_intr(s0, 18, 2) | bitnum_intr(s1, 26, 1) | bitnum_intr(s0, 26, 0)) & 0xff
out[5] = (bitnum_intr(s1, 1, 7) | bitnum_intr(s0, 1, 6) | bitnum_intr(s1, 9, 5) | bitnum_intr(s0, 9, 4) | bitnum_intr(s1, 17, 3) | bitnum_intr(s0, 17, 2) | bitnum_intr(s1, 25, 1) | bitnum_intr(s0, 25, 0)) & 0xff
out[4] = (bitnum_intr(s1, 0, 7) | bitnum_intr(s0, 0, 6) | bitnum_intr(s1, 8, 5) | bitnum_intr(s0, 8, 4) | bitnum_intr(s1, 16, 3) | bitnum_intr(s0, 16, 2) | bitnum_intr(s1, 24, 1) | bitnum_intr(s0, 24, 0)) & 0xff
}
// Feistel 轮函数 f(state, key)
const des_f = (state, key) => {
const t1 = (
bitnum_intl(state, 31, 0) | (((state & 0xF0000000) >>> 0) >>> 1) | bitnum_intl(state, 4, 5) |
bitnum_intl(state, 3, 6) | (((state & 0x0F000000) >>> 0) >>> 3) | bitnum_intl(state, 8, 11) |
bitnum_intl(state, 7, 12) | (((state & 0x00F00000) >>> 0) >>> 5) | bitnum_intl(state, 12, 17) |
bitnum_intl(state, 11, 18) | (((state & 0x000F0000) >>> 0) >>> 7) | bitnum_intl(state, 16, 23)
) >>> 0
const t2 = (
bitnum_intl(state, 15, 0) | (((state & 0x0000F000) << 15) >>> 0) | bitnum_intl(state, 20, 5) |
bitnum_intl(state, 19, 6) | (((state & 0x00000F00) << 13) >>> 0) | bitnum_intl(state, 24, 11) |
bitnum_intl(state, 23, 12) | (((state & 0x000000F0) << 11) >>> 0) | bitnum_intl(state, 28, 17) |
bitnum_intl(state, 27, 18) | (((state & 0x0000000F) << 9) >>> 0) | bitnum_intl(state, 0, 23)
) >>> 0
const lrgstate = [
(t1 >>> 24) & 0xFF, (t1 >>> 16) & 0xFF, (t1 >>> 8) & 0xFF,
(t2 >>> 24) & 0xFF, (t2 >>> 16) & 0xFF, (t2 >>> 8) & 0xFF,
]
for (let i = 0; i < 6; i++) lrgstate[i] ^= key[i]
let s = (
(sbox[0][sbox_bit(lrgstate[0] >>> 2)] << 28) |
(sbox[1][sbox_bit(((lrgstate[0] & 0x03) << 4) | (lrgstate[1] >>> 4))] << 24) |
(sbox[2][sbox_bit(((lrgstate[1] & 0x0F) << 2) | (lrgstate[2] >>> 6))] << 20) |
(sbox[3][sbox_bit(lrgstate[2] & 0x3F)] << 16) |
(sbox[4][sbox_bit(lrgstate[3] >>> 2)] << 12) |
(sbox[5][sbox_bit(((lrgstate[3] & 0x03) << 4) | (lrgstate[4] >>> 4))] << 8) |
(sbox[6][sbox_bit(((lrgstate[4] & 0x0F) << 2) | (lrgstate[5] >>> 6))] << 4) |
sbox[7][sbox_bit(lrgstate[5] & 0x3F)]
) >>> 0
return (
bitnum_intl(s, 15, 0) | bitnum_intl(s, 6, 1) | bitnum_intl(s, 19, 2) | bitnum_intl(s, 20, 3) |
bitnum_intl(s, 28, 4) | bitnum_intl(s, 11, 5) | bitnum_intl(s, 27, 6) | bitnum_intl(s, 16, 7) |
bitnum_intl(s, 0, 8) | bitnum_intl(s, 14, 9) | bitnum_intl(s, 22, 10) | bitnum_intl(s, 25, 11) |
bitnum_intl(s, 4, 12) | bitnum_intl(s, 17, 13) | bitnum_intl(s, 30, 14) | bitnum_intl(s, 9, 15) |
bitnum_intl(s, 1, 16) | bitnum_intl(s, 7, 17) | bitnum_intl(s, 23, 18) | bitnum_intl(s, 13, 19) |
bitnum_intl(s, 31, 20) | bitnum_intl(s, 26, 21) | bitnum_intl(s, 2, 22) | bitnum_intl(s, 8, 23) |
bitnum_intl(s, 18, 24) | bitnum_intl(s, 12, 25) | bitnum_intl(s, 29, 26) | bitnum_intl(s, 5, 27) |
bitnum_intl(s, 21, 28) | bitnum_intl(s, 10, 29) | bitnum_intl(s, 3, 30) | bitnum_intl(s, 24, 31)
) >>> 0
}
// 单次 DES 加/解密一个 8 字节分组(轮密钥 schedule 为 16x6)
const des_crypt = (input, schedule, output) => {
let [s0, s1] = initial_permutation(input)
for (let i = 0; i < 15; i++) {
const prev = s1
s1 = (des_f(s1, schedule[i]) ^ s0) >>> 0
s0 = prev
}
s0 = (des_f(s1, schedule[15]) ^ s0) >>> 0
inverse_permutation(s0, s1, output)
}
// 生成 16 轮子密钥
const key_schedule = (key, mode) => {
const schedule = Array.from({ length: 16 }, () => new Uint8Array(6))
let c = 0
let d = 0
for (let i = 0; i < 28; i++) {
c = (c | bitnum(key, key_perm_c[i], 31 - i)) >>> 0
d = (d | bitnum(key, key_perm_d[i], 31 - i)) >>> 0
}
for (let i = 0; i < 16; i++) {
c = ((((c << key_rnd_shift[i]) >>> 0) | (c >>> (28 - key_rnd_shift[i]))) & 0xFFFFFFF0) >>> 0
d = ((((d << key_rnd_shift[i]) >>> 0) | (d >>> (28 - key_rnd_shift[i]))) & 0xFFFFFFF0) >>> 0
const togen = mode === DES_DECRYPT ? 15 - i : i
for (let j = 0; j < 24; j++) {
schedule[togen][(j / 8) | 0] |= bitnum_intr(c, key_compression[j], 7 - (j % 8))
}
for (let j = 24; j < 48; j++) {
schedule[togen][(j / 8) | 0] |= bitnum_intr(d, key_compression[j] - 27, 7 - (j % 8))
}
}
return schedule
}
// 3DES(EDE3)密钥编排,返回 3 组 schedule
const tripledes_key_setup = (key, mode) => {
if (mode === DES_ENCRYPT) {
return [
key_schedule(key.subarray(0, 8), DES_ENCRYPT),
key_schedule(key.subarray(8, 16), DES_DECRYPT),
key_schedule(key.subarray(16, 24), DES_ENCRYPT),
]
}
return [
key_schedule(key.subarray(16, 24), DES_DECRYPT),
key_schedule(key.subarray(8, 16), DES_ENCRYPT),
key_schedule(key.subarray(0, 8), DES_DECRYPT),
]
}
// 3DES 加/解密一个 8 字节分组
const tripledes_crypt = (input, schedule, output) => {
const buf = new Uint8Array(8)
des_crypt(input, schedule[0], buf)
des_crypt(buf, schedule[1], output)
des_crypt(output, schedule[2], buf)
output.set(buf)
}
// QRC 固定密钥:!@#)(*$%123ZXC!@!@#)(NHL
const QRC_KEY = Buffer.from([
0x21, 0x40, 0x23, 0x29, 0x28, 0x2a, 0x24, 0x25, 0x31, 0x32, 0x33, 0x5a,
0x58, 0x43, 0x21, 0x40, 0x21, 0x40, 0x23, 0x29, 0x28, 0x4e, 0x48, 0x4c,
])
const handleInflate = (data) => new Promise((resolve, reject) => {
// Z_SYNC_FLUSH:容忍尾部不完整的 zlib 流(对应原实现忽略 Z_BUF_ERROR 的行为)
inflate(data, { finishFlush: constants.Z_SYNC_FLUSH }, (err, result) => {
if (err) reject(err)
else resolve(result)
})
})
/**
* 解密腾讯 QRC 歌词。
* @param {string} hexData 服务端返回的十六进制字符串
* @returns {Promise<string>} 解密并解压后的歌词文本(解析失败时返回空字符串)
*/
export const decodeQrc = async(hexData) => {
if (!hexData || hexData.length % 2 !== 0) return ''
const encrypted = Buffer.from(hexData, 'hex')
if (encrypted.length === 0) return ''
const schedule = tripledes_key_setup(QRC_KEY, DES_DECRYPT)
const block = new Uint8Array(8)
for (let i = 0; i + 8 <= encrypted.length; i += 8) {
tripledes_crypt(encrypted.subarray(i, i + 8), schedule, block)
encrypted.set(block, i)
}
try {
const result = await handleInflate(encrypted)
return result.toString('utf8')
} catch {
return ''
}
}
@@ -193,6 +193,65 @@ export default {
return id
},
// 获取歌曲列表内的音乐
async getListDetail2(id, tryNum = 0) {
if (tryNum > 2) return Promise.reject(new Error('try max num'))
const requestObj_listDetail = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
method: 'post',
headers: {
Origin: 'https://y.qq.com',
Referer: `https://y.qq.com/n/yqq/playsquare/${id}.html`,
},
body: {
comm: {
cv: 4747474,
ct: 24,
format: 'json',
inCharset: 'utf-8',
outCharset: 'utf-8',
platform: 'yqq.json',
needNewCode: 1,
uin: 0,
},
req_1: {
module: 'music.srfDissInfo.aiDissInfo',
method: 'uniform_get_Dissinfo',
param: {
disstid: parseInt(id),
userinfo: 1,
tag: 1,
orderlist: 1,
song_begin: 0,
song_num: this.limit_song,
onlysonglist: 0,
enc_host_uin: '',
},
},
},
})
const { body } = await requestObj_listDetail.promise
// console.log(body)
if (body.code !== this.successCode) return this.getListDetail2(id, ++tryNum)
if (body.req_1.code !== this.successCode) throw new Error('failed')
const result = body.req_1.data
const dirinfo = result.dirinfo
return {
list: this.filterListDetail(result.songlist),
page: 1,
limit: this.limit_song,
total: result.songnum,
source: 'tx',
info: {
name: dirinfo.title,
img: dirinfo.picurl,
desc: decodeName(dirinfo.desc ?? '').replace(/<br>/g, '\n'),
author: dirinfo.host_nick,
play_count: formatPlayCount(dirinfo.listennum),
},
}
},
// 获取歌曲列表内的音乐
async getListDetail(id, tryNum = 0) {
if (tryNum > 2) return Promise.reject(new Error('try max num'))
@@ -206,7 +265,9 @@ export default {
})
const { body } = await requestObj_listDetail.promise
// console.log(body)
if (body.code !== this.successCode) return this.getListDetail(id, ++tryNum)
if (body.subcode !== this.successCode || !body.cdlist) return this.getListDetail2(id)
const cdlist = body.cdlist[0]
return {
list: this.filterListDetail(cdlist.songlist),
@@ -0,0 +1,28 @@
import crypto from 'node:crypto'
const PART_1_INDEXES = [23, 14, 6, 36, 16, 40, 7, 19]
const PART_2_INDEXES = [16, 1, 32, 12, 19, 27, 8, 5]
const SCRAMBLE_VALUES = [89, 39, 179, 150, 218, 82, 58, 252, 177, 52, 186, 123, 120, 64, 242, 133, 143, 161, 121, 179]
async function hashSHA1(data) {
return crypto.createHash('sha1').update(data).digest('hex')
}
function pickHashByIdx(hash, indexes) {
return indexes.map((idx) => hash[idx]).join('')
}
function base64Encode(data) {
return Buffer.from(data)
.toString('base64')
.replace(/[\\/+=]/g, '')
}
export async function zzcSign(text) {
const hash = await hashSHA1(text)
const part1 = pickHashByIdx(hash, PART_1_INDEXES)
const part2 = pickHashByIdx(hash, PART_2_INDEXES)
const part3 = SCRAMBLE_VALUES.map((value, i) => value ^ parseInt(hash.slice(i * 2, i * 2 + 2), 16))
const b64Part = base64Encode(part3).replace(/[\\/+=]/g, '')
return `zzc${part1}${b64Part}${part2}`.toLowerCase()
}
@@ -0,0 +1,16 @@
import { httpFetch } from '../../../request'
import { zzcSign } from './crypto'
export const signRequest = async(data) => {
// console.log(data)
const sign = await zzcSign(JSON.stringify(data))
// console.log('sign', sign)
return httpFetch(`https://u.y.qq.com/cgi-bin/musics.fcg?sign=${sign}`, {
method: 'post',
headers: {
'User-Agent': 'QQMusic 14090508(android 12)',
},
body: data,
}).promise
}
+17 -7
View File
@@ -10,12 +10,21 @@ export default {
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,
// 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),
// })
const searchRequest = eapiRequest('/api/search/song/list/page', {
keyword: str,
needCorrect: '1',
channel: 'typing',
offset: limit * (page - 1),
scene: 'normal',
total: page == 1,
limit,
})
return searchRequest.promise.then(({ body }) => body)
},
@@ -30,6 +39,7 @@ export default {
// console.log(rawList)
if (!rawList) return []
return rawList.map(item => {
item = item.baseInfo.simpleSongData
const types = []
const _types = {}
let size
@@ -87,12 +97,12 @@ export default {
return this.musicSearch(str, page, limit).then(result => {
// console.log(result)
if (!result || result.code !== 200) return this.search(str, page, limit, retryNum)
let list = this.handleResult(result.result.songs || [])
let list = this.handleResult(result.data.resources || [])
// console.log(list)
if (list == null) return this.search(str, page, limit, retryNum)
this.total = result.result.songCount || 0
this.total = result.data.totalCount || 0
this.page = page
this.allPage = Math.ceil(this.total / this.limit)
+2 -1
View File
@@ -80,6 +80,7 @@ import useTaskActions from './useTaskActions'
import useMusicAdd from './useMusicAdd'
import { downloadStatus } from '@renderer/store/download/state'
import { appSetting } from '@renderer/store/setting'
import { formatMusicName } from '@renderer/utils'
export default {
name: 'Download',
@@ -203,7 +204,7 @@ export default {
}
const getName = (downloadInfo) => {
return appSetting['download.fileName'].replace('歌名', downloadInfo.metadata.musicInfo.name).replace('歌手', downloadInfo.metadata.musicInfo.singer)
return formatMusicName(appSetting['download.fileName'], downloadInfo.metadata.musicInfo.name, downloadInfo.metadata.musicInfo.singer)
}
const getTypeName = (quality) => {
return quality == 'flac24bit' ? 'FLAC Hires' : quality?.toUpperCase()
@@ -5,7 +5,7 @@ import { dialog } from '@renderer/plugins/Dialog'
import { useI18n } from '@renderer/plugins/i18n'
import { removeListMusics } from '@renderer/store/list/action'
import { appSetting } from '@renderer/store/setting'
import { toOldMusicInfo } from '@renderer/utils/index'
import { formatMusicName, toOldMusicInfo } from '@renderer/utils/index'
import { addDislikeInfo, hasDislike } from '@renderer/core/dislikeList'
import { playNext } from '@renderer/core/player'
import { playMusicInfo } from '@renderer/store/player/state'
@@ -34,7 +34,7 @@ export default ({ props, list, selectedList, removeAllSelect }) => {
const handleCopyName = index => {
const minfo = list.value[index]
clipboardWriteText(appSetting['download.fileName'].replace('歌名', minfo.name).replace('歌手', minfo.singer))
clipboardWriteText(formatMusicName(appSetting['download.fileName'], minfo.name, minfo.singer))
}
const handleDislikeMusic = async(index) => {
+3 -5
View File
@@ -2,7 +2,7 @@ import { DOWNLOAD_STATUS, QUALITYS } from '@common/constants'
import { filterFileName } from '@common/utils/common'
import { buildLyrics } from './lrcTool'
import fs from 'fs'
import { clipFileNameLength, clipNameLength } from '@common/utils/tools'
import { clipFileNameLength, clipNameLength, formatMusicName } from '@common/utils/tools'
/**
* 保存歌词文件
@@ -14,7 +14,7 @@ export const saveLrc = async(lrcData: LX.Music.LyricInfo, info: {
downloadTlrc: boolean
downloadRlrc: boolean
}) => {
const iconv = await import('iconv-lite')
const iconv = (await import('iconv-lite')).default
const lrc = buildLyrics(lrcData, info.downloadLxlrc, info.downloadTlrc, info.downloadRlrc)
switch (info.format) {
case 'gbk':
@@ -91,9 +91,7 @@ export const createDownloadInfo = (musicInfo: LX.Music.MusicInfoOnline, type: LX
ext,
filePath: '',
listId,
fileName: filterFileName(`${clipFileNameLength(fileName
.replace('歌名', musicInfo.name)
.replace('歌手', clipNameLength(musicInfo.singer)))}.${ext}`),
fileName: filterFileName(`${clipFileNameLength(formatMusicName(fileName, musicInfo.name, clipNameLength(musicInfo.singer)))}.${ext}`),
},
}
// downloadInfo.metadata.filePath = joinPath(savePath, downloadInfo.metadata.fileName)
+23 -3
View File
@@ -243,9 +243,24 @@ export const filterDuplicateMusic = async(list: LX.Music.MusicInfo[], isFilterVa
}
export const searchListMusic = (list: LX.Music.MusicInfo[], text: string) => {
const fullMathNameResults = new Set<LX.Music.MusicInfo>()
const fullMathSingerResults = new Set<LX.Music.MusicInfo>()
const fullMathAlbumResults = new Set<LX.Music.MusicInfo>()
const textLower = text.toLowerCase()
for (const mInfo of list) {
if (mInfo.name?.toLowerCase().includes(textLower)) {
fullMathNameResults.add(mInfo)
} else if (mInfo.singer?.toLowerCase().includes(textLower)) {
fullMathSingerResults.add(mInfo)
} else if (mInfo.meta.albumName?.toLowerCase().includes(textLower)) {
fullMathAlbumResults.add(mInfo)
}
}
let result: LX.Music.MusicInfo[] = []
let rxp = new RegExp(text.split('').map(s => s.replace(/[.*+?^${}()|[\]\\]/, '\\$&')).join('.*') + '.*', 'i')
for (const mInfo of list) {
if (fullMathNameResults.has(mInfo) || fullMathSingerResults.has(mInfo) || fullMathAlbumResults.has(mInfo)) continue
const str = `${mInfo.name}${mInfo.singer}${mInfo.meta.albumName ? mInfo.meta.albumName : ''}`
if (rxp.test(str)) result.push(mInfo)
}
@@ -258,7 +273,12 @@ export const searchListMusic = (list: LX.Music.MusicInfo[], text: string) => {
data: mInfo,
})
}
return sortedList.map(item => item.data).reverse()
return [
...fullMathNameResults.values(),
...fullMathSingerResults.values(),
...fullMathAlbumResults.values(),
...sortedList.map(item => item.data).reverse(),
]
}
/**
@@ -304,7 +324,7 @@ export const createLocalMusicInfos = async(filePaths: string[]): Promise<LX.Musi
* @param isMerge 是否合并
*/
export const exportPlayListToText = async(savePath: string, lists: Array<LX.List.MyDefaultListInfoFull | LX.List.MyLoveListInfoFull | LX.List.UserListInfoFull>, isMerge: boolean) => {
const iconv = await import('iconv-lite')
const iconv = (await import('iconv-lite')).default
if (isMerge) {
await saveStrToFile(savePath,
@@ -328,7 +348,7 @@ export const exportPlayListToCSV = async(savePath: string,
lists: Array<LX.List.MyDefaultListInfoFull | LX.List.MyLoveListInfoFull | LX.List.UserListInfoFull>,
isMerge: boolean,
header: string) => {
const iconv = await import('iconv-lite')
const iconv = (await import('iconv-lite')).default
const filterStr = (str: string) => {
if (!str) return ''
+2 -2
View File
@@ -5,10 +5,10 @@
"target": "ESNext",
"allowJs": true,
"module": "ESNext",
"moduleResolution": "Node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"rootDir": "./",
"outDir": "./dist",
"baseUrl": "./src", /* Specify the base directory to resolve non-relative module names. */
// "paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */
// "@common/*": ["common/*"],
// "@renderer/*": ["renderer/*"],