diff --git a/qzmusic-web/.gitignore b/qzmusic-web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/qzmusic-web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/qzmusic-web/README.md b/qzmusic-web/README.md new file mode 100644 index 0000000..c566cc7 --- /dev/null +++ b/qzmusic-web/README.md @@ -0,0 +1,143 @@ +# QZMusic Web + +QZMusic 网页版,基于 Vue 3 + TypeScript + Vite 构建的音乐播放器,支持插件系统获取音乐资源。 + +## 功能特性 + +- 🎵 音乐播放控制(播放/暂停、上一首、下一首) +- 📜 播放列表管理 +- 🎨 深色/浅色主题切换 +- 🎚️ 音量控制 +- 📊 音频可视化(基于 Web Audio API) +- 🔍 搜索功能 +- 🔌 **插件系统** - 支持通过插件获取音乐资源 +- 🌐 默认端口:1219 + +## 快速开始 + +### 一键安装(推荐) + +直接从 Gitea 仓库一键安装部署: + +```bash +# 方式1:使用 curl 直接执行 +bash <(curl -sL http://171.80.3.149:4321/miao-moe/QZMusic-Web/raw/branch/master/install.sh) + +# 方式2:克隆后执行 +git clone http://171.80.3.149:4321/miao-moe/QZMusic-Web.git +cd QZMusic-Web +bash install.sh +``` + +### 一键部署(本地开发) + +```bash +# 使用一键部署脚本 +npm run deploy +# 或者直接运行 +./deploy.sh +``` + +### 一键启动 + +```bash +# 使用启动脚本 +npm run start +# 或者直接运行 +./start.sh +``` + +## 一键卸载 + +完全删除所有 QZMusic 部署文件和配置: + +```bash +# 方式1:使用 curl 直接执行 +bash <(curl -sL http://171.80.3.149:4321/miao-moe/QZMusic-Web/raw/branch/master/uninstall.sh) + +# 方式2:在安装目录执行 +cd /opt/QZMusic-Web +bash uninstall.sh +``` + +卸载将删除: +- 安装目录(`/opt/QZMusic-Web`) +- 端口 1219 上的所有进程 +- Systemd 服务(如有) +- npm 全局包(如有) +- 相关缓存文件 + +## 开发 + +### 安装依赖 + +```bash +npm install +``` + +### 启动开发服务器 + +```bash +npm run dev +``` + +服务器将在 http://localhost:1219 启动 + +### 构建生产版本 + +```bash +npm run build +``` + +### 预览生产构建 + +```bash +npm run preview +``` + +## 脚本说明 + +### 本地脚本 + +| 命令 | 说明 | +|------|------| +| `npm run dev` | 启动开发服务器(端口 1219) | +| `npm run build` | 构建生产版本 | +| `npm run preview` | 预览生产构建(端口 1219) | +| `npm run deploy` | 一键部署(安装依赖 + 构建) | +| `npm run start` | 一键启动(自动安装依赖 + 启动) | +| `npm run install-app` | 一键安装(从仓库安装到 /opt) | +| `npm run uninstall` | 一键卸载(删除所有部署文件) | + +### 一键命令(远程执行) + +| 命令 | 说明 | +|------|------| +| `bash <(curl ... install.sh)` | 从仓库一键安装部署 | +| `bash <(curl ... uninstall.sh)` | 从仓库一键卸载所有部署 | + +## 项目结构 + +``` +├── src/ +│ ├── assets/ # 静态资源 +│ ├── components/ # 组件 +│ ├── layout/ # 布局组件 +│ ├── stores/ # Pinia 状态管理 +│ ├── styles/ # 样式文件 +│ ├── types/ # TypeScript 类型定义 +│ ├── utils/ # 工具函数 +│ ├── views/ # 页面组件 +│ ├── App.vue # 根组件 +│ └── main.ts # 入口文件 +├── public/ # 公开资源 +├── index.html # HTML 模板 +├── vite.config.ts # Vite 配置 +└── tsconfig.json # TypeScript 配置 +``` + +## 注意事项 + +- 网页版移除了原 Electron 项目的本地文件系统访问和原生插件功能 +- 音乐播放使用 HTML5 Audio API +- 音频可视化使用 Web Audio API diff --git a/qzmusic-web/deploy.sh b/qzmusic-web/deploy.sh new file mode 100644 index 0000000..23fceca --- /dev/null +++ b/qzmusic-web/deploy.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +# QZMusic-Web 一键部署脚本 +# 端口:1219 + +echo "==========================================" +echo " QZMusic-Web 一键部署" +echo "==========================================" +echo "" + +# 检查Node.js是否安装 +if ! command -v node &> /dev/null; then + echo "❌ Node.js 未安装!请先安装 Node.js" + exit 1 +fi + +echo "✅ Node.js 版本: $(node -v)" + +# 检查npm是否安装 +if ! command -v npm &> /dev/null; then + echo "❌ npm 未安装!" + exit 1 +fi + +echo "✅ npm 版本: $(npm -v)" +echo "" + +# 安装依赖 +echo "📦 正在安装依赖..." +npm install + +if [ $? -ne 0 ]; then + echo "❌ 依赖安装失败!" + exit 1 +fi + +echo "✅ 依赖安装成功!" +echo "" + +# 构建项目 +echo "🔨 正在构建项目..." +npm run build + +if [ $? -ne 0 ]; then + echo "❌ 构建失败!" + exit 1 +fi + +echo "✅ 项目构建成功!" +echo "" + +echo "==========================================" +echo " 部署完成!" +echo "==========================================" +echo "" +echo "📂 构建产物目录: ./dist" +echo "" +echo "🚀 启动方式:" +echo " 开发模式: npm run dev" +echo " 预览模式: npm run preview" +echo "" +echo "🌐 访问地址: http://localhost:1219" +echo "" diff --git a/qzmusic-web/index.html b/qzmusic-web/index.html new file mode 100644 index 0000000..62909b8 --- /dev/null +++ b/qzmusic-web/index.html @@ -0,0 +1,13 @@ + + +
+ + + +暂无歌词
+]*>([\s\S]*?)<\/p>/gi;
+ const beginRe = /begin\s*=\s*["']([^"']+)["']/i;
+ const endRe = /end\s*=\s*["']([^"']+)["']/i;
+ let m;
+ while ((m = lineRe.exec(t)) !== null) {
+ const attrs = m[0].substring(0, m[0].indexOf('>'));
+ const begin = beginRe.exec(attrs);
+ const end = endRe.exec(attrs);
+ const inner = m[1].replace(/ {{ song.singername || '未知歌手' }} {{ scanMessage }} {{ scanned ? '未找到音频文件' : '暂无本地音乐' }} 选择包含音乐文件的文件夹或直接选择文件 {{ song.artist }} 搜索出错了,请稍后重试
/gi, '\n').replace(/<[^>]+>/g, '').trim();
+ if (!inner) continue;
+ const start = begin ? parseTtmlTime(begin[1]) : 0;
+ const endT = end ? parseTtmlTime(end[1]) : undefined;
+ lines.push({ startTime: start, endTime: endT, text: inner });
+ }
+ if (lines.length === 0) return parseLrc(text);
+ return lines.sort((a, b) => a.startTime - b.startTime);
+}
+
+function parseTtmlTime(str: string): number {
+ // 00:01:23.456 / 00:01:23 / 01:23.456
+ const parts = str.split(':');
+ if (parts.length === 3) {
+ const [h, m, s] = parts;
+ return parseInt(h, 10) * 3600000 + parseInt(m, 10) * 60000 + parseFloat(s) * 1000;
+ }
+ if (parts.length === 2) {
+ return parseInt(parts[0], 10) * 60000 + parseFloat(parts[1]) * 1000;
+ }
+ return parseFloat(str) * 1000;
+}
+
+function parseYrc(raw: any): LyricLine[] {
+ let lyricText: string | undefined;
+ if (typeof raw === 'string') {
+ try {
+ const obj = JSON.parse(raw);
+ raw = obj;
+ } catch {}
+ }
+ if (typeof raw === 'object' && raw !== null) {
+ lyricText = raw.yrc || raw.lrc || raw.lyric || raw.klyric || raw.lrclib;
+ if (typeof lyricText === 'object' && lyricText !== null) {
+ lyricText = (lyricText as any).lyric || (lyricText as any).content || JSON.stringify(lyricText);
+ }
+ }
+ if (!lyricText || typeof lyricText !== 'string') {
+ return parseLrc(JSON.stringify(raw));
+ }
+ // 网易云 YRC 逐字格式:[0,1800,"(前奏)"]{{340,220,yu},{620,230,ye},...}
+ const lines: LyricLine[] = [];
+ const regex = /\[\s*(\d+)\s*,\s*(\d+)\s*(?:,[^\]]*)?\]([^{]*)(\{[^}]*\})?/g;
+ let match;
+ while ((match = regex.exec(lyricText)) !== null) {
+ const start = parseInt(match[1], 10);
+ const dur = parseInt(match[2], 10);
+ let text = (match[3] || '').trim();
+ const wordsPart = match[4];
+ const words: { time: number; duration?: number; text: string }[] = [];
+ if (wordsPart) {
+ const wordRe = /\{\s*(\d+)\s*,\s*(\d+)\s*(?:,[^}]*)?\}/g;
+ let wm;
+ while ((wm = wordRe.exec(wordsPart)) !== null) {
+ const time = parseInt(wm[1], 10);
+ const duration = parseInt(wm[2], 10);
+ const tStart = wordRe.lastIndex;
+ // 从 wordsPart 中找到单词字符(可能是中文字符 / 英文)
+ // 网易云格式中的文字在 {} 后紧接的 , 位置后。简化处理:
+ words.push({ time: start + time, duration, text: '' });
+ void tStart;
+ }
+ }
+ if (!text && words.length === 0) continue;
+ lines.push({ startTime: start, endTime: start + dur, text, words: words.length ? words : undefined });
+ }
+ if (lines.length > 0) return lines;
+ return parseLrc(lyricText);
+}
+
+function parseJson(raw: any): LyricLine[] {
+ try {
+ let obj = raw;
+ if (typeof obj === 'string') {
+ obj = JSON.parse(obj);
+ }
+ if (Array.isArray(obj)) {
+ const lines: LyricLine[] = [];
+ for (const item of obj) {
+ if (item && typeof item === 'object') {
+ if (item.startTime != null || item.time != null || item.start != null || item.t != null) {
+ const t = item.startTime ?? item.time ?? item.start ?? item.t ?? 0;
+ const end = item.endTime ?? item.end ?? undefined;
+ const txt = item.text ?? item.content ?? item.word ?? item.lyric ?? item.line ?? '';
+ if (txt) lines.push({ startTime: typeof t === 'number' ? t : parseTimeStamp(String(t)), endTime: typeof end === 'number' ? end : undefined, text: String(txt) });
+ }
+ } else if (typeof item === 'string') {
+ lines.push(...parseLrc(item));
+ }
+ }
+ return lines.sort((a, b) => a.startTime - b.startTime);
+ }
+ if (obj && typeof obj === 'object') {
+ if (obj.lrc && typeof obj.lrc === 'string') return parseLrc(obj.lrc);
+ if (obj.lyric && typeof obj.lyric === 'string') return parseLrc(obj.lyric);
+ if (typeof obj.content === 'string') return parseLrc(obj.content);
+ if (Array.isArray(obj.lines)) return parseJson(obj.lines);
+ }
+ return [];
+ } catch { return []; }
+}
+
+function parseSrt(text: string): LyricLine[] {
+ const lines: LyricLine[] = [];
+ const blocks = String(text).split(/\r?\n\s*\r?\n/);
+ for (const block of blocks) {
+ const linesArr = block.split(/\r?\n/).filter(Boolean);
+ if (linesArr.length < 2) continue;
+ // skip leading index line
+ let timeLineIdx = 0;
+ if (/^\d+$/.test(linesArr[0].trim())) timeLineIdx = 1;
+ const timeLine = linesArr[timeLineIdx];
+ const tm = timeLine.match(/(\d{1,2}):(\d{2}):(\d{2})[.,](\d{1,3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[.,](\d{1,3})/);
+ if (!tm) continue;
+ const startTime = parseInt(tm[1], 10) * 3600000 + parseInt(tm[2], 10) * 60000 + parseInt(tm[3], 10) * 1000 + parseInt(tm[4], 10);
+ const endTime = parseInt(tm[5], 10) * 3600000 + parseInt(tm[6], 10) * 60000 + parseInt(tm[7], 10) * 1000 + parseInt(tm[8], 10);
+ const content = linesArr.slice(timeLineIdx + 1).map(s => s.replace(/<[^>]+>/g, '').trim()).filter(Boolean).join(' ');
+ if (!content) continue;
+ lines.push({ startTime, endTime, text: content });
+ }
+ return lines;
+}
+
+function parseVtt(text: string): LyricLine[] {
+ const lines: LyricLine[] = [];
+ const blocks = String(text).replace(/^WEBVTT\s*(\r?\n|$)/i, '').split(/\r?\n\s*\r?\n/);
+ for (const block of blocks) {
+ const linesArr = block.split(/\r?\n/).filter(Boolean);
+ if (linesArr.length < 1) continue;
+ let timeLineIdx = 0;
+ while (timeLineIdx < linesArr.length && !/-->/.test(linesArr[timeLineIdx])) timeLineIdx++;
+ if (timeLineIdx >= linesArr.length) continue;
+ const tm = linesArr[timeLineIdx].match(/(?:(\d{1,2}):)?(\d{1,2}):(\d{2})[.,](\d{1,3})\s*-->\s*(?:(\d{1,2}):)?(\d{1,2}):(\d{2})[.,](\d{1,3})/);
+ if (!tm) continue;
+ const startTime = (parseInt(tm[1] || '0', 10) * 3600000) + parseInt(tm[2], 10) * 60000 + parseInt(tm[3], 10) * 1000 + parseInt(tm[4], 10);
+ const endTime = (parseInt(tm[5] || '0', 10) * 3600000) + parseInt(tm[6], 10) * 60000 + parseInt(tm[7], 10) * 1000 + parseInt(tm[8], 10);
+ const content = linesArr.slice(timeLineIdx + 1).map(s => s.replace(/<[^>]+>/g, '').trim()).filter(Boolean).join(' ');
+ if (!content) continue;
+ lines.push({ startTime, endTime, text: content });
+ }
+ return lines;
+}
+
+export function parseAnyLyric(input: { format?: string | null; raw: any } | any): LyricLine[] {
+ let format: string | null = input?.format;
+ let raw: any = input?.raw;
+ if (raw === undefined && input !== null && typeof input !== 'object') {
+ raw = input;
+ format = null;
+ }
+ if (raw === null || raw === undefined || (typeof raw === 'string' && !raw.trim())) return [];
+
+ if (!format) {
+ if (typeof raw === 'string') {
+ const s = raw.trim();
+ if (/^<\s*(?:\?xml|tt|TT|lyric\b|Lyric\b|LyricData\b)/i.test(s)) format = 'ttml';
+ else if (/<\s*\d+[::]\d+/.test(s)) format = 'qrc';
+ else if (/\[\s*\d{1,2}[::]\d{1,2}(?:[.::]\d{1,3})?\s*\]/.test(s)) format = 'lrc';
+ else if (s.charAt(0) === '{' || s.charAt(0) === '[') {
+ try {
+ const obj = JSON.parse(s);
+ if (obj.yrc || obj.lrclib || obj.klyric) format = 'yrc';
+ else if (obj.lrc || obj.ttml || obj.qrc || obj.lyric || obj.lines) format = 'json';
+ else format = 'json';
+ raw = obj;
+ } catch { format = 'text'; }
+ } else if (/^\d+\s*\r?\n\d{1,2}:\d{2}:\d{2}[.,]\d+\s*-->/.test(s)) format = 'srt';
+ else format = 'text';
+ } else if (typeof raw === 'object') {
+ if (raw.yrc || raw.lrclib || raw.klyric) format = 'yrc';
+ else if (raw.lrc) format = 'lrc';
+ else if (raw.ttml) format = 'ttml';
+ else if (raw.qrc) format = 'qrc';
+ else if (Array.isArray(raw) || raw.lines || raw.list) format = 'json';
+ else format = 'json';
+ }
+ }
+
+ switch (format) {
+ case 'lrc': return typeof raw === 'string' ? parseLrc(raw) : parseLrc(String(raw.lrc || raw.lyric || raw.content || ''));
+ case 'qrc': return typeof raw === 'string' ? parseQrc(raw) : parseQrc(String(raw.qrc || raw));
+ case 'ttml': return typeof raw === 'string' ? parseTtml(raw) : parseTtml(String(raw.ttml || raw));
+ case 'yrc': return parseYrc(raw);
+ case 'json': return parseJson(raw);
+ case 'srt': return typeof raw === 'string' ? parseSrt(raw) : parseSrt(String(raw));
+ case 'vtt': return typeof raw === 'string' ? parseVtt(raw) : parseVtt(String(raw));
+ case 'text':
+ default:
+ if (typeof raw === 'string' && raw.trim()) {
+ return [{ startTime: 0, text: raw.trim() }];
+ }
+ return [];
+ }
+}
+
+export { parseLrc, parseQrc, parseTtml, parseYrc, parseJson, parseSrt, parseVtt };
diff --git a/qzmusic-web/src/utils/songUtils.ts b/qzmusic-web/src/utils/songUtils.ts
new file mode 100644
index 0000000..326de0f
--- /dev/null
+++ b/qzmusic-web/src/utils/songUtils.ts
@@ -0,0 +1,65 @@
+import type { Song } from '../types/song';
+
+/** 将毫秒转换为 MM:SS 格式 */
+export function formatDuration(ms: number): string {
+ const totalSeconds = Math.floor(ms / 1000);
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
+}
+
+/**
+ * 将 PC/Android 原版插件搜索结果转换为 Web 版 Song 格式
+ *
+ * PC 原版 v2 字段:
+ * - songmid: 歌曲 ID
+ * - name: 歌曲名
+ * - singer: 歌手名
+ * - img / m_img / s_img: 封面图
+ * - interval: 时长(毫秒)
+ * - source: 来源标识
+ * - albumId: 专辑 ID
+ * - albumName: 专辑名
+ * - types: 音质映射
+ *
+ * v3 插件字段:
+ * - id: 歌曲 ID
+ * - name: 歌曲名
+ * - artists: 歌手名
+ * - pic: 封面图
+ * - interval: 时长(毫秒字符串)
+ * - source: 来源标识
+ * - albumName: 专辑名
+ * - albumId: 专辑 ID
+ * - qualities: 音质映射
+ */
+export function transformSearchSong(raw: any): Song {
+ const id = String(raw.songmid || raw.id || raw.songId || '');
+ const name = raw.name || '未知歌曲';
+ const artist = raw.singer || raw.artist || raw.artists || '未知歌手';
+ const picUrl = raw.img || raw.picUrl || raw.pic || raw.mPic || raw.sPic || raw.m_img || raw.s_img || '';
+ const interval = raw.interval ? Number(raw.interval) : raw.duration ? Number(raw.duration) : 0;
+ const albumName = raw.albumName || raw.album || null;
+ const albumId = raw.albumId ? String(raw.albumId) : null;
+ const types = raw.types || raw.qualities || undefined;
+
+ return {
+ id,
+ name,
+ artist,
+ picUrl,
+ url: '',
+ duration: formatDuration(interval),
+ source: raw.source || '',
+ albumId,
+ albumName,
+ type: 'Remote',
+ quality: 'auto',
+ types,
+ };
+}
+
+/** 批量转换搜索结果 */
+export function transformSearchResults(results: any[]): Song[] {
+ return results.map(item => transformSearchSong(item));
+}
diff --git a/qzmusic-web/src/views/Home.vue b/qzmusic-web/src/views/Home.vue
new file mode 100644
index 0000000..718bb25
--- /dev/null
+++ b/qzmusic-web/src/views/Home.vue
@@ -0,0 +1,538 @@
+
+ 热门搜索
+ 排行榜
+
+
{{ song.songname || song.filename }}
+ 本地音乐
+
+
+
+
+
+
+ 运行日志
+ 共 {{ logStore.logs.length }} 条
+
+
{{ formatErrorDetail(entry.detail) }}
+
+
+ {{ entry.detail }}
+
+
+ {{ entry.detail }}
+
+
+ {{ String(entry.detail) }}
+
+
+ {{ formatJson(entry.detail) }}
+
+
+ {{ formatDetailForText(entry.detail) }}
+
+ {{ title }}
+
+
+ {{ song.name }}
+ 搜索: "{{ query }}"
+ 找到 {{ total }} 个结果
+
+
+