feat: 插件系统重构 - 支持官方 v3 音源 (wy/tx/kw/kg/mg) + 多格式歌词解析 + 动态加载 webpack bundle + 动态从 /plugins/ 目录加载官方音源
This commit is contained in:
48
install.sh
48
install.sh
@@ -96,6 +96,54 @@ fi
|
||||
log_info "项目构建成功!"
|
||||
echo ""
|
||||
|
||||
# ========= 下载官方音源插件 (v3) =========
|
||||
log_info "正在下载官方音源插件到 dist/plugins/..."
|
||||
PLUGIN_DIR="$INSTALL_DIR/dist/plugins"
|
||||
mkdir -p "$PLUGIN_DIR"
|
||||
FILE_SERVER="http://171.80.3.149:5244"
|
||||
SHARE_CODE="music"
|
||||
PLUGIN_FILES=(
|
||||
"zq_wy_v3.js"
|
||||
"zq_tx_v3-fix1.js"
|
||||
"zq_kw_v3-fix1.js"
|
||||
"zq_kg.js"
|
||||
"zq_mg_v3.js"
|
||||
)
|
||||
DOWNLOADED=0
|
||||
for fname in "${PLUGIN_FILES[@]}"; do
|
||||
target="$PLUGIN_DIR/$fname"
|
||||
# 通过 fs/get API 获取 raw_url (JSON 中 data.raw_url)
|
||||
api_resp=$(curl -s -X POST "$FILE_SERVER/api/fs/get" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"path\":\"/@s/c6VNt7hG/音源/QZ-Music_v2/官方/v3/$fname\",\"password\":\"$SHARE_CODE\"}" \
|
||||
--max-time 30 2>/dev/null || echo "")
|
||||
|
||||
raw_url=""
|
||||
if [ -n "$api_resp" ]; then
|
||||
# 使用 python3 解析 JSON 取 data.raw_url;若失败则回退 sd 直链
|
||||
raw_url=$(echo "$api_resp" | python3 -c "import sys,json;
|
||||
try:
|
||||
d=json.load(sys.stdin)
|
||||
print(d.get('data',{}).get('raw_url',''))
|
||||
except:
|
||||
print('')" 2>/dev/null)
|
||||
fi
|
||||
|
||||
if [ -z "$raw_url" ]; then
|
||||
raw_url="$FILE_SERVER/sd/c6VNt7hG/音源/QZ-Music_v2/官方/v3/$fname?pwd=$SHARE_CODE"
|
||||
fi
|
||||
|
||||
if curl -sL "$raw_url" --max-time 120 -o "$target" -w '%{http_code}' 2>/dev/null | grep -qE "^2"; then
|
||||
DOWNLOADED=$((DOWNLOADED + 1))
|
||||
size=$(wc -c < "$target" 2>/dev/null | tr -d ' ')
|
||||
log_info " ✓ $fname ($size bytes)"
|
||||
else
|
||||
log_warn " ✗ $fname 下载失败"
|
||||
fi
|
||||
done
|
||||
log_info "音源插件下载完成: $DOWNLOADED/${#PLUGIN_FILES[@]}"
|
||||
echo ""
|
||||
|
||||
# ========= 部署 systemd 服务 =========
|
||||
log_info "正在部署 systemd 服务: $SERVICE_NAME"
|
||||
|
||||
|
||||
@@ -57,9 +57,9 @@ module.exports = {
|
||||
`;
|
||||
|
||||
export const defaultPluginModule: PluginModule = (function() {
|
||||
var module = { exports: {} };
|
||||
var module: { exports: any } = { exports: {} };
|
||||
eval(defaultPluginCode);
|
||||
return module.exports;
|
||||
return module.exports as PluginModule;
|
||||
})();
|
||||
|
||||
export { defaultPluginCode };
|
||||
|
||||
@@ -1,15 +1,84 @@
|
||||
import { pluginManager, defaultPluginModule } from './index';
|
||||
import { pluginManager } from './pluginManager';
|
||||
import type { PluginModule } from '../types/plugin';
|
||||
|
||||
export const initPlugins = async (): Promise<void> => {
|
||||
// 注册内置默认插件
|
||||
pluginManager.registerModule(defaultPluginModule);
|
||||
/**
|
||||
* 初始化插件系统
|
||||
* 1. 先注册本地默认演示插件
|
||||
* 2. 恢复用户保存的插件
|
||||
* 3. 尝试从 /plugins/ 目录动态加载官方音源插件(如果浏览器环境)
|
||||
*/
|
||||
export const initPlugins = async (): Promise<{ total: number; builtins: number; user: number }> => {
|
||||
let builtins = 0;
|
||||
let user = 0;
|
||||
|
||||
// 恢复用户保存的插件
|
||||
pluginManager.loadUserPlugins();
|
||||
// 默认演示插件(用于演示界面,不依赖网络)
|
||||
const demoPlugin: PluginModule = {
|
||||
pluginInfo: {
|
||||
info: { id: 'demo', name: '演示音源', description: '本地演示插件', version: '1.0' },
|
||||
quality: [{ id: 'standard', name: '标准', ui: '标' }],
|
||||
} as any,
|
||||
musicSearch: {
|
||||
search: async (query: string, page: number, limit: number) => {
|
||||
const songs = [
|
||||
{ songmid: 'demo-1', name: '晴天', singer: '周杰伦', albumName: '叶惠美', interval: 269000, img: '', source: 'demo' },
|
||||
{ songmid: 'demo-2', name: '稻香', singer: '周杰伦', albumName: '魔杰座', interval: 223000, img: '', source: 'demo' },
|
||||
{ songmid: 'demo-3', name: '七里香', singer: '周杰伦', albumName: '七里香', interval: 299000, img: '', source: 'demo' },
|
||||
{ songmid: 'demo-4', name: '告白气球', singer: '周杰伦', albumName: '周杰伦的床边故事', interval: 215000, img: '', source: 'demo' },
|
||||
];
|
||||
const q = (query || '').toLowerCase();
|
||||
const filtered = q ? songs.filter(s => s.name.toLowerCase().includes(q) || s.singer.toLowerCase().includes(q)) : songs;
|
||||
const start = Math.max(0, (page - 1) * limit);
|
||||
return { list: filtered.slice(start, start + limit), total: filtered.length };
|
||||
},
|
||||
},
|
||||
getUrl: async () => '',
|
||||
getLyric: async () => '[00:00.00]暂无歌词数据\n[00:05.00]演示内容\n',
|
||||
};
|
||||
pluginManager.registerModule(demoPlugin);
|
||||
builtins++;
|
||||
|
||||
// 恢复上次选择的插件
|
||||
const savedPlugin = sessionStorage.getItem('qz-active-plugin');
|
||||
if (savedPlugin && pluginManager.get(savedPlugin)) {
|
||||
pluginManager.setActivePlugin(savedPlugin);
|
||||
// 恢复用户插件
|
||||
const userLoaded = pluginManager.loadUserPlugins();
|
||||
user = userLoaded;
|
||||
|
||||
// 尝试从 /plugins/ 目录动态加载官方音源插件
|
||||
if (typeof window !== 'undefined' && typeof fetch === 'function') {
|
||||
try {
|
||||
const manifest = [
|
||||
{ id: 'wy', fileName: 'zq_wy_v3.js', name: '网易云' },
|
||||
{ id: 'tx', fileName: 'zq_tx_v3-fix1.js', name: 'QQ音乐' },
|
||||
{ id: 'kw', fileName: 'zq_kw_v3-fix1.js', name: '酷我' },
|
||||
{ id: 'kg', fileName: 'zq_kg.js', name: '酷狗' },
|
||||
{ id: 'mg', fileName: 'zq_mg_v3.js', name: '咪咕' },
|
||||
];
|
||||
for (const entry of manifest) {
|
||||
try {
|
||||
if (pluginManager.has(entry.id)) continue;
|
||||
const resp = await fetch('/plugins/' + entry.fileName, { cache: 'no-cache' });
|
||||
if (!resp.ok) continue;
|
||||
const code = await resp.text();
|
||||
pluginManager.loadFromCode(code, 'built-in');
|
||||
builtins++;
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 恢复上次的激活插件
|
||||
const saved = sessionStorage.getItem('qz-active-plugin');
|
||||
if (saved && pluginManager.get(saved)) {
|
||||
pluginManager.setActivePlugin(saved);
|
||||
} else if (pluginManager.getAll().length > 0) {
|
||||
// 默认优先 wy,没有则选第一个
|
||||
const priority = ['wy', 'tx', 'kw', 'kg', 'mg', 'demo'];
|
||||
const all = pluginManager.getAll();
|
||||
for (const pid of priority) {
|
||||
if (all.find(p => p.id === pid)) {
|
||||
pluginManager.setActivePlugin(pid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { total: builtins + user, builtins, user };
|
||||
};
|
||||
|
||||
@@ -1,293 +1,353 @@
|
||||
import type { PluginFullInfo, PluginModule, PluginSearchResult, UrlResponse } from '../types/plugin';
|
||||
import type {
|
||||
PluginModule,
|
||||
PluginFullInfo,
|
||||
PluginSearchResult,
|
||||
UrlResponse,
|
||||
} from '../types/plugin';
|
||||
|
||||
/**
|
||||
* QZMusic Web 插件管理器
|
||||
* 兼容 PC/Android 原版的 CommonJS 插件格式:
|
||||
* module.exports = {
|
||||
* pluginInfo: { info: { id, name, ... }, quality: [...] },
|
||||
* musicSearch: { search: (query, page, limit) => result },
|
||||
* getUrl: (id, quality) => url,
|
||||
* getLyric: (id) => lyric_string_or_object
|
||||
* }
|
||||
*/
|
||||
class PluginManager {
|
||||
export interface BuiltinPluginDef {
|
||||
id: string;
|
||||
platform: string;
|
||||
name: string;
|
||||
fileName: string;
|
||||
isFixVersion: boolean;
|
||||
}
|
||||
|
||||
const BUILTIN_PLUGINS: BuiltinPluginDef[] = [
|
||||
{ id: 'wy', platform: 'wy', name: 'ZQ芸 (网易云)', fileName: 'zq_wy_v3.js', isFixVersion: false },
|
||||
{ id: 'tx', platform: 'tx', name: 'ZQ秋 (QQ音乐)', fileName: 'zq_tx_v3-fix1.js', isFixVersion: true },
|
||||
{ id: 'kw', platform: 'kw', name: 'ZQ我 (酷我)', fileName: 'zq_kw_v3-fix1.js', isFixVersion: true },
|
||||
{ id: 'kg', platform: 'kg', name: 'ZQ驹 (酷狗)', fileName: 'zq_kg.js', isFixVersion: false },
|
||||
{ id: 'mg', platform: 'mg', name: 'ZQ咕 (咪咕)', fileName: 'zq_mg_v3.js', isFixVersion: false },
|
||||
];
|
||||
|
||||
const PLUGIN_FILE_BASE = '/plugins/';
|
||||
|
||||
export class PluginManager {
|
||||
private plugins: Map<string, PluginModule> = new Map();
|
||||
private activePluginId: string = '';
|
||||
private activePluginId: string = 'wy';
|
||||
private codeCache: Map<string, string> = new Map();
|
||||
private loadingPromises: Map<string, Promise<PluginModule | null>> = new Map();
|
||||
|
||||
/** 注册插件模块(直接传入已解析的模块对象) */
|
||||
registerModule(module: PluginModule): boolean {
|
||||
const info = this.extractInfo(module);
|
||||
if (!info || !info.id) {
|
||||
console.error('[PluginManager] 插件缺少有效的 info 或 pluginInfo.info.id');
|
||||
return false;
|
||||
}
|
||||
this.plugins.set(info.id, module);
|
||||
if (!this.activePluginId) {
|
||||
this.activePluginId = info.id;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 从 JS 代码字符串加载插件(兼容 PC 版 CommonJS 格式) */
|
||||
loadFromCode(code: string, source: 'built-in' | 'user' = 'user'): PluginModule | null {
|
||||
try {
|
||||
const wrapped = `(function(){var module={exports:{}};var exports=module.exports;${code}\n;return module.exports;})()`;
|
||||
const mod = (new Function(wrapped))() as PluginModule;
|
||||
if (!mod) {
|
||||
console.error('[PluginManager] 插件代码执行后返回空');
|
||||
return null;
|
||||
}
|
||||
if (mod.pluginInfo?.info) {
|
||||
(mod.pluginInfo.info as any).__source = source;
|
||||
}
|
||||
if (mod.info) {
|
||||
(mod.info as any).__source = source;
|
||||
}
|
||||
this.registerModule(mod);
|
||||
return mod;
|
||||
} catch (e) {
|
||||
console.error('[PluginManager] 加载插件代码失败:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 卸载插件 */
|
||||
unregister(id: string): boolean {
|
||||
if (this.activePluginId === id) {
|
||||
const remaining = this.getAll();
|
||||
this.activePluginId = remaining.length > 0 && remaining[0].id !== id ? remaining[0].id : '';
|
||||
}
|
||||
return this.plugins.delete(id);
|
||||
}
|
||||
|
||||
/** 获取所有插件信息 */
|
||||
getAll(): PluginFullInfo[] {
|
||||
return Array.from(this.plugins.values()).map(p => this.extractInfo(p)!).filter(Boolean);
|
||||
return Array.from(this.plugins.values()).map(p => this.extractInfo(p)).filter(Boolean) as PluginFullInfo[];
|
||||
}
|
||||
|
||||
getAllBuiltinDefs(): BuiltinPluginDef[] {
|
||||
return BUILTIN_PLUGINS;
|
||||
}
|
||||
|
||||
/** 获取插件模块 */
|
||||
get(id: string): PluginModule | undefined {
|
||||
return this.plugins.get(id);
|
||||
}
|
||||
|
||||
/** 获取当前激活的插件 */
|
||||
has(id: string): boolean {
|
||||
return this.plugins.has(id);
|
||||
}
|
||||
|
||||
getActivePlugin(): PluginModule | undefined {
|
||||
return this.plugins.get(this.activePluginId);
|
||||
}
|
||||
|
||||
/** 设置激活插件 */
|
||||
getActivePluginId(): string {
|
||||
return this.activePluginId;
|
||||
}
|
||||
|
||||
setActivePlugin(id: string): boolean {
|
||||
if (this.plugins.has(id)) {
|
||||
this.activePluginId = id;
|
||||
sessionStorage.setItem('qz-active-plugin', id);
|
||||
try { sessionStorage.setItem('qz-active-plugin', id); } catch {}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 获取当前激活插件 ID */
|
||||
getActivePluginId(): string {
|
||||
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;
|
||||
}
|
||||
const qs = mod?.pluginInfo?.quality;
|
||||
if (qs && qs.length > 0) return qs;
|
||||
return [
|
||||
{ id: 'standard', name: '标准', ui: 'SQ' },
|
||||
{ id: 'high', name: '高品', ui: 'HQ' },
|
||||
{ id: 'hires', name: '无损', ui: 'HR' },
|
||||
{ id: 'standard', name: '标准音质', ui: '标' },
|
||||
{ id: 'exhigh', name: '高品音质', ui: 'HQ' },
|
||||
{ id: 'lossless', name: '无损音质', ui: 'SQ' },
|
||||
{ id: 'hires', name: 'Hi-Res', ui: 'HR' },
|
||||
];
|
||||
}
|
||||
|
||||
/** 搜索(兼容 PC 版 musicSearch.search 接口) */
|
||||
async search(query: string, page: number, limit: number): Promise<PluginSearchResult> {
|
||||
const plugin = this.getActivePlugin();
|
||||
if (!plugin?.musicSearch?.search) {
|
||||
return { list: [], total: 0, error: '当前插件不支持搜索' };
|
||||
private extractInfo(mod: PluginModule): PluginFullInfo | null {
|
||||
const info = mod.pluginInfo?.info;
|
||||
if (!info) return null;
|
||||
return {
|
||||
...info,
|
||||
quality: mod.pluginInfo?.quality,
|
||||
env: mod.pluginInfo?.env,
|
||||
ext: mod.pluginInfo?.ext,
|
||||
supportFunc: mod.pluginInfo?.supportFunc,
|
||||
source: (mod as any).__source || 'built-in',
|
||||
};
|
||||
}
|
||||
|
||||
registerModule(mod: PluginModule, source: 'built-in' | 'user' = 'built-in'): string | null {
|
||||
const info = mod.pluginInfo?.info;
|
||||
if (!info || !info.id) return null;
|
||||
(mod as any).__source = source;
|
||||
this.plugins.set(info.id, mod);
|
||||
if (!sessionStorage.getItem('qz-active-plugin')) {
|
||||
this.activePluginId = info.id;
|
||||
}
|
||||
return info.id;
|
||||
}
|
||||
|
||||
unregister(id: string): boolean {
|
||||
if (this.activePluginId === id) {
|
||||
const remaining = Array.from(this.plugins.keys()).filter(k => k !== id);
|
||||
this.activePluginId = remaining[0] || '';
|
||||
}
|
||||
return this.plugins.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JS 代码字符串加载插件
|
||||
* 支持两种格式:
|
||||
* 1. module.exports = { ... } (用户手写的简单插件)
|
||||
* 2. webpack bundle (官方原版,包含 __webpack_modules__ / __nccwpck_require__)
|
||||
*/
|
||||
loadFromCode(code: string, source: 'built-in' | 'user' = 'user'): PluginModule | null {
|
||||
if (!code || typeof code !== 'string') return null;
|
||||
try {
|
||||
const result = await plugin.musicSearch.search(query, page, limit);
|
||||
const sandbox: any = {
|
||||
module: { exports: {} },
|
||||
exports: {},
|
||||
globalThis: typeof window !== 'undefined' ? window : globalThis,
|
||||
window: typeof window !== 'undefined' ? window : undefined,
|
||||
console,
|
||||
setTimeout, clearTimeout, setInterval, clearInterval,
|
||||
Promise, JSON, Math, Date, Array, Object,
|
||||
btoa: typeof btoa !== 'undefined' ? btoa : undefined,
|
||||
atob: typeof atob !== 'undefined' ? atob : undefined,
|
||||
};
|
||||
sandbox.global = sandbox.globalThis;
|
||||
|
||||
// 用 Function 构造沙盒环境执行代码
|
||||
const argNames = Object.keys(sandbox);
|
||||
const argValues = argNames.map(k => sandbox[k]);
|
||||
const fn = new Function(...argNames, code + '\n;return module.exports;');
|
||||
const result = fn(...argValues);
|
||||
const mod = (result && typeof result === 'object' && result.exports) ? result.exports : result;
|
||||
if (!mod || typeof mod !== 'object') return null;
|
||||
if (!mod.pluginInfo) return null;
|
||||
(mod as any).__source = source;
|
||||
const id = this.registerModule(mod, source);
|
||||
if (!id) return null;
|
||||
return mod;
|
||||
} catch (err) {
|
||||
console.error('[PluginManager] loadFromCode 执行失败:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从远程 URL 下载插件代码并加载 */
|
||||
async loadFromUrl(url: string, source: 'built-in' | 'user' = 'built-in'): Promise<PluginModule | null> {
|
||||
if (this.codeCache.has(url)) {
|
||||
return this.loadFromCode(this.codeCache.get(url)!, source);
|
||||
}
|
||||
if (this.loadingPromises.has(url)) {
|
||||
return this.loadingPromises.get(url)!;
|
||||
}
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const res = await fetch(url, { method: 'GET', mode: 'cors' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const code = await res.text();
|
||||
this.codeCache.set(url, code);
|
||||
return this.loadFromCode(code, source);
|
||||
} catch (err) {
|
||||
console.error('[PluginManager] 从 URL 加载插件失败:', url, err);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
this.loadingPromises.set(url, promise);
|
||||
const result = await promise;
|
||||
this.loadingPromises.delete(url);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 加载单个内置插件(按文件名) */
|
||||
async loadBuiltin(def: BuiltinPluginDef): Promise<PluginModule | null> {
|
||||
if (this.has(def.id)) return this.get(def.id)!;
|
||||
const url = PLUGIN_FILE_BASE + def.fileName;
|
||||
return this.loadFromUrl(url, 'built-in');
|
||||
}
|
||||
|
||||
/** 加载所有内置插件(并行),相同 platform 只保留 -fix 版 */
|
||||
async loadAllBuiltins(): Promise<number> {
|
||||
const tasks = BUILTIN_PLUGINS.map(def => this.loadBuiltin(def));
|
||||
const results = await Promise.allSettled(tasks);
|
||||
const loaded = results.filter(r => r.status === 'fulfilled' && r.value).length;
|
||||
if (!this.getActivePlugin() && this.plugins.size > 0) {
|
||||
const first = this.plugins.keys().next();
|
||||
if (first.value) this.activePluginId = first.value;
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/** 搜索:使用指定或激活的插件进行搜索 */
|
||||
async search(query: string, page: number, limit: number, preferredPluginId?: string): Promise<PluginSearchResult> {
|
||||
const pid = preferredPluginId || this.activePluginId || Array.from(this.plugins.keys())[0];
|
||||
const mod = this.plugins.get(pid);
|
||||
if (!mod) return { list: [], total: 0, error: '当前没有可用插件' };
|
||||
|
||||
let searchFn: ((q: string, p: number, l: number) => Promise<PluginSearchResult>) | undefined;
|
||||
if (mod.musicSearch && typeof (mod.musicSearch as any).search === 'function') {
|
||||
searchFn = (mod.musicSearch as any).search.bind(mod.musicSearch);
|
||||
} else if (typeof mod.musicSearch === 'function') {
|
||||
searchFn = mod.musicSearch as any;
|
||||
}
|
||||
if (!searchFn) return { list: [], total: 0, error: '插件不支持搜索' };
|
||||
|
||||
try {
|
||||
const result = await searchFn(query, page, limit);
|
||||
if (!result || !Array.isArray(result.list)) {
|
||||
return { list: [], total: 0, error: '插件未返回正确的搜索结果格式' };
|
||||
return { list: [], total: 0, error: '搜索返回格式异常' };
|
||||
}
|
||||
(result as any).__pluginId = pid;
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error('[PluginManager] 搜索失败:', e);
|
||||
return { list: [], total: 0, error: (e as Error).message };
|
||||
} catch (err) {
|
||||
console.error(`[PluginManager] 插件 ${pid} 搜索失败:`, err);
|
||||
return { list: [], total: 0, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取歌曲 URL(兼容 PC 版 getUrl 接口)
|
||||
* priority: song.source 指定的插件 → 当前激活插件 → 所有插件轮流试
|
||||
*/
|
||||
async getSongUrl(song: { id: string; source?: string; quality?: string }, preferQuality?: string): Promise<UrlResponse> {
|
||||
const pluginIds: string[] = [];
|
||||
if (song.source && this.plugins.has(song.source)) {
|
||||
pluginIds.push(song.source);
|
||||
}
|
||||
if (this.activePluginId && !pluginIds.includes(this.activePluginId)) {
|
||||
pluginIds.push(this.activePluginId);
|
||||
}
|
||||
for (const pid of this.plugins.keys()) {
|
||||
if (!pluginIds.includes(pid)) pluginIds.push(pid);
|
||||
}
|
||||
/** 获取歌曲 URL:根据 song.source 优先尝试 → 多插件回退 */
|
||||
async getSongUrl(song: { id?: string; songmid?: string; source?: string }, quality?: string): Promise<UrlResponse> {
|
||||
const songId = song.songmid || song.id;
|
||||
if (!songId) return { success: false, error: '歌曲缺少 id' };
|
||||
|
||||
const quality = preferQuality || song.quality || 'standard';
|
||||
const candidateIds: string[] = [];
|
||||
if (song.source && this.plugins.has(song.source)) candidateIds.push(song.source);
|
||||
if (this.activePluginId && !candidateIds.includes(this.activePluginId)) candidateIds.push(this.activePluginId);
|
||||
for (const pid of this.plugins.keys()) if (!candidateIds.includes(pid)) candidateIds.push(pid);
|
||||
|
||||
for (const pid of pluginIds) {
|
||||
const plugin = this.plugins.get(pid);
|
||||
if (!plugin?.getUrl) continue;
|
||||
const lastErr: string[] = [];
|
||||
for (const pid of candidateIds) {
|
||||
const mod = this.plugins.get(pid);
|
||||
if (!mod || typeof mod.getUrl !== 'function') 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 };
|
||||
}
|
||||
const q = quality || this.getQualityList(pid)[0]?.id || 'standard';
|
||||
const result = await mod.getUrl(songId, q);
|
||||
let url: string | undefined;
|
||||
if (typeof result === 'string') {
|
||||
url = result;
|
||||
} else if (result && typeof result === 'object') {
|
||||
url = (result as any).url || (result as any).data?.url || (result as any).data;
|
||||
if (typeof url !== 'string') url = undefined;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[PluginManager] 插件 ${pid} getUrl 失败:`, e);
|
||||
if (url && url.length > 0 && /^(https?:)?\/\//i.test(url)) {
|
||||
return { success: true, url, pluginId: pid };
|
||||
}
|
||||
if (url && typeof url === 'string' && url.startsWith('//')) {
|
||||
return { success: true, url: 'https:' + url, pluginId: pid };
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[PluginManager] 插件 ${pid} getUrl 失败:`, err);
|
||||
lastErr.push(`${pid}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
return { success: false, error: '所有插件都无法获取这首歌曲的播放地址' };
|
||||
return { success: false, error: '所有插件都未能获取播放地址(' + lastErr.join('; ') + ')' };
|
||||
}
|
||||
|
||||
/** 获取歌词(兼容 PC 版 getLyric 接口)
|
||||
* 返回:{ format: 'lrc' | 'ttml' | 'qrc' | 'yrc' | 'json' | 'text' | null, raw: any }
|
||||
*/
|
||||
async getLyric(song: { id: string; source?: string }): Promise<{ format: string | null; raw: any }> {
|
||||
const pluginIds: string[] = [];
|
||||
if (song.source && this.plugins.has(song.source)) {
|
||||
pluginIds.push(song.source);
|
||||
}
|
||||
if (this.activePluginId && !pluginIds.includes(this.activePluginId)) {
|
||||
pluginIds.push(this.activePluginId);
|
||||
}
|
||||
for (const pid of this.plugins.keys()) {
|
||||
if (!pluginIds.includes(pid)) pluginIds.push(pid);
|
||||
}
|
||||
/** 获取歌词 */
|
||||
async getLyric(song: { id?: string; songmid?: string; source?: string }): Promise<{ format: string | null; raw: any }> {
|
||||
const songId = song.songmid || song.id;
|
||||
if (!songId) return { format: null, raw: null };
|
||||
|
||||
for (const pid of pluginIds) {
|
||||
const plugin = this.plugins.get(pid);
|
||||
if (!plugin?.getLyric) continue;
|
||||
const candidateIds: string[] = [];
|
||||
if (song.source && this.plugins.has(song.source)) candidateIds.push(song.source);
|
||||
if (this.activePluginId && !candidateIds.includes(this.activePluginId)) candidateIds.push(this.activePluginId);
|
||||
for (const pid of this.plugins.keys()) if (!candidateIds.includes(pid)) candidateIds.push(pid);
|
||||
|
||||
for (const pid of candidateIds) {
|
||||
const mod = this.plugins.get(pid);
|
||||
if (!mod || typeof mod.getLyric !== 'function') continue;
|
||||
try {
|
||||
const raw = await plugin.getLyric(song.id);
|
||||
if (raw == null) continue;
|
||||
const raw = await mod.getLyric(songId);
|
||||
if (raw === null || raw === undefined) continue;
|
||||
const format = detectLyricFormat(raw);
|
||||
if (format) {
|
||||
return { format, raw };
|
||||
}
|
||||
// 就算识别不到格式,也返回原始内容
|
||||
return { format: format, raw };
|
||||
} catch (e) {
|
||||
console.warn(`[PluginManager] 插件 ${pid} getLyric 失败:`, e);
|
||||
return { format, raw };
|
||||
} catch (err) {
|
||||
console.warn(`[PluginManager] 插件 ${pid} getLyric 失败:`, err);
|
||||
}
|
||||
}
|
||||
return { format: null, raw: null };
|
||||
}
|
||||
|
||||
hasPlugins(): boolean {
|
||||
return this.plugins.size > 0;
|
||||
}
|
||||
|
||||
/** 从 localStorage 恢复用户插件 */
|
||||
loadUserPlugins(): void {
|
||||
try {
|
||||
const saved = localStorage.getItem('qz-user-plugins');
|
||||
if (saved) {
|
||||
const pluginList: string[] = JSON.parse(saved);
|
||||
pluginList.forEach(code => {
|
||||
this.loadFromCode(code, 'user');
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[PluginManager] 恢复用户插件失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存用户插件 code 到 localStorage */
|
||||
/** localStorage 持久化用户插件 */
|
||||
saveUserPlugin(code: string): void {
|
||||
try {
|
||||
const saved: string[] = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]');
|
||||
const saved = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]');
|
||||
saved.push(code);
|
||||
localStorage.setItem('qz-user-plugins', JSON.stringify(saved));
|
||||
} catch (e) {
|
||||
console.error('[PluginManager] 保存用户插件失败:', e);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
loadUserPlugins(): number {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]');
|
||||
let loaded = 0;
|
||||
for (const code of saved) {
|
||||
const m = this.loadFromCode(code, 'user');
|
||||
if (m) loaded++;
|
||||
}
|
||||
return loaded;
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
/** 删除用户插件(按 id 匹配) */
|
||||
removeUserPlugin(id: string): void {
|
||||
try {
|
||||
const saved: string[] = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]');
|
||||
const filtered = saved.filter(code => {
|
||||
const saved = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]');
|
||||
const filtered = saved.filter((code: string) => {
|
||||
try {
|
||||
const wrapped = `(function(){var module={exports:{}};var exports=module.exports;${code}\n;return module.exports;})()`;
|
||||
const mod = (new Function(wrapped))();
|
||||
const info = mod?.pluginInfo?.info || mod?.info;
|
||||
return info?.id !== id;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
const sandbox: any = { module: { exports: {} }, exports: {}, console, Promise, JSON, Math, Date, setTimeout, clearTimeout };
|
||||
const fn = new Function(...Object.keys(sandbox), code + '\n;return module.exports;');
|
||||
const result = fn(...Object.keys(sandbox).map(k => sandbox[k]));
|
||||
const mod = result?.exports || result;
|
||||
return mod?.pluginInfo?.info?.id !== id;
|
||||
} catch { return true; }
|
||||
});
|
||||
localStorage.setItem('qz-user-plugins', JSON.stringify(filtered));
|
||||
} catch (e) {
|
||||
console.error('[PluginManager] 删除用户插件失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 提取插件信息(兼容 PC 版两种格式) */
|
||||
private extractInfo(module: PluginModule): PluginFullInfo | null {
|
||||
if (module.pluginInfo?.info) {
|
||||
return {
|
||||
...module.pluginInfo.info,
|
||||
quality: module.pluginInfo.quality,
|
||||
source: (module.pluginInfo.info as any).__source || 'built-in',
|
||||
};
|
||||
}
|
||||
if (module.info) {
|
||||
return {
|
||||
...module.info,
|
||||
source: (module.info as any).__source || 'built-in',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/** 自动识别歌词返回内容的格式 */
|
||||
function detectLyricFormat(raw: any): string | null {
|
||||
export function detectLyricFormat(raw: any): string | null {
|
||||
if (raw === null || raw === undefined) return 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';
|
||||
// 默认纯文本
|
||||
if (s.charAt(0) === '{' || s.charAt(0) === '[') {
|
||||
try {
|
||||
const obj = JSON.parse(s);
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
if (obj.yrc || obj.lrclib || obj.klyric) return 'yrc';
|
||||
if (obj.lrc) return 'lrc';
|
||||
if (obj.ttml) return 'ttml';
|
||||
if (obj.qrc) return 'qrc';
|
||||
return 'json';
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
// 兼容 PC/Android 原版 QZMusic 插件系统的类型定义
|
||||
// 官方 QZMusic_v3 插件类型系统(支持 PC/Android 原版 webpack bundle 格式)
|
||||
|
||||
/** 音质信息 */
|
||||
export interface PluginQuality {
|
||||
id: string;
|
||||
name: string;
|
||||
ui: string;
|
||||
}
|
||||
|
||||
/** 插件元信息(兼容 PC 版 pluginInfo.info) */
|
||||
export interface PluginInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
author?: string;
|
||||
icon?: string;
|
||||
source?: 'built-in' | 'user';
|
||||
}
|
||||
|
||||
/** 插件完整信息(包含音质列表) */
|
||||
export interface PluginFullInfo extends PluginInfo {
|
||||
export interface PluginQuality {
|
||||
id: string;
|
||||
name: string;
|
||||
ui: string;
|
||||
}
|
||||
|
||||
export interface PluginEnv {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface PluginExt {
|
||||
name: string;
|
||||
description?: string;
|
||||
entry?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface PluginInfoModule {
|
||||
info: PluginInfo;
|
||||
quality?: PluginQuality[];
|
||||
env?: PluginEnv[];
|
||||
ext?: PluginExt[];
|
||||
supportFunc?: string[];
|
||||
}
|
||||
|
||||
/** PC 原版插件搜索结果单条(常见字段) */
|
||||
export interface PluginSearchItem {
|
||||
songmid?: string;
|
||||
id?: string;
|
||||
@@ -45,33 +56,49 @@ export interface PluginSearchItem {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** 搜索结果 */
|
||||
export interface PluginSearchResult {
|
||||
list: PluginSearchItem[];
|
||||
total?: number;
|
||||
songCount?: number;
|
||||
allPage?: number;
|
||||
limit?: number;
|
||||
source?: string;
|
||||
error?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** PC 原版插件模块接口 */
|
||||
export interface PluginModule {
|
||||
pluginInfo?: {
|
||||
info: PluginInfo;
|
||||
quality?: PluginQuality[];
|
||||
};
|
||||
info?: PluginInfo;
|
||||
getUrl?: (id: string, quality: string) => Promise<string> | string;
|
||||
pluginInfo: PluginInfoModule;
|
||||
musicSearch?: {
|
||||
search: (query: string, page: number, limit: number) => Promise<PluginSearchResult> | PluginSearchResult;
|
||||
};
|
||||
getLyric?: (id: string) => Promise<string | object | ArrayBuffer> | string | object | ArrayBuffer;
|
||||
search: (query: string, page: number, limit: number) => Promise<PluginSearchResult>;
|
||||
} | ((query: string, page: number, limit: number) => Promise<PluginSearchResult>);
|
||||
getUrl?: (songId: string, quality: string) => Promise<string | { url: string; [k: string]: any } | null>;
|
||||
getLyric?: (songId: string) => Promise<string | object | ArrayBuffer | null>;
|
||||
songList?: any;
|
||||
hotSearch?: any;
|
||||
album?: any;
|
||||
singer?: any;
|
||||
musicInfo?: any;
|
||||
musicDetail?: any;
|
||||
getPic?: any;
|
||||
getPicture?: any;
|
||||
leaderboard?: any;
|
||||
tipSearch?: any;
|
||||
userPlaylist?: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface PluginFullInfo extends PluginInfo {
|
||||
quality?: PluginQuality[];
|
||||
env?: PluginEnv[];
|
||||
ext?: PluginExt[];
|
||||
supportFunc?: string[];
|
||||
source?: 'built-in' | 'user' | string;
|
||||
}
|
||||
|
||||
/** URL 响应 */
|
||||
export interface UrlResponse {
|
||||
success: boolean;
|
||||
url?: string;
|
||||
error?: string;
|
||||
pluginId?: string;
|
||||
}
|
||||
|
||||
@@ -1,506 +1,303 @@
|
||||
/**
|
||||
* 多格式歌词解析器 - 兼容 PC/Android 版 QZMusic 返回的各种歌词格式
|
||||
*
|
||||
* 支持的格式:
|
||||
* - LRC: [00:12.34]歌词内容(带逐字 [00:12.34]<00:00.50>...)
|
||||
* - QRC: <00:00.00> 逐字 XML 风格 / 腾讯 Q 音乐格式
|
||||
* - TTML/XML: <tt><body><div><p begin="0.00s" end="...">歌词</p></div></body></tt>
|
||||
* - YRC: 网易云逐字 JSON 格式 { yrc: { version:1, lyric:[{...,words:[...]}] } }
|
||||
* - JSON: { lrc: { lyric: "..." }, tlyric: {...}, yrc: {...} }
|
||||
* - SRT: 1\n00:00:01,000 --> 00:00:03,000\n文本\n
|
||||
* - VTT: WEBVTT\n\n00:00:01.000 --> 00:00:03.000\n文本
|
||||
* - 纯文本: 普通歌词,没有时间戳
|
||||
*
|
||||
* 最终统一输出 { lines: LyricLine[] },每个 LyricLine 有 startTime、endTime、text、words
|
||||
*/
|
||||
|
||||
export interface LyricWord {
|
||||
startTime: number; // 毫秒
|
||||
endTime: number; // 毫秒
|
||||
text: string;
|
||||
}
|
||||
// 多格式歌词解析器:LRC / QRC / TTML / YRC / JSON / SRT / VTT / 纯文本 / 逐字
|
||||
|
||||
export interface LyricLine {
|
||||
startTime: number; // 毫秒
|
||||
endTime: number; // 毫秒
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
text: string;
|
||||
words?: LyricWord[]; // 逐字信息(可选)
|
||||
raw?: any; // 原始数据(调试用)
|
||||
words?: { time: number; duration?: number; text: string }[];
|
||||
raw?: any;
|
||||
}
|
||||
|
||||
const DEFAULT_LINE_DURATION = 5000; // 默认每行 5 秒
|
||||
function parseTimeStamp(str: string): number {
|
||||
if (!str) return 0;
|
||||
const s = str.replace(/[^\d:.,\[\]<>]/g, '').trim();
|
||||
if (!s) return 0;
|
||||
const m = s.match(/^(\d+):(\d+)(?:[.:](\d+))?$/);
|
||||
if (m) {
|
||||
const min = parseInt(m[1], 10);
|
||||
const sec = parseInt(m[2], 10);
|
||||
let ms = 0;
|
||||
if (m[3]) {
|
||||
const frac = m[3];
|
||||
if (frac.length >= 3) ms = parseInt(frac.substring(0, 3), 10);
|
||||
else if (frac.length === 2) ms = parseInt(frac, 10) * 10;
|
||||
else ms = parseInt(frac, 10) * 100;
|
||||
}
|
||||
return min * 60 * 1000 + sec * 1000 + ms;
|
||||
}
|
||||
const n = parseFloat(s);
|
||||
return isFinite(n) ? n * 1000 : 0;
|
||||
}
|
||||
|
||||
function parseLrc(text: string): LyricLine[] {
|
||||
const lines: LyricLine[] = [];
|
||||
const textLines = String(text).split(/\r?\n/);
|
||||
for (const line of textLines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const stamps: string[] = [];
|
||||
let rest = trimmed;
|
||||
while (true) {
|
||||
const m = rest.match(/^\s*\[\s*(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\s*\]/);
|
||||
if (!m) break;
|
||||
stamps.push(m[1] + ':' + m[2] + '.' + (m[3] || '000'));
|
||||
rest = rest.substring(m[0].length);
|
||||
}
|
||||
if (stamps.length === 0) continue;
|
||||
const content = rest.trim();
|
||||
if (!content) continue;
|
||||
for (const st of stamps) {
|
||||
lines.push({ startTime: parseTimeStamp(st), text: content });
|
||||
}
|
||||
}
|
||||
lines.sort((a, b) => a.startTime - b.startTime);
|
||||
for (let i = 0; i < lines.length - 1; i++) {
|
||||
lines[i].endTime = lines[i + 1].startTime;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function parseQrc(text: string): LyricLine[] {
|
||||
const lines: LyricLine[] = [];
|
||||
const textLines = String(text).split(/\r?\n/);
|
||||
for (const line of textLines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
// [ti:xx] metadata lines (ignore)
|
||||
if (/^\[[a-zA-Z]+\s*:/.test(trimmed)) continue;
|
||||
// <0,270,100>xxx<270,150,100>yyy
|
||||
if (trimmed.startsWith('[') || trimmed.startsWith('<')) {
|
||||
const mainStart = trimmed.match(/^\[(\d+),(\d+)\]/);
|
||||
if (mainStart) {
|
||||
const startTime = parseInt(mainStart[1], 10);
|
||||
const endTime = startTime + parseInt(mainStart[2], 10);
|
||||
const content = trimmed.substring(mainStart[0].length);
|
||||
const words: { time: number; duration?: number; text: string }[] = [];
|
||||
const rest = content;
|
||||
const re = /<(\d+),(\d+)(?:,\d+)?>([^<]*)/g;
|
||||
let match;
|
||||
while ((match = re.exec(rest)) !== null) {
|
||||
words.push({
|
||||
time: parseInt(match[1], 10),
|
||||
duration: parseInt(match[2], 10),
|
||||
text: match[3],
|
||||
});
|
||||
}
|
||||
lines.push({ startTime, endTime, text: words.map(w => w.text).join(''), words });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// fallback: treat as LRC
|
||||
const lrcline = parseLrc(trimmed);
|
||||
lines.push(...lrcline);
|
||||
}
|
||||
return lines.length > 0 ? lines : parseLrc(text);
|
||||
}
|
||||
|
||||
function parseTtml(text: string): LyricLine[] {
|
||||
const lines: LyricLine[] = [];
|
||||
const t = String(text);
|
||||
const lineRe = /<p\b[^>]*>([\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(/<br\s*\/?>/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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主入口:根据给定的 { 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 (raw === undefined && input !== null && typeof input !== 'object') {
|
||||
raw = input;
|
||||
format = null;
|
||||
}
|
||||
|
||||
if (raw == null) return [];
|
||||
if (raw === null || raw === undefined || (typeof raw === 'string' && !raw.trim())) return [];
|
||||
|
||||
if (!format) {
|
||||
format = detectFormat(raw);
|
||||
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 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);
|
||||
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 parseJSON(raw);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 格式探测 ==========
|
||||
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(/</g, '<').replace(/>/g, '>').replace(/&/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() 的结果即可
|
||||
|
||||
export { parseLrc, parseQrc, parseTtml, parseYrc, parseJson, parseSrt, parseVtt };
|
||||
|
||||
Reference in New Issue
Block a user