From 489e920b691a996d6607b4d2e472e4ef8e4a3dc3 Mon Sep 17 00:00:00 2001 From: sqj Date: Mon, 6 Oct 2025 22:54:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=90=9C=E7=B4=A2=E8=81=94=E6=83=B3=20?= =?UTF-8?q?fix:=20=E7=BD=91=E6=98=93=E4=BA=91=E6=AD=8C=E5=8D=95=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E6=95=B0=E9=87=8F=E9=99=90=E5=88=B61000=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guide/api.md | 197 ------- docs/guide/used/playList.md | 2 +- electron.vite.config.ts | 10 +- package.json | 5 +- src/main/services/musicSdk/index.ts | 12 + src/main/services/musicSdk/service.ts | 516 +----------------- src/main/services/musicSdk/type.ts | 3 + src/main/utils/downloadSongs.ts | 508 +++++++++++++++++ src/main/utils/musicSdk/kg/index.js | 4 +- src/main/utils/musicSdk/mg/index.js | 4 +- src/main/utils/musicSdk/tx/index.js | 4 +- src/main/utils/musicSdk/wy/index.js | 5 +- src/preload/index.ts | 2 +- src/renderer/auto-imports.d.ts | 68 +++ src/renderer/components.d.ts | 4 + src/renderer/src/App.vue | 64 ++- src/renderer/src/components/ThemeSelector.vue | 3 + .../src/components/layout/HomeLayout.vue | 29 +- .../src/components/search/searchSuggest.vue | 272 +++++++++ src/renderer/src/main.ts | 21 +- src/renderer/src/store/ControlAudio.ts | 382 ++++++------- src/renderer/src/store/LocalUserDetail.ts | 196 +++---- src/renderer/src/store/Settings.ts | 90 +-- src/renderer/src/store/index.ts | 1 + src/renderer/src/store/search.ts | 12 +- src/renderer/src/views/ThemeDemo.vue | 368 ------------- src/renderer/src/views/music/local.vue | 4 +- src/renderer/src/views/music/search.vue | 15 +- 28 files changed, 1359 insertions(+), 1442 deletions(-) delete mode 100644 docs/guide/api.md create mode 100644 src/main/utils/downloadSongs.ts create mode 100644 src/renderer/src/components/search/searchSuggest.vue create mode 100644 src/renderer/src/store/index.ts delete mode 100644 src/renderer/src/views/ThemeDemo.vue diff --git a/docs/guide/api.md b/docs/guide/api.md deleted file mode 100644 index c71412e..0000000 --- a/docs/guide/api.md +++ /dev/null @@ -1,197 +0,0 @@ -# 音乐API接口文档 - -## 概述 - -这是一个基于 Meting 库的音乐API接口,支持多个音乐平台的数据获取,包括歌曲信息、专辑、歌词、播放链接等。 - -## 基础信息 - -- **请求方式**: GET -- **返回格式**: JSON -- **字符编码**: UTF-8 -- **跨域支持**: 是 - -## 请求参数 - -| 参数名 | 类型 | 必填 | 默认值 | 说明 | -| ------ | ------ | ---- | ------- | -------------- | -| server | string | 否 | netease | 音乐平台 | -| type | string | 否 | search | 请求类型 | -| id | string | 否 | hello | 查询ID或关键词 | - -### 支持的音乐平台 (server) - -| 平台代码 | 平台名称 | -| -------- | ---------- | -| netease | 网易云音乐 | -| tencent | QQ音乐 | -| baidu | 百度音乐 | -| xiami | 虾米音乐 | -| kugou | 酷狗音乐 | -| kuwo | 酷我音乐 | - -### 支持的请求类型 (type) - -| 类型 | 说明 | id参数说明 | -| -------- | ------------ | ---------------- | -| search | 搜索歌曲 | 搜索关键词 | -| song | 获取歌曲详情 | 歌曲ID | -| album | 获取专辑信息 | 专辑ID | -| artist | 获取歌手信息 | 歌手ID | -| playlist | 获取歌单信息 | 歌单ID | -| lrc | 获取歌词 | 歌曲ID | -| url | 获取播放链接 | 歌曲ID | -| pic | 获取封面图片 | 歌曲/专辑/歌手ID | - -## 响应格式 - -### 成功响应 - -```json -{ - "success": true, - "message": { - // 具体数据内容,根据请求类型不同而不同 - } -} -``` - -### 错误响应 - -```json -{ - "success": false, - "message": "错误信息" -} -``` - -## 请求示例 - -### 1. 搜索歌曲 - -``` -GET /?server=netease&type=search&id=周杰伦 -``` - -**响应示例**: - -```json -{ - "success": true, - "message": [ - { - "id": "186016", - "name": "青花瓷", - "artist": ["周杰伦"], - "album": "我很忙", - "pic_id": "109951163240682406", - "url_id": "186016", - "lyric_id": "186016" - } - ] -} -``` - -### 2. 获取歌曲详情 - -``` -GET /?server=netease&type=song&id=186016 -``` - -### 3. 获取歌词 - -``` -GET /?server=netease&type=lrc&id=186016 -``` - -**响应示例**: - -```json -{ - "success": true, - "message": { - "lyric": "[00:00.00] 作词 : 方文山\n[00:01.00] 作曲 : 周杰伦\n[00:22.78]素胚勾勒出青花笔锋浓转淡\n..." - } -} -``` - -### 4. 获取播放链接 - -``` -GET /?server=netease&type=url&id=186016 -``` - -**响应示例**: - -```json -{ - "success": true, - "message": [ - { - "id": "186016", - "url": "http://music.163.com/song/media/outer/url?id=186016.mp3", - "size": 4729252, - "br": 128 - } - ] -} -``` - -### 5. 获取专辑信息 - -``` -GET /?server=netease&type=album&id=18905 -``` - -### 6. 获取歌手信息 - -``` -GET /?server=netease&type=artist&id=6452 -``` - -### 7. 获取歌单信息 - -``` -GET /?server=netease&type=playlist&id=19723756 -``` - -### 8. 获取封面图片 - -``` -GET /?server=netease&type=pic&id=186016 -``` - -## 错误码说明 - -| 错误信息 | 说明 | -| ------------------- | ---------------- | -| require id. | 缺少必需的id参数 | -| unsupported server. | 不支持的音乐平台 | -| unsupported type. | 不支持的请求类型 | - -## 注意事项 - -1. **代理支持**: 如果设置了环境变量 `METING_PROXY`,API会使用代理访问音乐平台 -2. **Cookie支持**: API会自动传递请求中的Cookie到音乐平台 -3. **跨域访问**: API已配置CORS,支持跨域请求 -4. **请求频率**: 建议控制请求频率,避免被音乐平台限制 -5. **数据时效性**: 音乐平台的数据可能会发生变化,建议适当缓存但不要过度依赖 - -## 使用建议 - -1. **错误处理**: 请务必检查响应中的 `success` 字段 -2. **数据验证**: 返回的数据结构可能因平台而异,请做好数据验证 -3. **备用方案**: 建议支持多个音乐平台作为备用数据源 -4. **缓存策略**: 对于不经常变化的数据(如歌词、专辑信息)建议进行缓存 - -## 技术实现 - -本API基于以下技术栈: - -- **PHP**: 后端语言 -- **Meting**: 音乐数据获取库 -- **Composer**: 依赖管理 - -## 更新日志 - -- **v1.0.0**: 初始版本,支持基础的音乐数据获取功能 diff --git a/docs/guide/used/playList.md b/docs/guide/used/playList.md index 86171ca..138f63e 100644 --- a/docs/guide/used/playList.md +++ b/docs/guide/used/playList.md @@ -3,7 +3,7 @@ ## 基础使用 1. 默认情况下,播放 **「搜索」「歌单」** 双击中的歌曲时,会自动将该歌曲添加到 **「列表」** 中的末尾后并不会播放,这与手动将歌曲添加到 **「列表」** 等价,亦或是通过点击歌曲前面小三角播放image-20250916132248046可以添加到歌曲开头也可进行该歌曲添加到 **「列表」** 中的开头并播放。歌曲会按照你选择的播放顺序进行播放。 -2. 对于 **「列表」** 歌曲的顺序可通过展开长按 **1.5s** 后可以进行拖拽排序,歌曲排序实时保存到本地 **LocalStorage**image-20250916133531421 +2. 对于 **「列表」** 歌曲的顺序可通过展开长按 **1.5s** 后可以进行拖拽排序,歌曲排序实时保存到本地 **LocalStorage**image-20250916133531421 ## 歌曲列表的导出和分享 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 6c3df48..8ed5dba 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -6,6 +6,7 @@ import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { TDesignResolver } from '@tdesign-vue-next/auto-import-resolver' import wasm from 'vite-plugin-wasm' +import { NaiveUiResolver } from 'unplugin-vue-components/resolvers' import topLevelAwait from 'vite-plugin-top-level-await' export default defineConfig({ @@ -37,13 +38,20 @@ export default defineConfig({ library: 'vue-next' }) ], + imports: [ + 'vue', + { + 'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'] + } + ], dts: true }), Components({ resolvers: [ TDesignResolver({ library: 'vue-next' - }) + }), + NaiveUiResolver() ], dts: true }) diff --git a/package.json b/package.json index 480b875..7e32c55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ceru-music", - "version": "1.3.12", + "version": "1.3.13", "description": "一款简洁优雅的音乐播放器", "main": "./out/main/index.js", "author": "sqj,wldss,star", @@ -71,6 +71,7 @@ "node-fetch": "2", "node-id3": "^0.2.9", "pinia": "^3.0.3", + "pinia-plugin-persistedstate": "^4.5.0", "tdesign-icons-vue-next": "^0.4.1", "tdesign-vue-next": "^1.15.2", "vue-router": "^4.5.1", @@ -88,12 +89,14 @@ "@types/node": "^22.16.5", "@types/node-fetch": "^2.6.13", "@vitejs/plugin-vue": "^6.0.0", + "@vueuse/core": "^13.9.0", "electron": "^38.1.0", "electron-builder": "^25.1.8", "electron-icon-builder": "^2.0.1", "electron-vite": "^4.0.0", "eslint": "^9.31.0", "eslint-plugin-vue": "^10.3.0", + "naive-ui": "^2.43.1", "prettier": "^3.6.2", "sass-embedded": "^1.90.0", "scss": "^0.2.4", diff --git a/src/main/services/musicSdk/index.ts b/src/main/services/musicSdk/index.ts index c5a36c4..7ca5d4a 100644 --- a/src/main/services/musicSdk/index.ts +++ b/src/main/services/musicSdk/index.ts @@ -24,3 +24,15 @@ export function request( } } ipcMain.handle('service-music-sdk-request', request) + +// 处理搜索联想请求 +ipcMain.handle('service-music-tip-search', async (_, source, keyword) => { + try { + if (!source) throw new Error('请配置音源') + const Api = main(source) + return await Api.tipSearch({ keyword }) + } catch (error: any) { + console.error('搜索联想错误:', error) + return { result: { songs: [], order: ['songs'] } } + } +}) diff --git a/src/main/services/musicSdk/service.ts b/src/main/services/musicSdk/service.ts index de82e4d..d80044f 100644 --- a/src/main/services/musicSdk/service.ts +++ b/src/main/services/musicSdk/service.ts @@ -7,427 +7,13 @@ import { PlaylistResult, GetSongListDetailsArg, PlaylistDetailResult, - DownloadSingleSongArgs + DownloadSingleSongArgs, + TipSearchResult } from './type' import pluginService from '../plugin/index' import musicSdk from '../../utils/musicSdk/index' import { musicCacheService } from '../musicCache' -import path from 'node:path' -import fs from 'fs' -import fsPromise from 'fs/promises' -import axios from 'axios' -import { pipeline } from 'node:stream/promises' -import { fileURLToPath } from 'url' -import { configManager } from '../ConfigManager' -import NodeID3 from 'node-id3' -import ffmpegStatic from 'ffmpeg-static' -import ffmpeg from 'fluent-ffmpeg' - -const fileLock: Record = {} - -/** - * 转换LRC格式 - * 将带有字符位置信息的LRC格式转换为标准的逐字时间戳格式 - * @param lrcContent 原始LRC内容 - * @returns 转换后的LRC内容 - */ -function convertLrcFormat(lrcContent: string): string { - if (!lrcContent) return '' - - const lines = lrcContent.split('\n') - const convertedLines: string[] = [] - - for (const line of lines) { - // 跳过空行 - if (!line.trim()) { - convertedLines.push(line) - continue - } - - // 检查是否是新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符 - const newFormatMatch = line.match(/^\[(\d+),(\d+)\](.*)$/) - if (newFormatMatch) { - const [, startTimeMs, , content] = newFormatMatch - const convertedLine = convertNewFormat(parseInt(startTimeMs), content) - convertedLines.push(convertedLine) - continue - } - - // 检查是否是旧格式:[mm:ss.xxx]字符(偏移,持续时间) - const oldFormatMatch = line.match(/^\[(\d{2}:\d{2}\.\d{3})\](.*)$/) - if (oldFormatMatch) { - const [, timestamp, content] = oldFormatMatch - - // 如果内容中没有位置信息,直接返回原行 - if (!content.includes('(') || !content.includes(')')) { - convertedLines.push(line) - continue - } - - const convertedLine = convertOldFormat(timestamp, content) - convertedLines.push(convertedLine) - continue - } - - // 其他行直接保留 - convertedLines.push(line) - } - - return convertedLines.join('\n') -} - -/** - * 将毫秒时间戳格式化为 mm:ss.xxx 格式 - * @param timeMs 毫秒时间戳 - * @returns 格式化的时间字符串 - */ -function formatTimestamp(timeMs: number): string { - const minutes = Math.floor(timeMs / 60000) - const seconds = Math.floor((timeMs % 60000) / 1000) - const milliseconds = timeMs % 1000 - - return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}` -} - -/** - * 转换新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符 - */ -function convertNewFormat(baseTimeMs: number, content: string): string { - const baseTimestamp = formatTimestamp(baseTimeMs) - let convertedContent = `<${baseTimestamp}>` - - // 匹配模式:(开始时间,字符持续时间,0)字符 - const charPattern = /\((\d+),(\d+),(\d+)\)([^(]*?)(?=\(|$)/g - let match - let isFirstChar = true - - while ((match = charPattern.exec(content)) !== null) { - const [, charStartMs, , , char] = match - const charTimeMs = parseInt(charStartMs) - const charTimestamp = formatTimestamp(charTimeMs) - - if (isFirstChar) { - // 第一个字符直接添加 - convertedContent += char.trim() - isFirstChar = false - } else { - convertedContent += `<${charTimestamp}>${char.trim()}` - } - } - - return `[${baseTimestamp}]${convertedContent}` -} - -/** - * 转换旧格式:[mm:ss.xxx]字符(偏移,持续时间) - */ -function convertOldFormat(timestamp: string, content: string): string { - // 解析基础时间戳(毫秒) - const [minutes, seconds] = timestamp.split(':') - const [sec, ms] = seconds.split('.') - const baseTimeMs = parseInt(minutes) * 60 * 1000 + parseInt(sec) * 1000 + parseInt(ms) - - let convertedContent = `<${timestamp}>` - - // 匹配所有字符(偏移,持续时间)的模式 - const charPattern = /([^()]+)\((\d+),(\d+)\)/g - let match - let lastIndex = 0 - let isFirstChar = true - - while ((match = charPattern.exec(content)) !== null) { - const [fullMatch, char, offsetMs, _durationMs] = match - const charTimeMs = baseTimeMs + parseInt(offsetMs) - const charTimestamp = formatTimestamp(charTimeMs) - - // 添加匹配前的普通文本 - if (match.index > lastIndex) { - const beforeText = content.substring(lastIndex, match.index) - if (beforeText.trim()) { - convertedContent += beforeText - } - } - - // 添加带时间戳的字符 - if (isFirstChar) { - // 第一个字符直接添加,不需要额外的时间戳 - convertedContent += char - isFirstChar = false - } else { - convertedContent += `<${charTimestamp}>${char}` - } - lastIndex = match.index + fullMatch.length - } - - // 添加剩余的普通文本 - if (lastIndex < content.length) { - const remainingText = content.substring(lastIndex) - if (remainingText.trim()) { - convertedContent += remainingText - } - } - - return `[${timestamp}]${convertedContent}` -} - -// 写入音频标签的辅助函数 -async function writeAudioTags(filePath: string, songInfo: any, tagWriteOptions: any) { - try { - console.log('开始写入音频标签:', filePath, tagWriteOptions, songInfo) - - // 获取文件扩展名来判断格式 - const fileExtension = path.extname(filePath).toLowerCase().substring(1) - console.log('文件格式:', fileExtension) - - // 根据文件格式选择不同的标签写入方法 - if (fileExtension === 'mp3') { - await writeMP3Tags(filePath, songInfo, tagWriteOptions) - } else if (['flac', 'ogg', 'opus'].includes(fileExtension)) { - await writeVorbisCommentTags(filePath, songInfo, tagWriteOptions) - } else if (['m4a', 'mp4', 'aac'].includes(fileExtension)) { - await writeMP4Tags(filePath, songInfo, tagWriteOptions) - } else { - console.warn('不支持的音频格式:', fileExtension) - // 尝试使用 NodeID3 作为后备方案 - await writeMP3Tags(filePath, songInfo, tagWriteOptions) - } - } catch (error) { - console.error('写入音频标签时发生错误:', error) - throw error - } -} - -// MP3 格式标签写入 -async function writeMP3Tags(filePath: string, songInfo: any, tagWriteOptions: any) { - const tags: any = {} - - // 写入基础信息 - if (tagWriteOptions.basicInfo) { - tags.title = songInfo.name || '' - tags.artist = songInfo.singer || '' - tags.album = songInfo.albumName || '' - tags.year = songInfo.year || '' - tags.genre = songInfo.genre || '' - } - - // 写入歌词 - if (tagWriteOptions.lyrics && songInfo.lrc) { - const convertedLrc = convertLrcFormat(songInfo.lrc) - tags.unsynchronisedLyrics = { - language: 'chi', - shortText: 'Lyrics', - text: convertedLrc - } - } - - // 写入封面 - if (tagWriteOptions.cover && songInfo.img) { - try { - const coverResponse = await axios({ - method: 'GET', - url: songInfo.img, - responseType: 'arraybuffer', - timeout: 10000 - }) - - if (coverResponse.data) { - tags.image = { - mime: 'image/jpeg', - type: { - id: 3, - name: 'front cover' - }, - description: 'Cover', - imageBuffer: Buffer.from(coverResponse.data) - } - } - } catch (coverError) { - console.warn('获取封面失败:', coverError) - } - } - - // 写入标签到文件 - if (Object.keys(tags).length > 0) { - const success = NodeID3.write(tags, filePath) - if (success) { - console.log('MP3音频标签写入成功:', filePath) - } else { - console.warn('MP3音频标签写入失败:', filePath) - } - } -} - -// FLAC/OGG 格式标签写入 (使用 Vorbis Comment) -async function writeVorbisCommentTags(filePath: string, songInfo: any, tagWriteOptions: any) { - try { - console.log('开始写入 FLAC 标签:', filePath) - - // 准备新的标签数据 - const newTags: any = {} - - // 写入基础信息 - if (tagWriteOptions.basicInfo) { - if (songInfo.name) newTags.TITLE = songInfo.name - if (songInfo.singer) newTags.ARTIST = songInfo.singer - if (songInfo.albumName) newTags.ALBUM = songInfo.albumName - if (songInfo.year) newTags.DATE = songInfo.year.toString() - if (songInfo.genre) newTags.GENRE = songInfo.genre - } - - // 写入歌词 - if (tagWriteOptions.lyrics && songInfo.lrc) { - const convertedLrc = convertLrcFormat(songInfo.lrc) - newTags.LYRICS = convertedLrc - } - - console.log('准备写入的标签:', newTags) - - // 使用 ffmpeg-static 写入 FLAC 标签 - if (path.extname(filePath).toLowerCase() === '.flac') { - await writeFLACTagsWithFFmpeg(filePath, newTags, songInfo, tagWriteOptions) - } else { - console.warn('暂不支持该格式的标签写入:', path.extname(filePath)) - } - } catch (error) { - console.error('写入 Vorbis Comment 标签失败:', error) - throw error - } -} - -// 使用 fluent-ffmpeg 写入 FLAC 标签 -async function writeFLACTagsWithFFmpeg( - filePath: string, - tags: any, - songInfo: any, - tagWriteOptions: any -) { - let tempOutputPath: string | null = null - let tempCoverPath: string | null = null - - try { - if (!ffmpegStatic) { - throw new Error('ffmpeg-static 不可用') - } - ffmpeg.setFfmpegPath(ffmpegStatic.replace('app.asar', 'app.asar.unpacked')) - - // 创建临时输出文件 - tempOutputPath = filePath + '.temp.flac' - - // 创建 fluent-ffmpeg 实例 - let command = ffmpeg(filePath) - .audioCodec('copy') // 复制音频编解码器,不重新编码 - .output(tempOutputPath) - - // 添加元数据标签 - for (const [key, value] of Object.entries(tags)) { - if (value) { - // fluent-ffmpeg 会自动处理特殊字符转义 - command = command.outputOptions(['-metadata', `${key}=${value}`]) - } - } - - // 处理封面 - if (tagWriteOptions.cover && songInfo.img) { - try { - console.log('开始下载封面:', songInfo.img) - const coverResponse = await axios({ - method: 'GET', - url: songInfo.img, - responseType: 'arraybuffer', - timeout: 10000 - }) - - if (coverResponse.data) { - // 保存临时封面文件 - tempCoverPath = path.join(path.dirname(filePath), 'temp_cover.jpg') - await fsPromise.writeFile(tempCoverPath, Buffer.from(coverResponse.data)) - - // 添加封面作为输入 - command = command.input(tempCoverPath).outputOptions([ - '-map', - '0:a', // 映射原始文件的音频流 - '-map', - '1:v', // 映射封面的视频流 - '-c:v', - 'copy', // 复制视频编解码器 - '-disposition:v:0', - 'attached_pic' // 设置为附加图片 - ]) - - console.log('封面已添加到命令中') - } - } catch (coverError) { - console.warn( - '下载封面失败,跳过封面写入:', - coverError instanceof Error ? coverError.message : coverError - ) - } - } - - // 执行 ffmpeg 命令 - await new Promise((resolve, reject) => { - command - .on('start', () => { - console.log('执行 ffmpeg 命令') - }) - .on('progress', (progress) => { - if (progress.percent) { - console.log('处理进度:', Math.round(progress.percent) + '%') - } - }) - .on('end', () => { - console.log('ffmpeg 处理完成') - resolve() - }) - .on('error', (err, _, stderr) => { - console.error('ffmpeg 错误:', err.message) - if (stderr) { - console.error('ffmpeg stderr:', stderr) - } - reject(new Error(`ffmpeg 处理失败: ${err.message}`)) - }) - .run() - }) - - // 检查临时文件是否创建成功 - if (!fs.existsSync(tempOutputPath)) { - throw new Error('ffmpeg 未能创建输出文件') - } - - // 替换原文件 - await fsPromise.rename(tempOutputPath, filePath) - tempOutputPath = null // 标记已处理,避免重复清理 - - console.log('使用 fluent-ffmpeg 写入 FLAC 标签成功:', filePath) - } catch (error) { - console.error('使用 fluent-ffmpeg 写入 FLAC 标签失败:', error) - throw error - } finally { - // 清理所有临时文件 - const filesToClean = [tempOutputPath, tempCoverPath].filter(Boolean) as string[] - - for (const tempFile of filesToClean) { - try { - if (fs.existsSync(tempFile)) { - await fsPromise.unlink(tempFile) - console.log('已清理临时文件:', tempFile) - } - } catch (cleanupError) { - console.warn( - '清理临时文件失败:', - tempFile, - cleanupError instanceof Error ? cleanupError.message : cleanupError - ) - } - } - } -} - -// MP4/M4A 格式标签写入 -async function writeMP4Tags(filePath: string, _songInfo: any, _tagWriteOptions: any) { - console.log('MP4/M4A 格式标签写入暂未实现:', filePath) - // 可以使用 ffmpeg 或其他工具实现 -} +import download from '../../utils/downloadSongs' function main(source: string) { const Api = musicSdk[source] @@ -436,6 +22,14 @@ function main(source: string) { return (await Api.musicSearch.search(keyword, page, limit)) as Promise }, + async tipSearch({ keyword }: { keyword: string }) { + if (!Api.tipSearch?.tipSearchBySong) { + // 如果音乐源没有实现tipSearch方法,返回空结果 + return [] as TipSearchResult + } + return (await Api.tipSearch.search(keyword)) as Promise + }, + async getMusicUrl({ pluginId, songInfo, quality }: GetMusicUrlArg) { try { const usePlugin = pluginService.getPluginById(pluginId) @@ -506,93 +100,7 @@ function main(source: string) { }: DownloadSingleSongArgs) { const url = await this.getMusicUrl({ pluginId, songInfo, quality }) if (typeof url === 'object') throw new Error('无法获取歌曲链接') - - // 获取自定义下载目录 - const getDownloadDirectory = (): string => { - // 使用配置管理服务获取下载目录 - const directories = configManager.getDirectories() - return directories.downloadDir - } - - // 从URL中提取文件扩展名,如果没有则默认为mp3 - const getFileExtension = (url: string): string => { - try { - const urlObj = new URL(url) - const pathname = urlObj.pathname - const lastDotIndex = pathname.lastIndexOf('.') - if (lastDotIndex !== -1 && lastDotIndex < pathname.length - 1) { - const extension = pathname.substring(lastDotIndex + 1).toLowerCase() - // 验证是否为常见的音频格式 - const validExtensions = ['mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'wma'] - if (validExtensions.includes(extension)) { - return extension - } - } - } catch (error) { - console.warn('解析URL失败,使用默认扩展名:', error) - } - return 'mp3' // 默认扩展名 - } - - const fileExtension = getFileExtension(url) - const downloadDir = getDownloadDirectory() - const songPath = path.join( - downloadDir, - `${songInfo.name}-${songInfo.singer}-${songInfo.source}.${fileExtension}` - .replace(/[/\\:*?"<>|]/g, '') - .replace(/^\.+/, '') - .replace(/\.+$/, '') - .trim() - ) - - if (fileLock[songPath]) { - throw new Error('歌曲正在下载中') - } else { - fileLock[songPath] = true - } - - try { - if (fs.existsSync(songPath)) { - return { - message: '歌曲已存在' - } - } - await fsPromise.mkdir(path.dirname(songPath), { recursive: true }) - - if (url.startsWith('file://')) { - const filePath = fileURLToPath(url) - - const readStream = fs.createReadStream(filePath) - const writeStream = fs.createWriteStream(songPath) - - await pipeline(readStream, writeStream) - } else { - const songDataRes = await axios({ - method: 'GET', - url: url, - responseType: 'stream' - }) - - await pipeline(songDataRes.data, fs.createWriteStream(songPath)) - } - } finally { - delete fileLock[songPath] - } - - // 写入标签信息 - if (tagWriteOptions && fs.existsSync(songPath)) { - try { - await writeAudioTags(songPath, songInfo, tagWriteOptions) - } catch (error) { - console.warn('写入音频标签失败:', error) - throw ffmpegStatic - } - } - - return { - message: '下载成功', - path: songPath - } + return await download(url, songInfo, tagWriteOptions) }, async parsePlaylistId({ url }: { url: string }) { diff --git a/src/main/services/musicSdk/type.ts b/src/main/services/musicSdk/type.ts index 91cba90..16cc9a6 100644 --- a/src/main/services/musicSdk/type.ts +++ b/src/main/services/musicSdk/type.ts @@ -100,3 +100,6 @@ export interface DownloadSingleSongArgs extends GetMusicUrlArg { path?: string tagWriteOptions?: TagWriteOptions } + +// 搜索联想结果的类型定义 +export type TipSearchResult = string[] diff --git a/src/main/utils/downloadSongs.ts b/src/main/utils/downloadSongs.ts new file mode 100644 index 0000000..b47de12 --- /dev/null +++ b/src/main/utils/downloadSongs.ts @@ -0,0 +1,508 @@ +import NodeID3 from 'node-id3' +import ffmpegStatic from 'ffmpeg-static' +import ffmpeg from 'fluent-ffmpeg' +import path from 'node:path' +import axios from 'axios' +import fs from 'fs' +import fsPromise from 'fs/promises' +import { configManager } from '../services/ConfigManager' +import { pipeline } from 'node:stream/promises' +import { fileURLToPath } from 'url' + +const fileLock: Record = {} + +/** + * 转换LRC格式 + * 将带有字符位置信息的LRC格式转换为标准的逐字时间戳格式 + * @param lrcContent 原始LRC内容 + * @returns 转换后的LRC内容 + */ +function convertLrcFormat(lrcContent: string): string { + if (!lrcContent) return '' + + const lines = lrcContent.split('\n') + const convertedLines: string[] = [] + + for (const line of lines) { + // 跳过空行 + if (!line.trim()) { + convertedLines.push(line) + continue + } + + // 检查是否是新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符 + const newFormatMatch = line.match(/^\[(\d+),(\d+)\](.*)$/) + if (newFormatMatch) { + const [, startTimeMs, , content] = newFormatMatch + const convertedLine = convertNewFormat(parseInt(startTimeMs), content) + convertedLines.push(convertedLine) + continue + } + + // 检查是否是旧格式:[mm:ss.xxx]字符(偏移,持续时间) + const oldFormatMatch = line.match(/^\[(\d{2}:\d{2}\.\d{3})\](.*)$/) + if (oldFormatMatch) { + const [, timestamp, content] = oldFormatMatch + + // 如果内容中没有位置信息,直接返回原行 + if (!content.includes('(') || !content.includes(')')) { + convertedLines.push(line) + continue + } + + const convertedLine = convertOldFormat(timestamp, content) + convertedLines.push(convertedLine) + continue + } + + // 其他行直接保留 + convertedLines.push(line) + } + + return convertedLines.join('\n') +} + +/** + * 将毫秒时间戳格式化为 mm:ss.xxx 格式 + * @param timeMs 毫秒时间戳 + * @returns 格式化的时间字符串 + */ +function formatTimestamp(timeMs: number): string { + const minutes = Math.floor(timeMs / 60000) + const seconds = Math.floor((timeMs % 60000) / 1000) + const milliseconds = timeMs % 1000 + + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}` +} + +/** + * 转换新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符 + */ +function convertNewFormat(baseTimeMs: number, content: string): string { + const baseTimestamp = formatTimestamp(baseTimeMs) + let convertedContent = `<${baseTimestamp}>` + + // 匹配模式:(开始时间,字符持续时间,0)字符 + const charPattern = /\((\d+),(\d+),(\d+)\)([^(]*?)(?=\(|$)/g + let match + let isFirstChar = true + + while ((match = charPattern.exec(content)) !== null) { + const [, charStartMs, , , char] = match + const charTimeMs = parseInt(charStartMs) + const charTimestamp = formatTimestamp(charTimeMs) + + if (isFirstChar) { + // 第一个字符直接添加 + convertedContent += char.trim() + isFirstChar = false + } else { + convertedContent += `<${charTimestamp}>${char.trim()}` + } + } + + return `[${baseTimestamp}]${convertedContent}` +} + +/** + * 转换旧格式:[mm:ss.xxx]字符(偏移,持续时间) + */ +function convertOldFormat(timestamp: string, content: string): string { + // 解析基础时间戳(毫秒) + const [minutes, seconds] = timestamp.split(':') + const [sec, ms] = seconds.split('.') + const baseTimeMs = parseInt(minutes) * 60 * 1000 + parseInt(sec) * 1000 + parseInt(ms) + + let convertedContent = `<${timestamp}>` + + // 匹配所有字符(偏移,持续时间)的模式 + const charPattern = /([^()]+)\((\d+),(\d+)\)/g + let match + let lastIndex = 0 + let isFirstChar = true + + while ((match = charPattern.exec(content)) !== null) { + const [fullMatch, char, offsetMs, _durationMs] = match + const charTimeMs = baseTimeMs + parseInt(offsetMs) + const charTimestamp = formatTimestamp(charTimeMs) + + // 添加匹配前的普通文本 + if (match.index > lastIndex) { + const beforeText = content.substring(lastIndex, match.index) + if (beforeText.trim()) { + convertedContent += beforeText + } + } + + // 添加带时间戳的字符 + if (isFirstChar) { + // 第一个字符直接添加,不需要额外的时间戳 + convertedContent += char + isFirstChar = false + } else { + convertedContent += `<${charTimestamp}>${char}` + } + lastIndex = match.index + fullMatch.length + } + + // 添加剩余的普通文本 + if (lastIndex < content.length) { + const remainingText = content.substring(lastIndex) + if (remainingText.trim()) { + convertedContent += remainingText + } + } + + return `[${timestamp}]${convertedContent}` +} + +// 写入音频标签的辅助函数 +async function writeAudioTags(filePath: string, songInfo: any, tagWriteOptions: any) { + try { + console.log('开始写入音频标签:', filePath, tagWriteOptions, songInfo) + + // 获取文件扩展名来判断格式 + const fileExtension = path.extname(filePath).toLowerCase().substring(1) + console.log('文件格式:', fileExtension) + + // 根据文件格式选择不同的标签写入方法 + if (fileExtension === 'mp3') { + await writeMP3Tags(filePath, songInfo, tagWriteOptions) + } else if (['flac', 'ogg', 'opus'].includes(fileExtension)) { + await writeVorbisCommentTags(filePath, songInfo, tagWriteOptions) + } else if (['m4a', 'mp4', 'aac'].includes(fileExtension)) { + await writeMP4Tags(filePath, songInfo, tagWriteOptions) + } else { + console.warn('不支持的音频格式:', fileExtension) + // 尝试使用 NodeID3 作为后备方案 + await writeMP3Tags(filePath, songInfo, tagWriteOptions) + } + } catch (error) { + console.error('写入音频标签时发生错误:', error) + throw error + } +} + +// MP3 格式标签写入 +async function writeMP3Tags(filePath: string, songInfo: any, tagWriteOptions: any) { + const tags: any = {} + + // 写入基础信息 + if (tagWriteOptions.basicInfo) { + tags.title = songInfo.name || '' + tags.artist = songInfo.singer || '' + tags.album = songInfo.albumName || '' + tags.year = songInfo.year || '' + tags.genre = songInfo.genre || '' + } + + // 写入歌词 + if (tagWriteOptions.lyrics && songInfo.lrc) { + const convertedLrc = convertLrcFormat(songInfo.lrc) + tags.unsynchronisedLyrics = { + language: 'chi', + shortText: 'Lyrics', + text: convertedLrc + } + } + + // 写入封面 + if (tagWriteOptions.cover && songInfo.img) { + try { + const coverResponse = await axios({ + method: 'GET', + url: songInfo.img, + responseType: 'arraybuffer', + timeout: 10000 + }) + + if (coverResponse.data) { + tags.image = { + mime: 'image/jpeg', + type: { + id: 3, + name: 'front cover' + }, + description: 'Cover', + imageBuffer: Buffer.from(coverResponse.data) + } + } + } catch (coverError) { + console.warn('获取封面失败:', coverError) + } + } + + // 写入标签到文件 + if (Object.keys(tags).length > 0) { + const success = NodeID3.write(tags, filePath) + if (success) { + console.log('MP3音频标签写入成功:', filePath) + } else { + console.warn('MP3音频标签写入失败:', filePath) + } + } +} + +// FLAC/OGG 格式标签写入 (使用 Vorbis Comment) +async function writeVorbisCommentTags(filePath: string, songInfo: any, tagWriteOptions: any) { + try { + console.log('开始写入 FLAC 标签:', filePath) + + // 准备新的标签数据 + const newTags: any = {} + + // 写入基础信息 + if (tagWriteOptions.basicInfo) { + if (songInfo.name) newTags.TITLE = songInfo.name + if (songInfo.singer) newTags.ARTIST = songInfo.singer + if (songInfo.albumName) newTags.ALBUM = songInfo.albumName + if (songInfo.year) newTags.DATE = songInfo.year.toString() + if (songInfo.genre) newTags.GENRE = songInfo.genre + } + + // 写入歌词 + if (tagWriteOptions.lyrics && songInfo.lrc) { + const convertedLrc = convertLrcFormat(songInfo.lrc) + newTags.LYRICS = convertedLrc + } + + console.log('准备写入的标签:', newTags) + + // 使用 ffmpeg-static 写入 FLAC 标签 + if (path.extname(filePath).toLowerCase() === '.flac') { + await writeFLACTagsWithFFmpeg(filePath, newTags, songInfo, tagWriteOptions) + } else { + console.warn('暂不支持该格式的标签写入:', path.extname(filePath)) + } + } catch (error) { + console.error('写入 Vorbis Comment 标签失败:', error) + throw error + } +} + +// 使用 fluent-ffmpeg 写入 FLAC 标签 +async function writeFLACTagsWithFFmpeg( + filePath: string, + tags: any, + songInfo: any, + tagWriteOptions: any +) { + let tempOutputPath: string | null = null + let tempCoverPath: string | null = null + + try { + if (!ffmpegStatic) { + throw new Error('ffmpeg-static 不可用') + } + ffmpeg.setFfmpegPath(ffmpegStatic.replace('app.asar', 'app.asar.unpacked')) + + // 创建临时输出文件 + tempOutputPath = filePath + '.temp.flac' + + // 创建 fluent-ffmpeg 实例 + let command = ffmpeg(filePath) + .audioCodec('copy') // 复制音频编解码器,不重新编码 + .output(tempOutputPath) + + // 添加元数据标签 + for (const [key, value] of Object.entries(tags)) { + if (value) { + // fluent-ffmpeg 会自动处理特殊字符转义 + command = command.outputOptions(['-metadata', `${key}=${value}`]) + } + } + + // 处理封面 + if (tagWriteOptions.cover && songInfo.img) { + try { + console.log('开始下载封面:', songInfo.img) + const coverResponse = await axios({ + method: 'GET', + url: songInfo.img, + responseType: 'arraybuffer', + timeout: 10000 + }) + + if (coverResponse.data) { + // 保存临时封面文件 + tempCoverPath = path.join(path.dirname(filePath), 'temp_cover.jpg') + await fsPromise.writeFile(tempCoverPath, Buffer.from(coverResponse.data)) + + // 添加封面作为输入 + command = command.input(tempCoverPath).outputOptions([ + '-map', + '0:a', // 映射原始文件的音频流 + '-map', + '1:v', // 映射封面的视频流 + '-c:v', + 'copy', // 复制视频编解码器 + '-disposition:v:0', + 'attached_pic' // 设置为附加图片 + ]) + + console.log('封面已添加到命令中') + } + } catch (coverError) { + console.warn( + '下载封面失败,跳过封面写入:', + coverError instanceof Error ? coverError.message : coverError + ) + } + } + + // 执行 ffmpeg 命令 + await new Promise((resolve, reject) => { + command + .on('start', () => { + console.log('执行 ffmpeg 命令') + }) + .on('progress', (progress) => { + if (progress.percent) { + console.log('处理进度:', Math.round(progress.percent) + '%') + } + }) + .on('end', () => { + console.log('ffmpeg 处理完成') + resolve() + }) + .on('error', (err, _, stderr) => { + console.error('ffmpeg 错误:', err.message) + if (stderr) { + console.error('ffmpeg stderr:', stderr) + } + reject(new Error(`ffmpeg 处理失败: ${err.message}`)) + }) + .run() + }) + + // 检查临时文件是否创建成功 + if (!fs.existsSync(tempOutputPath)) { + throw new Error('ffmpeg 未能创建输出文件') + } + + // 替换原文件 + await fsPromise.rename(tempOutputPath, filePath) + tempOutputPath = null // 标记已处理,避免重复清理 + + console.log('使用 fluent-ffmpeg 写入 FLAC 标签成功:', filePath) + } catch (error) { + console.error('使用 fluent-ffmpeg 写入 FLAC 标签失败:', error) + throw error + } finally { + // 清理所有临时文件 + const filesToClean = [tempOutputPath, tempCoverPath].filter(Boolean) as string[] + + for (const tempFile of filesToClean) { + try { + if (fs.existsSync(tempFile)) { + await fsPromise.unlink(tempFile) + console.log('已清理临时文件:', tempFile) + } + } catch (cleanupError) { + console.warn( + '清理临时文件失败:', + tempFile, + cleanupError instanceof Error ? cleanupError.message : cleanupError + ) + } + } + } +} + +// MP4/M4A 格式标签写入 +async function writeMP4Tags(filePath: string, _songInfo: any, _tagWriteOptions: any) { + console.log('MP4/M4A 格式标签写入暂未实现:', filePath) + // 可以使用 ffmpeg 或其他工具实现 +} + +// 获取自定义下载目录 +const getDownloadDirectory = (): string => { + // 使用配置管理服务获取下载目录 + const directories = configManager.getDirectories() + return directories.downloadDir +} + +// 从URL中提取文件扩展名,如果没有则默认为mp3 +const getFileExtension = (url: string): string => { + try { + const urlObj = new URL(url) + const pathname = urlObj.pathname + const lastDotIndex = pathname.lastIndexOf('.') + if (lastDotIndex !== -1 && lastDotIndex < pathname.length - 1) { + const extension = pathname.substring(lastDotIndex + 1).toLowerCase() + // 验证是否为常见的音频格式 + const validExtensions = ['mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'wma'] + if (validExtensions.includes(extension)) { + return extension + } + } + } catch (error) { + console.warn('解析URL失败,使用默认扩展名:', error) + } + return 'mp3' // 默认扩展名 +} + +export default async function download( + url: string, + songInfo: any, + tagWriteOptions: any +): Promise { + const fileExtension = getFileExtension(url) + const downloadDir = getDownloadDirectory() + const songPath = path.join( + downloadDir, + `${songInfo.name}-${songInfo.singer}-${songInfo.source}.${fileExtension}` + .replace(/[/\\:*?"<>|]/g, '') + .replace(/^\.+/, '') + .replace(/\.+$/, '') + .trim() + ) + + if (fileLock[songPath]) { + throw new Error('歌曲正在下载中') + } else { + fileLock[songPath] = true + } + + try { + if (fs.existsSync(songPath)) { + return { + message: '歌曲已存在' + } + } + await fsPromise.mkdir(path.dirname(songPath), { recursive: true }) + + if (url.startsWith('file://')) { + const filePath = fileURLToPath(url) + + const readStream = fs.createReadStream(filePath) + const writeStream = fs.createWriteStream(songPath) + + await pipeline(readStream, writeStream) + } else { + const songDataRes = await axios({ + method: 'GET', + url: url, + responseType: 'stream' + }) + + await pipeline(songDataRes.data, fs.createWriteStream(songPath)) + } + } finally { + delete fileLock[songPath] + } + + // 写入标签信息 + if (tagWriteOptions && fs.existsSync(songPath)) { + try { + await writeAudioTags(songPath, songInfo, tagWriteOptions) + } catch (error) { + console.warn('写入音频标签失败:', error) + throw ffmpegStatic + } + } + return { + message: '下载成功', + path: songPath + } +} diff --git a/src/main/utils/musicSdk/kg/index.js b/src/main/utils/musicSdk/kg/index.js index 74cbca5..acada42 100644 --- a/src/main/utils/musicSdk/kg/index.js +++ b/src/main/utils/musicSdk/kg/index.js @@ -5,10 +5,10 @@ import pic from './pic' import lyric from './lyric' import hotSearch from './hotSearch' import comment from './comment' -// import tipSearch from './tipSearch' +import tipSearch from './tipSearch' const kg = { - // tipSearch, + tipSearch, leaderboard, songList, musicSearch, diff --git a/src/main/utils/musicSdk/mg/index.js b/src/main/utils/musicSdk/mg/index.js index dc5e8db..c67ff81 100644 --- a/src/main/utils/musicSdk/mg/index.js +++ b/src/main/utils/musicSdk/mg/index.js @@ -5,10 +5,10 @@ import pic from './pic' import lyric from './lyric' import hotSearch from './hotSearch' import comment from './comment' -// import tipSearch from './tipSearch' +import tipSearch from './tipSearch' const mg = { - // tipSearch, + tipSearch, songList, musicSearch, leaderboard, diff --git a/src/main/utils/musicSdk/tx/index.js b/src/main/utils/musicSdk/tx/index.js index bf5e302..990c465 100644 --- a/src/main/utils/musicSdk/tx/index.js +++ b/src/main/utils/musicSdk/tx/index.js @@ -4,10 +4,10 @@ import songList from './songList' import musicSearch from './musicSearch' import hotSearch from './hotSearch' import comment from './comment' -// import tipSearch from './tipSearch' +import tipSearch from './tipSearch' const tx = { - // tipSearch, + tipSearch, leaderboard, songList, musicSearch, diff --git a/src/main/utils/musicSdk/wy/index.js b/src/main/utils/musicSdk/wy/index.js index dccc2aa..ffe0d25 100644 --- a/src/main/utils/musicSdk/wy/index.js +++ b/src/main/utils/musicSdk/wy/index.js @@ -5,9 +5,10 @@ import musicSearch from './musicSearch' import songList from './songList' import hotSearch from './hotSearch' import comment from './comment' +import tipSearch from './tipSearch' const wy = { - // tipSearch, + tipSearch, leaderboard, musicSearch, songList, @@ -25,4 +26,4 @@ const wy = { } } -export default wy +export default wy \ No newline at end of file diff --git a/src/preload/index.ts b/src/preload/index.ts index 523275a..47d7f17 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -30,7 +30,7 @@ const api = { // 音乐相关方法 music: { requestSdk: (api: string, args: any) => - ipcRenderer.invoke('service-music-sdk-request', api, args) + ipcRenderer.invoke('service-music-sdk-request', api, args), }, //音源插件 plugins: { diff --git a/src/renderer/auto-imports.d.ts b/src/renderer/auto-imports.d.ts index 9bbc987..1c212c9 100644 --- a/src/renderer/auto-imports.d.ts +++ b/src/renderer/auto-imports.d.ts @@ -7,4 +7,72 @@ export {} declare global { const DialogPlugin: (typeof import('tdesign-vue-next'))['DialogPlugin'] + const EffectScope: typeof import('vue')['EffectScope'] + const computed: typeof import('vue')['computed'] + const createApp: typeof import('vue')['createApp'] + const customRef: typeof import('vue')['customRef'] + const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] + const defineComponent: typeof import('vue')['defineComponent'] + const effectScope: typeof import('vue')['effectScope'] + const getCurrentInstance: typeof import('vue')['getCurrentInstance'] + const getCurrentScope: typeof import('vue')['getCurrentScope'] + const getCurrentWatcher: typeof import('vue')['getCurrentWatcher'] + const h: typeof import('vue')['h'] + const inject: typeof import('vue')['inject'] + const isProxy: typeof import('vue')['isProxy'] + const isReactive: typeof import('vue')['isReactive'] + const isReadonly: typeof import('vue')['isReadonly'] + const isRef: typeof import('vue')['isRef'] + const isShallow: typeof import('vue')['isShallow'] + const markRaw: typeof import('vue')['markRaw'] + const nextTick: typeof import('vue')['nextTick'] + const onActivated: typeof import('vue')['onActivated'] + const onBeforeMount: typeof import('vue')['onBeforeMount'] + const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] + const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] + const onDeactivated: typeof import('vue')['onDeactivated'] + const onErrorCaptured: typeof import('vue')['onErrorCaptured'] + const onMounted: typeof import('vue')['onMounted'] + const onRenderTracked: typeof import('vue')['onRenderTracked'] + const onRenderTriggered: typeof import('vue')['onRenderTriggered'] + const onScopeDispose: typeof import('vue')['onScopeDispose'] + const onServerPrefetch: typeof import('vue')['onServerPrefetch'] + const onUnmounted: typeof import('vue')['onUnmounted'] + const onUpdated: typeof import('vue')['onUpdated'] + const onWatcherCleanup: typeof import('vue')['onWatcherCleanup'] + const provide: typeof import('vue')['provide'] + const reactive: typeof import('vue')['reactive'] + const readonly: typeof import('vue')['readonly'] + const ref: typeof import('vue')['ref'] + const resolveComponent: typeof import('vue')['resolveComponent'] + const shallowReactive: typeof import('vue')['shallowReactive'] + const shallowReadonly: typeof import('vue')['shallowReadonly'] + const shallowRef: typeof import('vue')['shallowRef'] + const toRaw: typeof import('vue')['toRaw'] + const toRef: typeof import('vue')['toRef'] + const toRefs: typeof import('vue')['toRefs'] + const toValue: typeof import('vue')['toValue'] + const triggerRef: typeof import('vue')['triggerRef'] + const unref: typeof import('vue')['unref'] + const useAttrs: typeof import('vue')['useAttrs'] + const useCssModule: typeof import('vue')['useCssModule'] + const useCssVars: typeof import('vue')['useCssVars'] + const useDialog: typeof import('naive-ui')['useDialog'] + const useId: typeof import('vue')['useId'] + const useLoadingBar: typeof import('naive-ui')['useLoadingBar'] + const useMessage: typeof import('naive-ui')['useMessage'] + const useModel: typeof import('vue')['useModel'] + const useNotification: typeof import('naive-ui')['useNotification'] + const useSlots: typeof import('vue')['useSlots'] + const useTemplateRef: typeof import('vue')['useTemplateRef'] + const watch: typeof import('vue')['watch'] + const watchEffect: typeof import('vue')['watchEffect'] + const watchPostEffect: typeof import('vue')['watchPostEffect'] + const watchSyncEffect: typeof import('vue')['watchSyncEffect'] +} +// for type re-export +declare global { + // @ts-ignore + export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' + import('vue') } diff --git a/src/renderer/components.d.ts b/src/renderer/components.d.ts index 3bcc6e1..d284ab5 100644 --- a/src/renderer/components.d.ts +++ b/src/renderer/components.d.ts @@ -18,6 +18,9 @@ declare module 'vue' { GlobalAudio: typeof import('./src/components/Play/GlobalAudio.vue')['default'] HomeLayout: typeof import('./src/components/layout/HomeLayout.vue')['default'] MusicCache: typeof import('./src/components/Settings/MusicCache.vue')['default'] + NCard: typeof import('naive-ui')['NCard'] + NScrollbar: typeof import('naive-ui')['NScrollbar'] + NText: typeof import('naive-ui')['NText'] PlaylistActions: typeof import('./src/components/Play/PlaylistActions.vue')['default'] PlaylistDrawer: typeof import('./src/components/Play/PlaylistDrawer.vue')['default'] PlaylistSettings: typeof import('./src/components/Settings/PlaylistSettings.vue')['default'] @@ -26,6 +29,7 @@ declare module 'vue' { Plugins: typeof import('./src/components/Settings/plugins.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] + SearchSuggest: typeof import('./src/components/search/searchSuggest.vue')['default'] ShaderBackground: typeof import('./src/components/Play/ShaderBackground.vue')['default'] SongVirtualList: typeof import('./src/components/Music/SongVirtualList.vue')['default'] TAlert: typeof import('tdesign-vue-next')['Alert'] diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index 4a22fa4..4ef1281 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -10,9 +10,10 @@ --> diff --git a/src/renderer/src/main.ts b/src/renderer/src/main.ts index e176df6..71af102 100644 --- a/src/renderer/src/main.ts +++ b/src/renderer/src/main.ts @@ -1,20 +1,25 @@ +// 基础样式 import './assets/base.css' import 'animate.css' - -// 引入组件库的少量全局样式变量 -// import 'tdesign-vue-next/es/style/index.css' //tdesign 组件样式 - // 引入iconfont图标样式 import './assets/icon_font/iconfont.css' import './assets/icon_font/iconfont.js' - +// vue import App from './App.vue' - import { createApp } from 'vue' const app = createApp(App) -import { createPinia } from 'pinia' +// router import router from './router' +// pinia +import { createPinia } from 'pinia' +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' +// pinia +const pinia = createPinia() +pinia.use(piniaPluginPersistedstate) +app.use(pinia) +//router app.use(router) -app.use(createPinia()) + +//app app.mount('#app') diff --git a/src/renderer/src/store/ControlAudio.ts b/src/renderer/src/store/ControlAudio.ts index 0ab790a..e0bea8f 100644 --- a/src/renderer/src/store/ControlAudio.ts +++ b/src/renderer/src/store/ControlAudio.ts @@ -21,202 +21,208 @@ import type { * @property {string} url - 音频URL。 */ let userInfo: any -export const ControlAudioStore = defineStore('controlAudio', () => { - const Audio = reactive({ - audio: null, - isPlay: false, - currentTime: 0, - duration: 0, - volume: 80, - url: '' - }) - - // -------------------------------------------发布订阅逻辑------------------------------------------ - // 事件订阅者映射表 - /** - * 音频事件订阅与发布逻辑。 - * @property {Record} subscribers - 事件订阅者映射表。 - */ - const subscribers = reactive>({ - ended: [], - seeked: [], - timeupdate: [], - play: [], - pause: [], - error: [], - canplay: [] - }) - // 生成唯一ID - const generateId = (): string => { - return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}` - } - - // 订阅事件 - /** - * 订阅音频事件。 - * @param {AudioEventType} eventType - 事件类型。 - * @param {AudioEventCallback} callback - 事件回调函数。 - * @returns {UnsubscribeFunction} 取消订阅的函数。 - */ - const subscribe = ( - eventType: AudioEventType, - callback: AudioEventCallback - ): UnsubscribeFunction => { - const id = generateId() - const subscriber: AudioSubscriber = { id, callback } - - subscribers[eventType].push(subscriber) - - // 返回取消订阅函数 - return () => { - const index = subscribers[eventType].findIndex((sub) => sub.id === id) - if (index > -1) { - subscribers[eventType].splice(index, 1) - } - } - } - - // 发布事件 - const publish = (eventType: AudioEventType): void => { - subscribers[eventType].forEach((subscriber) => { - try { - subscriber.callback() - } catch (error) { - console.error(`音频事件回调执行错误 [${eventType}]:`, error) - } +export const ControlAudioStore = defineStore( + 'controlAudio', + () => { + const Audio = reactive({ + audio: null, + isPlay: false, + currentTime: 0, + duration: 0, + volume: 80, + url: '' }) - } - // 清空所有订阅者 - const clearAllSubscribers = (): void => { - Object.keys(subscribers).forEach((eventType) => { - subscribers[eventType as AudioEventType] = [] + // -------------------------------------------发布订阅逻辑------------------------------------------ + // 事件订阅者映射表 + /** + * 音频事件订阅与发布逻辑。 + * @property {Record} subscribers - 事件订阅者映射表。 + */ + const subscribers = reactive>({ + ended: [], + seeked: [], + timeupdate: [], + play: [], + pause: [], + error: [], + canplay: [] }) - } - - // 清空特定事件的所有订阅者 - const clearEventSubscribers = (eventType: AudioEventType): void => { - subscribers[eventType] = [] - } - // End-------------------------------------------事件订阅者映射表逻辑------------------------------------------ - - // 初始化 - const init = (el: ControlAudioState['audio']) => { - userInfo = LocalUserDetailStore() - console.log(el, '全局音频挂载初始化success') - Audio.audio = el - } - - /** - * 设置当前播放时间。 - * @param {number} time - 播放时间(秒)。 - * @throws {Error} 如果时间不是数字类型。 - */ - const setCurrentTime = (time: number) => { - if (typeof time === 'number') { - Audio.currentTime = time - return + // 生成唯一ID + const generateId = (): string => { + return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}` } - throw new Error('时间必须是数字类型') - } - const setDuration = (duration: number) => { - if (typeof duration === 'number') { - Audio.duration = duration - return - } - throw new Error('时间必须是数字类型') - } - /** - * 设置音量。 - * @param {number} volume - 音量(0-100)。 - * @param {boolean} transition - 是否使用渐变。 - * @throws {Error} 如果音量不在0-100之间。 - */ - const setVolume = (volume: number, transition: boolean = false) => { - if (typeof volume === 'number' && volume >= 0 && volume <= 100) { - if (Audio.audio) { - if (Audio.isPlay && transition) { - transitionVolume(Audio.audio, volume / 100, Audio.volume <= volume) - } else { - Audio.audio.volume = Number((volume / 100).toFixed(2)) + + // 订阅事件 + /** + * 订阅音频事件。 + * @param {AudioEventType} eventType - 事件类型。 + * @param {AudioEventCallback} callback - 事件回调函数。 + * @returns {UnsubscribeFunction} 取消订阅的函数。 + */ + const subscribe = ( + eventType: AudioEventType, + callback: AudioEventCallback + ): UnsubscribeFunction => { + const id = generateId() + const subscriber: AudioSubscriber = { id, callback } + + subscribers[eventType].push(subscriber) + + // 返回取消订阅函数 + return () => { + const index = subscribers[eventType].findIndex((sub) => sub.id === id) + if (index > -1) { + subscribers[eventType].splice(index, 1) } - Audio.volume = volume - userInfo.userInfo.volume = volume } - } else { - if (typeof volume === 'number' && Audio.audio) { - if (volume <= 0) { - Audio.volume = 0 - Audio.audio.volume = 0 - userInfo.userInfo.volume = 0 - } else { - Audio.volume = 100 - Audio.audio.volume = 100 - userInfo.userInfo.volume = 100 + } + + // 发布事件 + const publish = (eventType: AudioEventType): void => { + subscribers[eventType].forEach((subscriber) => { + try { + subscriber.callback() + } catch (error) { + console.error(`音频事件回调执行错误 [${eventType}]:`, error) } - } else { - throw new Error('音量必须是0-100之间的数字') - } - } - } - - /** - * 设置音频URL。 - * @param {string} url - 音频URL。 - * @throws {Error} 如果URL为空或无效。 - */ - const setUrl = (url: string) => { - if (typeof url !== 'string' || url.trim() === '') { - throw new Error('音频URL不能为空') - } - - // 停止当前播放 - if (Audio.isPlay) { - stop() - } - - Audio.url = url.trim() - console.log('音频URL已设置:', Audio.url) - } - const start = async () => { - const volume = Audio.volume - if (Audio.audio) { - Audio.audio.volume = 0 - try { - await Audio.audio.play() - Audio.isPlay = true - return transitionVolume(Audio.audio, volume / 100, true, true) - } catch (error) { - Audio.audio.volume = volume / 100 - console.error('音频播放失败:', error) - Audio.isPlay = false - throw new Error('音频播放失败,请检查音频URL是否有效') - } - } - return false - } - const stop = () => { - if (Audio.audio) { - Audio.isPlay = false - return transitionVolume(Audio.audio, Audio.volume / 100, false, true).then(() => { - Audio.audio?.pause() }) } - return false - } - return { - Audio, - init, - setCurrentTime, - setVolume, - setUrl, - start, - stop, - subscribe, - publish, - clearAllSubscribers, - clearEventSubscribers, - setDuration + // 清空所有订阅者 + const clearAllSubscribers = (): void => { + Object.keys(subscribers).forEach((eventType) => { + subscribers[eventType as AudioEventType] = [] + }) + } + + // 清空特定事件的所有订阅者 + const clearEventSubscribers = (eventType: AudioEventType): void => { + subscribers[eventType] = [] + } + // End-------------------------------------------事件订阅者映射表逻辑------------------------------------------ + + // 初始化 + const init = (el: ControlAudioState['audio']) => { + userInfo = LocalUserDetailStore() + console.log(el, '全局音频挂载初始化success') + Audio.audio = el + } + + /** + * 设置当前播放时间。 + * @param {number} time - 播放时间(秒)。 + * @throws {Error} 如果时间不是数字类型。 + */ + const setCurrentTime = (time: number) => { + if (typeof time === 'number') { + Audio.currentTime = time + return + } + throw new Error('时间必须是数字类型') + } + const setDuration = (duration: number) => { + if (typeof duration === 'number') { + Audio.duration = duration + return + } + throw new Error('时间必须是数字类型') + } + /** + * 设置音量。 + * @param {number} volume - 音量(0-100)。 + * @param {boolean} transition - 是否使用渐变。 + * @throws {Error} 如果音量不在0-100之间。 + */ + const setVolume = (volume: number, transition: boolean = false) => { + if (typeof volume === 'number' && volume >= 0 && volume <= 100) { + if (Audio.audio) { + if (Audio.isPlay && transition) { + transitionVolume(Audio.audio, volume / 100, Audio.volume <= volume) + } else { + Audio.audio.volume = Number((volume / 100).toFixed(2)) + } + Audio.volume = volume + userInfo.userInfo.volume = volume + } + } else { + if (typeof volume === 'number' && Audio.audio) { + if (volume <= 0) { + Audio.volume = 0 + Audio.audio.volume = 0 + userInfo.userInfo.volume = 0 + } else { + Audio.volume = 100 + Audio.audio.volume = 100 + userInfo.userInfo.volume = 100 + } + } else { + throw new Error('音量必须是0-100之间的数字') + } + } + } + + /** + * 设置音频URL。 + * @param {string} url - 音频URL。 + * @throws {Error} 如果URL为空或无效。 + */ + const setUrl = (url: string) => { + if (typeof url !== 'string' || url.trim() === '') { + throw new Error('音频URL不能为空') + } + + // 停止当前播放 + if (Audio.isPlay) { + stop() + } + + Audio.url = url.trim() + console.log('音频URL已设置:', Audio.url) + } + const start = async () => { + const volume = Audio.volume + if (Audio.audio) { + Audio.audio.volume = 0 + try { + await Audio.audio.play() + Audio.isPlay = true + return transitionVolume(Audio.audio, volume / 100, true, true) + } catch (error) { + Audio.audio.volume = volume / 100 + console.error('音频播放失败:', error) + Audio.isPlay = false + throw new Error('音频播放失败,请检查音频URL是否有效') + } + } + return false + } + const stop = () => { + if (Audio.audio) { + Audio.isPlay = false + return transitionVolume(Audio.audio, Audio.volume / 100, false, true).then(() => { + Audio.audio?.pause() + }) + } + return false + } + + return { + Audio, + init, + setCurrentTime, + setVolume, + setUrl, + start, + stop, + subscribe, + publish, + clearAllSubscribers, + clearEventSubscribers, + setDuration + } + }, + { + persist: false } -}) +) diff --git a/src/renderer/src/store/LocalUserDetail.ts b/src/renderer/src/store/LocalUserDetail.ts index 9593ef3..54396bb 100644 --- a/src/renderer/src/store/LocalUserDetail.ts +++ b/src/renderer/src/store/LocalUserDetail.ts @@ -5,106 +5,112 @@ import { ControlAudioStore } from '@renderer/store/ControlAudio' import type { SongList } from '@renderer/types/audio' import type { UserInfo } from '@renderer/types/userInfo' -export const LocalUserDetailStore = defineStore('Local', () => { - const list = ref([]) - const userInfo = ref({}) - const initialization = ref(false) - function init(): void { - const UserInfoLocal = localStorage.getItem('userInfo') - const ListLocal = localStorage.getItem('songList') - if (UserInfoLocal) { - userInfo.value = JSON.parse(UserInfoLocal) as UserInfo - } else { - userInfo.value = { - lastPlaySongId: null, - topBarStyle: false, - mainColor: '#00DAC0', - volume: 80, - currentTime: 0, - selectSources: 'wy' +export const LocalUserDetailStore = defineStore( + 'Local', + () => { + const list = ref([]) + const userInfo = ref({}) + const initialization = ref(false) + function init(): void { + const UserInfoLocal = localStorage.getItem('userInfo') + const ListLocal = localStorage.getItem('songList') + if (UserInfoLocal) { + userInfo.value = JSON.parse(UserInfoLocal) as UserInfo + } else { + userInfo.value = { + lastPlaySongId: null, + topBarStyle: false, + mainColor: '#00DAC0', + volume: 80, + currentTime: 0, + selectSources: 'wy' + } + localStorage.setItem('userInfo', JSON.stringify(userInfo.value)) } - localStorage.setItem('userInfo', JSON.stringify(userInfo.value)) + if (ListLocal) { + list.value = JSON.parse(ListLocal) as SongList[] + } else { + list.value = [] + localStorage.setItem('songList', JSON.stringify([])) + } + console.log('init local user detail') + initialization.value = true + const Audio = ControlAudioStore() + startWatch() + Audio.setVolume(userInfo.value.volume as number) } - if (ListLocal) { - list.value = JSON.parse(ListLocal) as SongList[] - } else { + function startWatch() { + console.log('startWatch') + watch( + list, + (newVal) => { + localStorage.setItem('songList', JSON.stringify(newVal)) + }, + { + deep: true + } + ) + watch( + userInfo, + (newVal) => { + localStorage.setItem('userInfo', JSON.stringify(newVal)) + }, + { + deep: true + } + ) + } + + function addSong(song: SongList) { + if (!list.value.find((item) => item.songmid === song.songmid)) { + list.value.push(song) + } + return list.value + } + + function addSongToFirst(song: SongList) { + const existingIndex = list.value.findIndex((item) => item.songmid === song.songmid) + if (existingIndex !== -1) { + // 如果歌曲已存在,将其移动到第一位 + const existingSong = list.value.splice(existingIndex, 1)[0] + list.value.unshift(existingSong) + } else { + // 如果歌曲不存在,添加到第一位 + list.value.unshift(song) + } + return list.value + } + + function removeSong(songId: number | string) { + const index = list.value.findIndex((item) => item.songmid === songId) + if (index !== -1) { + list.value.splice(index, 1) + } + } + + function clearList() { list.value = [] - localStorage.setItem('songList', JSON.stringify([])) } - console.log('init local user detail') - initialization.value = true - const Audio = ControlAudioStore() - startWatch() - Audio.setVolume(userInfo.value.volume as number) - } - function startWatch() { - console.log('startWatch') - watch( - list, - (newVal) => { - localStorage.setItem('songList', JSON.stringify(newVal)) - }, - { - deep: true + const userSource = computed(() => { + return { + pluginId: userInfo.value.pluginId, + source: userInfo.value.selectSources, + quality: userInfo.value.selectQuality } - ) - watch( - userInfo, - (newVal) => { - localStorage.setItem('userInfo', JSON.stringify(newVal)) - }, - { - deep: true - } - ) - } - - function addSong(song: SongList) { - if (!list.value.find((item) => item.songmid === song.songmid)) { - list.value.push(song) - } - return list.value - } - - function addSongToFirst(song: SongList) { - const existingIndex = list.value.findIndex((item) => item.songmid === song.songmid) - if (existingIndex !== -1) { - // 如果歌曲已存在,将其移动到第一位 - const existingSong = list.value.splice(existingIndex, 1)[0] - list.value.unshift(existingSong) - } else { - // 如果歌曲不存在,添加到第一位 - list.value.unshift(song) - } - return list.value - } - - function removeSong(songId: number | string) { - const index = list.value.findIndex((item) => item.songmid === songId) - if (index !== -1) { - list.value.splice(index, 1) - } - } - - function clearList() { - list.value = [] - } - const userSource = computed(() => { + }) return { - pluginId: userInfo.value.pluginId, - source: userInfo.value.selectSources, - quality: userInfo.value.selectQuality + list, + userInfo, + initialization, + init, + addSong, + addSongToFirst, + removeSong, + clearList, + userSource } - }) - return { - list, - userInfo, - initialization, - init, - addSong, - addSongToFirst, - removeSong, - clearList, - userSource + }, + { + persist: false } -}) +) diff --git a/src/renderer/src/store/Settings.ts b/src/renderer/src/store/Settings.ts index f7a9b23..cb6ef68 100644 --- a/src/renderer/src/store/Settings.ts +++ b/src/renderer/src/store/Settings.ts @@ -16,51 +16,57 @@ export interface SettingsState { tagWriteOptions?: TagWriteOptions } -export const useSettingsStore = defineStore('settings', () => { - // 从本地存储加载设置,如果没有则使用默认值 - const loadSettings = (): SettingsState => { - try { - const savedSettings = localStorage.getItem('appSettings') - if (savedSettings) { - return JSON.parse(savedSettings) +export const useSettingsStore = defineStore( + 'settings', + () => { + // 从本地存储加载设置,如果没有则使用默认值 + const loadSettings = (): SettingsState => { + try { + const savedSettings = localStorage.getItem('appSettings') + if (savedSettings) { + return JSON.parse(savedSettings) + } + } catch (error) { + console.error('加载设置失败:', error) + } + + // 默认设置 + return { + showFloatBall: true, + tagWriteOptions: { + basicInfo: true, + cover: true, + lyrics: true + } } - } catch (error) { - console.error('加载设置失败:', error) } - // 默认设置 + const settings = ref(loadSettings()) + + // 保存设置到本地存储 + const saveSettings = () => { + localStorage.setItem('appSettings', JSON.stringify(settings.value)) + } + + // 更新设置 + const updateSettings = (newSettings: Partial) => { + settings.value = { ...settings.value, ...newSettings } + saveSettings() + } + + // 切换悬浮球显示状态 + const toggleFloatBall = () => { + settings.value.showFloatBall = !settings.value.showFloatBall + saveSettings() + } + return { - showFloatBall: true, - tagWriteOptions: { - basicInfo: true, - cover: true, - lyrics: true - } + settings, + updateSettings, + toggleFloatBall } + }, + { + persist: false } - - const settings = ref(loadSettings()) - - // 保存设置到本地存储 - const saveSettings = () => { - localStorage.setItem('appSettings', JSON.stringify(settings.value)) - } - - // 更新设置 - const updateSettings = (newSettings: Partial) => { - settings.value = { ...settings.value, ...newSettings } - saveSettings() - } - - // 切换悬浮球显示状态 - const toggleFloatBall = () => { - settings.value.showFloatBall = !settings.value.showFloatBall - saveSettings() - } - - return { - settings, - updateSettings, - toggleFloatBall - } -}) +) diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts new file mode 100644 index 0000000..a32380f --- /dev/null +++ b/src/renderer/src/store/index.ts @@ -0,0 +1 @@ +export { searchValue as useSearchStore } from './search' diff --git a/src/renderer/src/store/search.ts b/src/renderer/src/store/search.ts index 816869b..9842065 100644 --- a/src/renderer/src/store/search.ts +++ b/src/renderer/src/store/search.ts @@ -2,14 +2,20 @@ import { defineStore } from 'pinia' export const searchValue = defineStore('search', { state: () => ({ - value: '' + value: '', + focus: false }), getters: { - getValue: (state) => state.value + getValue: (state) => state.value, + getFocus: (state) => state.focus }, actions: { setValue(value: string) { this.value = value + }, + setFocus(focus: boolean) { + this.focus = focus } - } + }, + persist: false }) diff --git a/src/renderer/src/views/ThemeDemo.vue b/src/renderer/src/views/ThemeDemo.vue deleted file mode 100644 index 7e9a7d2..0000000 --- a/src/renderer/src/views/ThemeDemo.vue +++ /dev/null @@ -1,368 +0,0 @@ - - - - - - diff --git a/src/renderer/src/views/music/local.vue b/src/renderer/src/views/music/local.vue index d0f7256..9aebbfb 100644 --- a/src/renderer/src/views/music/local.vue +++ b/src/renderer/src/views/music/local.vue @@ -1320,7 +1320,7 @@ onMounted(() => { : importPlatformType === 'kw' ? '支持链接或ID:http://www.kuwo.cn/playlist_detail/123456789 或 123456789' : importPlatformType === 'kg' - ? '支持链接或ID:https://www.kugou.com/yy/special/single/123456789 或 123456789' + ? '手机链接或酷狗码:https://www.kugou.com/yy/special/single/123456789 或 123456789' : importPlatformType === 'mg' ? '支持链接或ID:https://music.migu.cn/v3/music/playlist/123456789 或 123456789' : '请输入歌单链接或ID' @@ -1369,11 +1369,11 @@ onMounted(() => {
  • 其他包含ID的酷我音乐链接格式
    • +
    • 酷狗码(推荐):123456789
    • 完整链接:https://www.kugou.com/yy/special/single/123456789
    • 手机版链接:https://m.kugou.com/songlist/gcid_3z9vj0yqz4bz00b
    • 旧版手机链接:https://m.kugou.com/playlist?id=123456789
    • 参数链接:https://www.kugou.com/playlist?specialid=123456789
    • -
    • 纯数字ID:123456789
    • 完整链接:https://music.migu.cn/v3/music/playlist/123456789
    • diff --git a/src/renderer/src/views/music/search.vue b/src/renderer/src/views/music/search.vue index 14e186f..fe4b395 100644 --- a/src/renderer/src/views/music/search.vue +++ b/src/renderer/src/views/music/search.vue @@ -1,10 +1,11 @@