feat: 插件系统重构 + 多格式歌词支持 + systemd 部署

- 插件类型定义(plugin.ts):匹配 PC/Android 原版 pluginInfo/quality/musicSearch/getUrl/getLyric
- pluginManager.ts:getSongUrl/getLyric 自动遍历所有可用插件
- lyricUtil.ts:LRC/QRC(XML)/TTML/YRC/JSON/SRT/VTT/纯文本 + 逐字歌词 自动识别解析
- player.ts:playSong/fetchLyrics 改为通过插件系统获取,移除硬编码 localhost:5266 代理
- server.cjs:SPA 路由回退 + 安全路径校验 + 日志时间戳
- install.sh:systemd 服务部署 + firewalld/ufw 端口开放 + 10096→1219
This commit is contained in:
auto-bot
2026-06-13 17:39:15 +00:00
parent d2255b5951
commit 4dde1b6b63
4 changed files with 730 additions and 166 deletions

View File

@@ -2,15 +2,13 @@ import type { PluginFullInfo, PluginModule, PluginSearchResult, UrlResponse } fr
/** /**
* QZMusic Web 插件管理器 * QZMusic Web 插件管理器
* 兼容 PC/Android 原版的 CommonJS 插件格式 * 兼容 PC/Android 原版的 CommonJS 插件格式
* * module.exports = {
* PC 原版插件格式: * pluginInfo: { info: { id, name, ... }, quality: [...] },
* module.exports = { * musicSearch: { search: (query, page, limit) => result },
* pluginInfo: { info: { id, name, ... }, quality: [...] }, * getUrl: (id, quality) => url,
* getUrl: (id, quality) => url, * getLyric: (id) => lyric_string_or_object
* musicSearch: { search: (query, page, limit) => result }, * }
* getLyric: (id) => lyric
* }
*/ */
class PluginManager { class PluginManager {
private plugins: Map<string, PluginModule> = new Map(); private plugins: Map<string, PluginModule> = new Map();
@@ -33,33 +31,20 @@ class PluginManager {
/** 从 JS 代码字符串加载插件(兼容 PC 版 CommonJS 格式) */ /** 从 JS 代码字符串加载插件(兼容 PC 版 CommonJS 格式) */
loadFromCode(code: string, source: 'built-in' | 'user' = 'user'): PluginModule | null { loadFromCode(code: string, source: 'built-in' | 'user' = 'user'): PluginModule | null {
try { try {
// 将 CommonJS module.exports 转换为可执行的函数 const wrapped = `(function(){var module={exports:{}};var exports=module.exports;${code}\n;return module.exports;})()`;
// 支持 module.exports = { ... } 和 exports.xxx = ... 语法 const mod = (new Function(wrapped))() as PluginModule;
const wrappedCode = ` if (!mod) {
(function() {
var module = { exports: {} };
var exports = module.exports;
${code}
return module.exports;
})()
`;
const module = new Function(wrappedCode)() as PluginModule;
if (!module) {
console.error('[PluginManager] 插件代码执行后返回空'); console.error('[PluginManager] 插件代码执行后返回空');
return null; return null;
} }
if (mod.pluginInfo?.info) {
// 标记来源 (mod.pluginInfo.info as any).__source = source;
if (module.pluginInfo?.info) {
(module.pluginInfo.info as any).__source = source;
} }
if (module.info) { if (mod.info) {
(module.info as any).__source = source; (mod.info as any).__source = source;
} }
this.registerModule(mod);
this.registerModule(module); return mod;
return module;
} catch (e) { } catch (e) {
console.error('[PluginManager] 加载插件代码失败:', e); console.error('[PluginManager] 加载插件代码失败:', e);
return null; return null;
@@ -100,11 +85,24 @@ class PluginManager {
return false; return false;
} }
/** 获取当前激活插件ID */ /** 获取当前激活插件 ID */
getActivePluginId(): string { getActivePluginId(): string {
return this.activePluginId; return this.activePluginId;
} }
/** 获取指定插件的音质列表 */
getQualityList(id: string): { id: string; name: string; ui: string }[] {
const mod = this.plugins.get(id);
if (mod?.pluginInfo?.quality && mod.pluginInfo.quality.length > 0) {
return mod.pluginInfo.quality;
}
return [
{ id: 'standard', name: '标准', ui: 'SQ' },
{ id: 'high', name: '高品', ui: 'HQ' },
{ id: 'hires', name: '无损', ui: 'HR' },
];
}
/** 搜索(兼容 PC 版 musicSearch.search 接口) */ /** 搜索(兼容 PC 版 musicSearch.search 接口) */
async search(query: string, page: number, limit: number): Promise<PluginSearchResult> { async search(query: string, page: number, limit: number): Promise<PluginSearchResult> {
const plugin = this.getActivePlugin(); const plugin = this.getActivePlugin();
@@ -112,42 +110,87 @@ class PluginManager {
return { list: [], total: 0, error: '当前插件不支持搜索' }; return { list: [], total: 0, error: '当前插件不支持搜索' };
} }
try { try {
return await plugin.musicSearch.search(query, page, limit); const result = await plugin.musicSearch.search(query, page, limit);
if (!result || !Array.isArray(result.list)) {
return { list: [], total: 0, error: '插件未返回正确的搜索结果格式' };
}
return result;
} catch (e) { } catch (e) {
console.error('[PluginManager] 搜索失败:', e); console.error('[PluginManager] 搜索失败:', e);
return { list: [], total: 0, error: (e as Error).message }; return { list: [], total: 0, error: (e as Error).message };
} }
} }
/** 获取歌曲URL兼容 PC 版 getUrl 接口) */ /** 获取歌曲 URL兼容 PC 版 getUrl 接口)
async getSongUrl(id: string, quality: string = 'standard'): Promise<UrlResponse> { * priority: song.source 指定的插件 → 当前激活插件 → 所有插件轮流试
const plugin = this.getActivePlugin(); */
if (!plugin?.getUrl) { async getSongUrl(song: { id: string; source?: string; quality?: string }, preferQuality?: string): Promise<UrlResponse> {
return { success: false, error: '当前插件不支持获取URL' }; const pluginIds: string[] = [];
if (song.source && this.plugins.has(song.source)) {
pluginIds.push(song.source);
} }
try { if (this.activePluginId && !pluginIds.includes(this.activePluginId)) {
const url = await plugin.getUrl(id, quality); pluginIds.push(this.activePluginId);
if (typeof url !== 'string' || !url.startsWith('http')) { }
return { success: false, error: '无效的URL' }; for (const pid of this.plugins.keys()) {
if (!pluginIds.includes(pid)) pluginIds.push(pid);
}
const quality = preferQuality || song.quality || 'standard';
for (const pid of pluginIds) {
const plugin = this.plugins.get(pid);
if (!plugin?.getUrl) continue;
try {
const url = await plugin.getUrl(song.id, quality);
if (typeof url === 'string' && url.length > 0) {
if (/^(https?:)?\/\//i.test(url) || url.startsWith('data:') || url.startsWith('blob:')) {
return { success: true, url };
}
// 一些插件可能返回相对路径,加 https:
if (url.startsWith('//')) {
return { success: true, url: 'https:' + url };
}
}
} catch (e) {
console.warn(`[PluginManager] 插件 ${pid} getUrl 失败:`, e);
} }
return { success: true, url };
} catch (e) {
return { success: false, error: (e as Error).message || '插件错误' };
} }
return { success: false, error: '所有插件都无法获取这首歌曲的播放地址' };
} }
/** 获取歌词(兼容 PC 版 getLyric 接口) */ /** 获取歌词(兼容 PC 版 getLyric 接口)
async getLyric(id: string): Promise<any> { * 返回:{ format: 'lrc' | 'ttml' | 'qrc' | 'yrc' | 'json' | 'text' | null, raw: any }
const plugin = this.getActivePlugin(); */
if (!plugin?.getLyric) { async getLyric(song: { id: string; source?: string }): Promise<{ format: string | null; raw: any }> {
return null; const pluginIds: string[] = [];
if (song.source && this.plugins.has(song.source)) {
pluginIds.push(song.source);
} }
try { if (this.activePluginId && !pluginIds.includes(this.activePluginId)) {
return await plugin.getLyric(id); pluginIds.push(this.activePluginId);
} catch (e) {
console.error('[PluginManager] 获取歌词失败:', e);
return null;
} }
for (const pid of this.plugins.keys()) {
if (!pluginIds.includes(pid)) pluginIds.push(pid);
}
for (const pid of pluginIds) {
const plugin = this.plugins.get(pid);
if (!plugin?.getLyric) continue;
try {
const raw = await plugin.getLyric(song.id);
if (raw == null) continue;
const format = detectLyricFormat(raw);
if (format) {
return { format, raw };
}
// 就算识别不到格式,也返回原始内容
return { format: format, raw };
} catch (e) {
console.warn(`[PluginManager] 插件 ${pid} getLyric 失败:`, e);
}
}
return { format: null, raw: null };
} }
hasPlugins(): boolean { hasPlugins(): boolean {
@@ -169,40 +212,39 @@ class PluginManager {
} }
} }
/** 保存用户插件到 localStorage */ /** 保存用户插件 code 到 localStorage */
saveUserPlugin(code: string): void { saveUserPlugin(code: string): void {
const saved: string[] = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]'); try {
saved.push(code); const saved: string[] = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]');
localStorage.setItem('qz-user-plugins', JSON.stringify(saved)); saved.push(code);
localStorage.setItem('qz-user-plugins', JSON.stringify(saved));
} catch (e) {
console.error('[PluginManager] 保存用户插件失败:', e);
}
} }
/** 删除用户插件代码 */ /** 删除用户插件(按 id 匹配) */
removeUserPlugin(id: string): void { removeUserPlugin(id: string): void {
const saved: string[] = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]'); try {
// 尝试从代码中提取id来匹配 const saved: string[] = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]');
const filtered = saved.filter(code => { const filtered = saved.filter(code => {
try { try {
const wrappedCode = ` const wrapped = `(function(){var module={exports:{}};var exports=module.exports;${code}\n;return module.exports;})()`;
(function() { const mod = (new Function(wrapped))();
var module = { exports: {} }; const info = mod?.pluginInfo?.info || mod?.info;
var exports = module.exports; return info?.id !== id;
${code} } catch {
return module.exports; return true;
})() }
`; });
const mod = new Function(wrappedCode)() as any; localStorage.setItem('qz-user-plugins', JSON.stringify(filtered));
const info = mod?.pluginInfo?.info || mod?.info; } catch (e) {
return info?.id !== id; console.error('[PluginManager] 删除用户插件失败:', e);
} catch { }
return true; // 解析失败的保留
}
});
localStorage.setItem('qz-user-plugins', JSON.stringify(filtered));
} }
/** 提取插件信息(兼容 PC 版两种格式) */ /** 提取插件信息(兼容 PC 版两种格式) */
private extractInfo(module: PluginModule): PluginFullInfo | null { private extractInfo(module: PluginModule): PluginFullInfo | null {
// PC 原版格式pluginInfo.info
if (module.pluginInfo?.info) { if (module.pluginInfo?.info) {
return { return {
...module.pluginInfo.info, ...module.pluginInfo.info,
@@ -210,7 +252,6 @@ class PluginManager {
source: (module.pluginInfo.info as any).__source || 'built-in', source: (module.pluginInfo.info as any).__source || 'built-in',
}; };
} }
// 简化格式info
if (module.info) { if (module.info) {
return { return {
...module.info, ...module.info,
@@ -221,4 +262,35 @@ class PluginManager {
} }
} }
/** 自动识别歌词返回内容的格式 */
function detectLyricFormat(raw: any): string | null {
if (typeof raw === 'string') {
const s = raw.trim();
if (!s) return null;
// 开头是 <tt> / <?xml / <Lyric> → TTML/QRC XML
if (/^<\s*(?:\?xml|tt|TT|lyric\b|Lyric\b|LyricData\b)/i.test(s)) return 'ttml';
// 含有大量 <[00:00.00]> → QRC 风格
if (/<\s*\d+[:]\d+/.test(s)) return 'qrc';
// 含有大量 [00:00.00] 时间戳 → LRC
if (/\[\s*\d{1,2}[:]\d{1,2}(?:[.:]\d{1,3})?\s*\]/.test(s)) return 'lrc';
// 看起来像 JSON
const first = s.charAt(0);
if (first === '{' || first === '[') return 'json';
// 默认纯文本
return 'text';
}
if (typeof raw === 'object') {
// 判断是否是网易云 YRC 格式(有 yrc 字段或逐字信息)
if (raw.yrc || raw.lrclib || raw.klyric) return 'yrc';
if (raw.lrc) return 'lrc';
if (raw.ttml) return 'ttml';
if (raw.qrc) return 'qrc';
if (Array.isArray(raw)) return 'json';
// 带 lines 数组
if (Array.isArray(raw.lines) || Array.isArray(raw.lyric)) return 'json';
return 'json';
}
return null;
}
export const pluginManager = new PluginManager(); export const pluginManager = new PluginManager();

View File

@@ -173,7 +173,6 @@ export const usePlayerStore = defineStore('player', () => {
if (!song) return; if (!song) return;
initAudio(); initAudio();
console.log(song);
currentSong.value = song; currentSong.value = song;
const foundIndex = playlist.value.findIndex(s => s.id === song.id); const foundIndex = playlist.value.findIndex(s => s.id === song.id);
if (foundIndex !== -1) { if (foundIndex !== -1) {
@@ -181,36 +180,48 @@ export const usePlayerStore = defineStore('player', () => {
} }
updateMediaSession(song); updateMediaSession(song);
if (audioContext && audioContext.state === 'suspended') {
try { await audioContext.resume(); } catch { /* ignore */ }
}
// 1) URL优先使用 song 自带有效 URL否则走插件系统
let playUrl = song.url;
const isValidUrl = typeof playUrl === 'string' && playUrl.length > 0
&& (/^https?:\/\//i.test(playUrl) || playUrl.startsWith('data:') || playUrl.startsWith('blob:'));
if (!isValidUrl) {
try {
// 动态引入,避免 SSR/初始化阶段依赖问题
const pluginManager = (await import('../plugins/index')).pluginManager;
const res = await pluginManager.getSongUrl(song);
if (res?.success && res.url) {
playUrl = res.url;
song.url = res.url;
}
} catch (e) {
console.warn('[Player] 插件获取 URL 失败:', e);
}
}
// 2) 歌词:用插件系统并行获取(不阻塞播放)
fetchLyrics(song); fetchLyrics(song);
// Resume audio context if suspended (for autoplay policy)
if (audioContext && audioContext.state === 'suspended') {
await audioContext.resume();
}
let playUrl = song.url;
if (song.type === 'Remote' && song.source) {
const quality = 'hires';
playUrl = `http://localhost:5266/music?source=${song.source}&id=${song.id}&quality=${quality}`;
console.log('[Player] Using Proxy:', playUrl);
}
if (playUrl && audioElement) { if (playUrl && audioElement) {
console.log('Playing:', song.name, 'AutoPlay:', autoPlay);
try { try {
audioElement.src = playUrl; audioElement.src = playUrl;
audioElement.load(); audioElement.load();
if (autoPlay) { if (autoPlay) {
await audioElement.play(); await audioElement.play();
} }
song.url = playUrl;
playErrorCount.value = 0; playErrorCount.value = 0;
} catch (e) { } catch (e) {
console.error("Play request failed:", e); console.error('[Player] 播放失败:', e);
if (autoPlay) handlePlayError(); if (autoPlay) handlePlayError();
} }
} else { } else {
console.warn("Song has no URL"); console.warn('[Player] 歌曲无可用 URL:', song.name);
MessagePlugin.warning('当前音源插件无法获取这首歌的播放地址').then();
if (autoPlay) handlePlayError(); if (autoPlay) handlePlayError();
} }
}; };
@@ -219,10 +230,19 @@ export const usePlayerStore = defineStore('player', () => {
lyrics.value = { lines: [] }; lyrics.value = { lines: [] };
if (!song || !song.id) return; if (!song || !song.id) return;
try { try {
// TODO: Implement web-based lyric fetching const pluginManager = (await import('../plugins/index')).pluginManager;
MessagePlugin.info("网页版暂不支持歌词获取").then(); const lyricData = await pluginManager.getLyric(song);
if (lyricData && (lyricData.raw || lyricData.format)) {
const { parseAnyLyric } = await import('../utils/lyricUtil');
const parsed = parseAnyLyric(lyricData);
if (Array.isArray(parsed) && parsed.length > 0) {
lyrics.value = { lines: parsed };
return;
}
}
// 插件无歌词或解析为空 → 不弹提示打扰用户
} catch (e) { } catch (e) {
console.error('Failed to fetch lyrics:', e); console.error('[Player] 获取歌词失败:', e);
} }
}; };

View File

@@ -23,6 +23,38 @@ export interface PluginFullInfo extends PluginInfo {
quality?: PluginQuality[]; quality?: PluginQuality[];
} }
/** PC 原版插件搜索结果单条(常见字段) */
export interface PluginSearchItem {
songmid?: string;
id?: string;
name?: string;
singer?: string;
artist?: string;
albumName?: string;
album?: string;
albumId?: string;
interval?: number;
duration?: number;
img?: string;
m_img?: string;
s_img?: string;
picUrl?: string;
source?: string;
types?: { [qualityId: string]: string | number };
quality?: string[];
[key: string]: any;
}
/** 搜索结果 */
export interface PluginSearchResult {
list: PluginSearchItem[];
total?: number;
songCount?: number;
allPage?: number;
error?: string;
[key: string]: any;
}
/** PC 原版插件模块接口 */ /** PC 原版插件模块接口 */
export interface PluginModule { export interface PluginModule {
pluginInfo?: { pluginInfo?: {
@@ -34,16 +66,7 @@ export interface PluginModule {
musicSearch?: { musicSearch?: {
search: (query: string, page: number, limit: number) => Promise<PluginSearchResult> | PluginSearchResult; search: (query: string, page: number, limit: number) => Promise<PluginSearchResult> | PluginSearchResult;
}; };
getLyric?: (id: string) => Promise<string | object> | string | object; getLyric?: (id: string) => Promise<string | object | ArrayBuffer> | string | object | ArrayBuffer;
}
/** 搜索结果 */
export interface PluginSearchResult {
list: any[];
total?: number;
songCount?: number;
allPage?: number;
error?: string;
} }
/** URL 响应 */ /** URL 响应 */

View File

@@ -1,57 +1,506 @@
import {LyricLine, parseLrc, parseQrc, parseTTML, parseYrc} from "@applemusic-like-lyrics/lyric"; /**
const sanitizeLyricLines = (lines: LyricLine[]): LyricLine[] => { * 多格式歌词解析器 - 兼容 PC/Android 版 QZMusic 返回的各种歌词格式
const defaultLineDuration = 3000 *
const toFiniteNumber = (v: any, fallback: number) => { * 支持的格式:
const n = typeof v === 'number' ? v : Number(v) * - LRC: [00:12.34]歌词内容(带逐字 [00:12.34]<00:00.50>...
return Number.isFinite(n) ? n : fallback * - QRC: <00:00.00> 逐字 XML 风格 / 腾讯 Q 音乐格式
} * - TTML/XML: <tt><body><div><p begin="0.00s" end="...">歌词</p></div></body></tt>
const cleaned: LyricLine[] = [] * - YRC: 网易云逐字 JSON 格式 { yrc: { version:1, lyric:[{...,words:[...]}] } }
for (const rawLine of lines || []) { * - JSON: { lrc: { lyric: "..." }, tlyric: {...}, yrc: {...} }
const rawWords = Array.isArray((rawLine as any).words) ? (rawLine as any).words : [] * - SRT: 1\n00:00:01,000 --> 00:00:03,000\n文本\n
const fixedWords: any[] = [] * - VTT: WEBVTT\n\n00:00:01.000 --> 00:00:03.000\n文本
let prevEnd = -1 * - 纯文本: 普通歌词,没有时间戳
for (const rawWord of rawWords) { *
const rawStart = toFiniteNumber(rawWord?.startTime, Number.NaN) * 最终统一输出 { lines: LyricLine[] },每个 LyricLine 有 startTime、endTime、text、words
const rawEnd = toFiniteNumber(rawWord?.endTime, Number.NaN) */
if (!Number.isFinite(rawStart)) continue
let startTime = Math.max(0, rawStart) export interface LyricWord {
if (startTime < prevEnd) startTime = prevEnd startTime: number; // 毫秒
let endTime = Number.isFinite(rawEnd) ? rawEnd : startTime + 1 endTime: number; // 毫秒
if (endTime <= startTime) endTime = startTime + 1 text: string;
prevEnd = endTime }
fixedWords.push({ ...rawWord, startTime, endTime })
export interface LyricLine {
startTime: number; // 毫秒
endTime: number; // 毫秒
text: string;
words?: LyricWord[]; // 逐字信息(可选)
raw?: any; // 原始数据(调试用)
}
const DEFAULT_LINE_DURATION = 5000; // 默认每行 5 秒
/**
* 主入口:根据给定的 { format, raw } 自动解析,或传入未知内容自动识别
*/
export function parseAnyLyric(input: { format?: string | null; raw: any } | any): LyricLine[] {
let format: string | null = input?.format;
let raw: any = input?.raw;
// 兼容直接传入字符串 / 对象
if (format == null && raw == null) {
if (typeof input === 'string' || (typeof input === 'object' && (input as any) !== null)) {
raw = input;
} else {
return [];
} }
if (fixedWords.length === 0) continue
const firstWordStart = fixedWords[0].startTime
const lastWordEnd = fixedWords[fixedWords.length - 1].endTime
let startTime = toFiniteNumber((rawLine as any).startTime, firstWordStart)
startTime = Math.max(0, startTime)
let endTime = toFiniteNumber((rawLine as any).endTime, lastWordEnd)
if (!Number.isFinite(endTime) || endTime <= startTime) endTime = startTime + defaultLineDuration
if (endTime < lastWordEnd) endTime = lastWordEnd
cleaned.push({ ...(rawLine as any), startTime, endTime, words: fixedWords })
} }
cleaned.sort((a: any, b: any) => (a?.startTime ?? 0) - (b?.startTime ?? 0))
return cleaned if (raw == null) return [];
}
interface LyricData { if (!format) {
ttml?: string, format = detectFormat(raw);
yrc?: string,
lrc?: string,
qrc?: string
}
export function parseLyric(lyric: LyricData):LyricLine[] {
let parsed:LyricLine[] = []
if (lyric.ttml != undefined) {
parsed = parseTTML(lyric.ttml).lines;
} else if (lyric.yrc != undefined) {
parsed = parseYrc(lyric.yrc);
} else if (lyric.lrc != undefined) {
parsed = parseLrc(lyric.lrc);
} else if (lyric.qrc != undefined) {
parsed = parseQrc(lyric.qrc)
} }
return sanitizeLyricLines(parsed);
} switch (format) {
case 'lrc': return parseLrc(typeof raw === 'string' ? raw : extractStringField(raw, 'lrc'));
case 'qrc': return parseQrc(typeof raw === 'string' ? raw : extractStringField(raw, 'qrc'));
case 'ttml': return parseTTML(typeof raw === 'string' ? raw : extractStringField(raw, 'ttml'));
case 'yrc': return parseYrc(typeof raw === 'object' && raw != null ? raw : tryParseJSON(String(raw)));
case 'srt': return parseSrt(String(raw));
case 'vtt': return parseVtt(String(raw));
case 'json': return parseJSON(raw);
case 'text': return parseText(String(raw));
default: {
// 最后兜底:试各种解析,取第一个返回非空的
if (typeof raw === 'string') {
const lrc = parseLrc(raw);
if (lrc.length > 0) return lrc;
return parseText(raw);
}
return parseJSON(raw);
}
}
}
// ========== 格式探测 ==========
function detectFormat(raw: any): string | null {
if (typeof raw === 'string') {
const s = raw.trim();
if (!s) return null;
if (/^<\s*(?:\?xml|tt\b|TT\b|lyric\b|Lyric\b|LyricData\b)/i.test(s)) return 'ttml';
if (/<\s*\d+[:]\d+/.test(s)) return 'qrc';
if (/\[\s*\d{1,2}[:]\d{1,2}(?:[.:]\d{1,3})?\s*\]/.test(s)) return 'lrc';
const first = s.charAt(0);
if (first === '{' || first === '[') return 'json';
return 'text';
}
if (typeof raw === 'object' && raw !== null) {
if (raw.yrc || raw.lrclib || raw.klyric) return 'yrc';
if (raw.lrc) return 'lrc';
if (raw.ttml) return 'ttml';
if (raw.qrc) return 'qrc';
return 'json';
}
return null;
}
function extractStringField(obj: any, field: string): string {
if (typeof obj[field] === 'string') return obj[field];
if (typeof obj[field]?.lyric === 'string') return obj[field].lyric;
return '';
}
function tryParseJSON(s: string): any {
try { return JSON.parse(s); } catch { return null; }
}
// ========== LRC 解析 ==========
export function parseLrc(text: string): LyricLine[] {
if (!text) return [];
const lines: LyricLine[] = [];
// 支持多个时间戳同一行: [00:12.00][00:25.00]歌词
const lineRegex = /((?:\[\s*\d{1,2}[:]\d{1,2}(?:[.:]\d{1,3})?\s*\])+)(.*)/g;
const timeRegex = /\[\s*(\d{1,2})[:](\d{1,2})(?:[.:](\d{1,3}))?\s*\]/g;
let match;
while ((match = lineRegex.exec(text)) !== null) {
const stamps = match[1];
const content = (match[2] || '').trim();
if (!content) continue;
let m;
timeRegex.lastIndex = 0;
while ((m = timeRegex.exec(stamps)) !== null) {
const mm = parseInt(m[1], 10);
const ss = parseInt(m[2], 10);
const msRaw = m[3] || '0';
const ms = parseInt(msRaw.padEnd(3, '0').substring(0, 3), 10);
const startTime = mm * 60000 + ss * 1000 + ms;
lines.push({ startTime, endTime: startTime + DEFAULT_LINE_DURATION, text: content });
}
}
// 检查是否有逐字信息QQ音乐 lrc 逐字扩展: [00:12.00]<00:00.00>字<00:00.50>字...
for (const line of lines) {
const words = extractInlineWords(line.text, line.startTime);
if (words.length > 1) {
line.words = words;
line.endTime = words[words.length - 1].endTime;
line.text = words.map(w => w.text).join('');
}
}
sortAndFixEndTime(lines);
return lines;
}
// ========== 逐字LRC 内嵌 <mm:ss.xx>word 或 QRC 风格) ==========
function extractInlineWords(text: string, _lineStart: number): LyricWord[] {
// 匹配 <mm:ss.xx>字 或 <ms>字
const regex = /<\s*(\d{1,2})[:](\d{1,2})(?:[.:](\d{1,3}))?\s*>([^<]*)/g;
const words: LyricWord[] = [];
let m;
while ((m = regex.exec(text)) !== null) {
const mm = parseInt(m[1], 10);
const ss = parseInt(m[2], 10);
const msRaw = m[3] || '0';
const ms = parseInt(msRaw.padEnd(3, '0').substring(0, 3), 10);
const start = mm * 60000 + ss * 1000 + ms;
words.push({ startTime: start, endTime: start + 500, text: (m[4] || '').trim() });
}
if (words.length > 1) {
for (let i = 0; i < words.length - 1; i++) {
words[i].endTime = words[i + 1].startTime;
}
}
return words;
}
// ========== QRC 解析QQ音乐逐字歌词 XML/文本风格) ==========
export function parseQrc(text: string): LyricLine[] {
if (!text) return [];
const lines: LyricLine[] = [];
// QRC 常见格式: [ms,ms]0 字/字/字 ... 或逐行 <mm:ss.xxx>字<mm:ss.xxx>字
// 先按行尝试
const rawLines = text.split(/\r?\n/).filter(l => l.trim().length > 0);
for (const rawLine of rawLines) {
// 格式1: [start,end]1 字1字2字3...
const bracketMatch = rawLine.match(/\[\s*(\d+)\s*,\s*(\d+)\s*\](.*)/);
if (bracketMatch) {
const startTime = parseInt(bracketMatch[1], 10);
const endTime = parseInt(bracketMatch[2], 10);
let content = bracketMatch[3].replace(/^[\(\[]\d+[\)\]]/, '').trim();
// 有时候是 "(10)字1字2字3" - 其中 (10) 是逐字时长索引
lines.push({ startTime, endTime: endTime || startTime + DEFAULT_LINE_DURATION, text: content, raw: rawLine });
continue;
}
// 格式2: <00:12.340>字<00:12.840>字...(把一整行拆成逐字)
if (/<\s*\d+[:]\d+/.test(rawLine)) {
const words = extractInlineWords(rawLine, 0);
if (words.length > 0) {
const start = words[0].startTime;
const end = words[words.length - 1].endTime;
const combinedText = words.map(w => w.text).join('');
lines.push({ startTime: start, endTime: end, text: combinedText, words });
}
}
}
if (lines.length === 0) {
// 尝试整段内嵌 <mm:ss.xx> 字的流式文本
const words = extractInlineWords(text, 0);
if (words.length > 0) {
// 按停顿把 words 聚合为行:这里简单把每个字当一行(用户机器若用不到会被 UI 平滑合并)
for (const w of words) {
lines.push({ startTime: w.startTime, endTime: w.endTime, text: w.text, words: [w] });
}
}
}
sortAndFixEndTime(lines);
return lines;
}
// ========== TTML / XML 歌词 ==========
export function parseTTML(text: string): LyricLine[] {
if (!text) return [];
const lines: LyricLine[] = [];
// 简易解析:用正则匹配所有 <p ... begin="..." end="..." ...>内容</p>
const pRegex = /<\s*p\b([^>]*)>([\s\S]*?)<\s*\/\s*p\s*>/gi;
let m;
while ((m = pRegex.exec(text)) !== null) {
const attrs = m[1];
const content = stripTags(m[2]).trim();
if (!content) continue;
const begin = extractTimeAttr(attrs, /\bbegin\s*=\s*["']([^"']+)["']/i);
const end = extractTimeAttr(attrs, /\bend\s*=\s*["']([^"']+)["']/i);
if (begin == null) continue;
lines.push({
startTime: begin,
endTime: end != null ? end : begin + DEFAULT_LINE_DURATION,
text: content,
});
}
if (lines.length === 0) {
// 退化为纯 <Line>Start="ms">内容</Line> 的 XML
const lineRegex = /<\s*(?:line|Line|LyricLine|Item)\b([^>]*)>([\s\S]*?)<\s*\/\s*(?:line|Line|LyricLine|Item)\s*>/gi;
while ((m = lineRegex.exec(text)) !== null) {
const attrs = m[1];
const content = stripTags(m[2]).trim();
if (!content) continue;
const start = extractNumericAttr(attrs, /\b(?:start|Start|Start\s*Time)\s*=\s*["']([^"']+)["']/i)
?? extractNumericAttr(attrs, /\b(\d+)\b/);
let end = extractNumericAttr(attrs, /\b(?:end|End|End\s*Time)\s*=\s*["']([^"']+)["']/i);
if (start == null) continue;
if (end == null) end = start + DEFAULT_LINE_DURATION;
lines.push({ startTime: start, endTime: end, text: content });
}
}
sortAndFixEndTime(lines);
return lines;
}
function stripTags(s: string): string {
return s.replace(/<[^>]+>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
}
function extractTimeAttr(attrs: string, regex: RegExp): number | null {
const m = attrs.match(regex);
if (!m) return null;
return parseTimeString(m[1]);
}
function extractNumericAttr(attrs: string, regex: RegExp): number | null {
const m = attrs.match(regex);
if (!m) return null;
const n = Number(m[1]);
return Number.isFinite(n) ? n : parseTimeString(m[1]);
}
/** 解析 "HH:MM:SS.mmm" / "MM:SS.mmm" / "1234ms" / "12.34s" / "1234" */
function parseTimeString(t: string): number | null {
if (!t) return null;
const s = t.trim();
// 纯数字 → 毫秒或秒(小于 3600 视为秒)
if (/^\d+(\.\d+)?$/.test(s)) {
const n = Number(s);
return n < 3600 && s.includes('.') ? Math.round(n * 1000) : Math.round(n);
}
// HH:MM:SS.mmm / MM:SS.mmm
let m = s.match(/^(\d+):(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?/);
if (m) {
const h = parseInt(m[1], 10), mm = parseInt(m[2], 10), ss = parseInt(m[3], 10);
const msRaw = m[4] || '0';
const ms = parseInt(msRaw.padEnd(3, '0').substring(0, 3), 10);
return h * 3600000 + mm * 60000 + ss * 1000 + ms;
}
m = s.match(/^(\d+):(\d{1,2})(?:[.,](\d{1,3}))?/);
if (m) {
const mm = parseInt(m[1], 10), ss = parseInt(m[2], 10);
const msRaw = m[3] || '0';
const ms = parseInt(msRaw.padEnd(3, '0').substring(0, 3), 10);
return mm * 60000 + ss * 1000 + ms;
}
// 12.34s / 1234ms
m = s.match(/^(\d+(?:\.\d+)?)\s*(ms|s)?$/i);
if (m) {
const n = Number(m[1]);
const unit = (m[2] || 'ms').toLowerCase();
return unit === 's' ? Math.round(n * 1000) : Math.round(n);
}
return null;
}
// ========== YRC网易云逐字歌词==========
export function parseYrc(raw: any): LyricLine[] {
if (!raw) return [];
const lines: LyricLine[] = [];
// YRC 常见结构: { yrc: { version: 1, lyric: [ { "time": 1234, "words": [ ... ] } ] } }
// 也可能是 stringified JSON
let data: any = raw;
if (typeof raw === 'string') {
const p = tryParseJSON(raw);
if (p) data = p;
}
// 向下取字段
const yrc = data?.yrc ?? data?.lrclib ?? data?.klyric ?? data;
const lyricArr = yrc?.lyric ?? yrc?.lines ?? yrc?.lyrics ?? (Array.isArray(yrc) ? yrc : null);
if (Array.isArray(lyricArr)) {
for (const row of lyricArr) {
// row: { time: start_ms, duration: ms_line, "lyric": "word (start, duration) word2 (start2, duration2)" }
// 或 row: { t: ms, d: ms, w: [ {t:ms,s:ms}, ... ] }
const startTime = toNumber(row?.time ?? row?.t ?? row?.start ?? row?.startTime ?? 0);
const duration = toNumber(row?.duration ?? row?.d ?? row?.dur ?? DEFAULT_LINE_DURATION);
const wordsRaw = row?.words ?? row?.w ?? row?.lyric ?? row?.text ?? '';
if (Array.isArray(wordsRaw)) {
const wordList: LyricWord[] = [];
for (const w of wordsRaw) {
const ws = toNumber(w?.time ?? w?.t ?? w?.start ?? 0);
const we = ws + toNumber(w?.duration ?? w?.d ?? 500);
const text = String(w?.text ?? w?.word ?? w?.name ?? '').trim();
if (text) wordList.push({ startTime: ws + startTime, endTime: we + startTime, text });
}
if (wordList.length > 0) {
const fullText = wordList.map(w => w.text).join('');
lines.push({
startTime: wordList[0].startTime,
endTime: wordList[wordList.length - 1].endTime,
text: fullText,
words: wordList,
});
continue;
}
}
// 回退:纯文本形式的一行
const text = String(wordsRaw || row?.text || '').trim();
if (text) {
lines.push({ startTime, endTime: startTime + duration, text });
}
}
}
if (lines.length === 0) {
// 有些返回 { lrc: { lyric: "..." } }
const lrc = data?.lrc?.lyric;
if (typeof lrc === 'string') return parseLrc(lrc);
}
sortAndFixEndTime(lines);
return lines;
}
function toNumber(v: any): number {
if (typeof v === 'number') return v;
if (typeof v === 'string') {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
return 0;
}
// ========== JSON通用对象形式 ==========
export function parseJSON(raw: any): LyricLine[] {
if (raw == null) return [];
if (typeof raw === 'string') {
// 先当 JSON 试一下
const obj = tryParseJSON(raw);
if (obj) return parseJSON(obj);
// 失败就退回 LRC
return parseLrc(raw);
}
// 优先看有没有 lrc / yrc / ttml / qrc 字段
if (typeof raw.lrc?.lyric === 'string') return parseLrc(raw.lrc.lyric);
if (typeof raw.lrc === 'string') return parseLrc(raw.lrc);
if (typeof raw.yrc === 'string' || raw.yrc?.lyric) return parseYrc(raw);
if (typeof raw.ttml === 'string') return parseTTML(raw.ttml);
if (typeof raw.qrc === 'string') return parseQrc(raw.qrc);
// 数组形式: [{ time: 1234, text: "..." }]
if (Array.isArray(raw)) {
return raw.map((item: any) => {
const startTime = toNumber(item?.time ?? item?.t ?? item?.start ?? item?.startTime ?? 0);
const endTime = toNumber(item?.end ?? item?.endTime ?? 0) || startTime + DEFAULT_LINE_DURATION;
return { startTime, endTime, text: String(item?.text ?? item?.content ?? item?.lyric ?? '').trim() };
}).filter(l => l.text);
}
if (Array.isArray(raw.lines)) {
return parseJSON(raw.lines);
}
if (Array.isArray(raw.lyric)) {
return parseJSON(raw.lyric);
}
return [];
}
// ========== SRT 字幕 ==========
export function parseSrt(text: string): LyricLine[] {
if (!text) return [];
const lines: LyricLine[] = [];
const blocks = text.replace(/\r\n/g, '\n').split(/\n{2,}/);
for (const block of blocks) {
const rows = block.split('\n').filter(Boolean);
if (rows.length < 2) continue;
const timeLine = rows[0].includes('-->') ? rows[0] : rows[1];
const textStart = rows[0].includes('-->') ? 1 : 2;
const t = timeLine.match(/(\d+):(\d{2}):(\d{2})[.,](\d{1,3})\s*-->\s*(\d+):(\d{2}):(\d{2})[.,](\d{1,3})/);
if (!t) continue;
const start = (+t[1]) * 3600000 + (+t[2]) * 60000 + (+t[3]) * 1000 + parseInt((t[4] || '0').padEnd(3, '0').substring(0, 3), 10);
const end = (+t[5]) * 3600000 + (+t[6]) * 60000 + (+t[7]) * 1000 + parseInt((t[8] || '0').padEnd(3, '0').substring(0, 3), 10);
const content = rows.slice(textStart).join(' ').trim();
if (content) lines.push({ startTime: start, endTime: end, text: content });
}
sortAndFixEndTime(lines);
return lines;
}
// ========== WebVTT ==========
export function parseVtt(text: string): LyricLine[] {
if (!text) return [];
// VTT 和 SRT 几乎一样,只是开头是 WEBVTT并且时间格式点号
const lines: LyricLine[] = [];
const clean = text.replace(/^WEBVTT[\s\S]*?\n\n/i, '').replace(/\r\n/g, '\n');
const blocks = clean.split(/\n{2,}/);
for (const block of blocks) {
const rows = block.split('\n').filter(Boolean);
if (rows.length < 1) continue;
const timeLine = rows.find(r => r.includes('-->'));
if (!timeLine) continue;
const idx = rows.indexOf(timeLine);
const t = timeLine.match(/(\d+):(\d{2})(?::(\d{2}))?(?:[.,](\d{1,3}))?\s*-->\s*(\d+):(\d{2})(?::(\d{2}))?(?:[.,](\d{1,3}))?/);
if (!t) continue;
let start, end;
if (t[3] != null) {
// HH:MM:SS.mmm
start = (+t[1]) * 3600000 + (+t[2]) * 60000 + (+t[3]) * 1000 + parseInt((t[4] || '0').padEnd(3, '0').substring(0, 3), 10);
end = (+t[5]) * 3600000 + (+t[6]) * 60000 + (+t[7]) * 1000 + parseInt((t[8] || '0').padEnd(3, '0').substring(0, 3), 10);
} else {
// MM:SS.mmm
start = (+t[1]) * 60000 + (+t[2]) * 1000 + parseInt((t[4] || '0').padEnd(3, '0').substring(0, 3), 10);
end = (+t[5]) * 60000 + (+t[6]) * 1000 + parseInt((t[8] || '0').padEnd(3, '0').substring(0, 3), 10);
}
const content = rows.slice(idx + 1).join(' ').trim();
if (content) lines.push({ startTime: start, endTime: end, text: content });
}
sortAndFixEndTime(lines);
return lines;
}
// ========== 纯文本 ==========
export function parseText(text: string): LyricLine[] {
if (!text) return [];
const rows = text.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
const lines: LyricLine[] = [];
let cursor = 0;
for (const row of rows) {
const start = cursor;
const dur = Math.max(3000, row.length * 250);
lines.push({ startTime: start, endTime: start + dur, text: row });
cursor += dur;
}
return lines;
}
// ========== 公共工具:排序 & 修正每行的 endTime ==========
function sortAndFixEndTime(lines: LyricLine[]) {
if (!lines || lines.length === 0) return;
lines.sort((a, b) => a.startTime - b.startTime);
for (let i = 0; i < lines.length - 1; i++) {
if (lines[i].endTime == null || lines[i].endTime <= lines[i].startTime || lines[i].endTime > lines[i + 1].startTime) {
lines[i].endTime = lines[i + 1].startTime;
}
}
const last = lines[lines.length - 1];
if (last.endTime == null || last.endTime <= last.startTime) {
last.endTime = last.startTime + DEFAULT_LINE_DURATION;
}
}
/**
* 把输入归一化成 { lines: LyricLine[] } 形式,用于与现有调用方兼容
*/
export function normalizeLyric(input: any): { lines: LyricLine[] } {
return { lines: parseAnyLyric(input) };
}
// @applemusic-like-lyrics/vue 的 LyricPlayer 需要 lines 中每项有
// startTime、endTime、text以及可选的 words与上面的 LyricLine 完全兼容
// 因此直接返回 parseAnyLyric() 的结果即可