11import { songLyric , songLyricTTML } from "@/api/song" ;
2+ import { qqMusicMatch } from "@/api/qqmusic" ;
23import { keywords as defaultKeywords , regexes as defaultRegexes } from "@/assets/data/exclude" ;
34import { useCacheManager } from "@/core/resource/CacheManager" ;
45import { useMusicStore , useSettingStore , useStatusStore } from "@/stores" ;
@@ -42,12 +43,13 @@ class LyricManager {
4243 * @param type 缓存类型
4344 * @returns 缓存数据
4445 */
45- private async getRawLyricCache ( id : number , type : "lrc" | "ttml" ) : Promise < string | null > {
46+ private async getRawLyricCache ( id : number , type : "lrc" | "ttml" | "qrc" ) : Promise < string | null > {
4647 const settingStore = useSettingStore ( ) ;
4748 if ( ! isElectron || ! settingStore . cacheEnabled ) return null ;
4849 try {
4950 const cacheManager = useCacheManager ( ) ;
50- const result = await cacheManager . get ( "lyrics" , `${ id } .${ type === "ttml" ? "ttml" : "json" } ` ) ;
51+ const ext = type === "ttml" ? "ttml" : type === "qrc" ? "qrc.json" : "json" ;
52+ const result = await cacheManager . get ( "lyrics" , `${ id } .${ ext } ` ) ;
5153 if ( result . success && result . data ) {
5254 // Uint8Array to string
5355 const decoder = new TextDecoder ( ) ;
@@ -65,12 +67,13 @@ class LyricManager {
6567 * @param type 缓存类型
6668 * @param data 数据
6769 */
68- private async saveRawLyricCache ( id : number , type : "lrc" | "ttml" , data : string ) {
70+ private async saveRawLyricCache ( id : number , type : "lrc" | "ttml" | "qrc" , data : string ) {
6971 const settingStore = useSettingStore ( ) ;
7072 if ( ! isElectron || ! settingStore . cacheEnabled ) return ;
7173 try {
7274 const cacheManager = useCacheManager ( ) ;
73- await cacheManager . set ( "lyrics" , `${ id } .${ type === "ttml" ? "ttml" : "json" } ` , data ) ;
75+ const ext = type === "ttml" ? "ttml" : type === "qrc" ? "qrc.json" : "json" ;
76+ await cacheManager . set ( "lyrics" , `${ id } .${ ext } ` , data ) ;
7477 } catch ( error ) {
7578 console . error ( "写入歌词缓存失败:" , error ) ;
7679 }
@@ -143,6 +146,92 @@ class LyricManager {
143146 return { lrcData : aligned , yrcData : lyricData . yrcData } ;
144147 }
145148
149+ /**
150+ * 解析 QQ 音乐 QRC 格式歌词
151+ * @param qrcContent QRC 原始内容
152+ * @param trans 翻译歌词
153+ * @param roma 罗马音歌词
154+ * @returns LyricLine 数组
155+ */
156+ private parseQRCLyric ( qrcContent : string , trans ?: string , roma ?: string ) : LyricLine [ ] {
157+ const lines : LyricLine [ ] = [ ] ;
158+ // 感觉 QQ 音乐歌词时间全部有些慢,所以时间偏移一下
159+ const QRC_TIME_OFFSET = - 200 ;
160+
161+ // 从 XML 中提取歌词内容
162+ const contentMatch = / < L y r i c _ 1 [ ^ > ] * L y r i c C o n t e n t = " ( [ ^ " ] * ) " [ ^ > ] * \/ > / . exec ( qrcContent ) ;
163+ const content = contentMatch ? contentMatch [ 1 ] : qrcContent ;
164+
165+ // 行匹配: [开始时间,持续时间]内容
166+ const linePattern = / ^ \[ ( \d + ) , ( \d + ) \] ( .* ) $ / ;
167+ // 逐字匹配: 文字(开始时间,持续时间)
168+ const wordPattern = / ( [ ^ ( ] * ) \( ( \d + ) , ( \d + ) \) / g;
169+
170+ for ( const rawLine of content . split ( "\n" ) ) {
171+ const line = rawLine . trim ( ) ;
172+ if ( ! line ) continue ;
173+
174+ // 跳过元数据标签 [ti:xxx] [ar:xxx] 等
175+ if ( / ^ \[ [ a - z ] + : / i. test ( line ) ) continue ;
176+
177+ const lineMatch = linePattern . exec ( line ) ;
178+ if ( ! lineMatch ) continue ;
179+
180+ const lineStart = parseInt ( lineMatch [ 1 ] , 10 ) + QRC_TIME_OFFSET ;
181+ const lineDuration = parseInt ( lineMatch [ 2 ] , 10 ) ;
182+ const lineContent = lineMatch [ 3 ] ;
183+
184+ // 解析逐字
185+ const words : Array < { word : string ; startTime : number ; endTime : number } > = [ ] ;
186+ let wordMatch : RegExpExecArray | null ;
187+ const wordRegex = new RegExp ( wordPattern . source , "g" ) ;
188+
189+ while ( ( wordMatch = wordRegex . exec ( lineContent ) ) !== null ) {
190+ const wordText = wordMatch [ 1 ] ;
191+ const wordStart = parseInt ( wordMatch [ 2 ] , 10 ) + QRC_TIME_OFFSET ;
192+ const wordDuration = parseInt ( wordMatch [ 3 ] , 10 ) ;
193+
194+ if ( wordText ) {
195+ words . push ( {
196+ word : wordText ,
197+ startTime : wordStart ,
198+ endTime : wordStart + wordDuration ,
199+ } ) ;
200+ }
201+ }
202+
203+ if ( words . length > 0 ) {
204+ lines . push ( {
205+ words : words . map ( ( w ) => ( { ...w , romanWord : "" } ) ) ,
206+ startTime : lineStart ,
207+ endTime : lineStart + lineDuration ,
208+ translatedLyric : "" ,
209+ romanLyric : "" ,
210+ isBG : false ,
211+ isDuet : false ,
212+ } ) ;
213+ }
214+ }
215+
216+ // 处理翻译歌词
217+ if ( trans ) {
218+ const transLines = parseLrc ( trans ) ;
219+ if ( transLines ?. length ) {
220+ return this . alignLyrics ( lines , transLines , "translatedLyric" ) ;
221+ }
222+ }
223+
224+ // 处理罗马音歌词
225+ if ( roma ) {
226+ const romaLines = parseLrc ( roma ) ;
227+ if ( romaLines ?. length ) {
228+ return this . alignLyrics ( lines , romaLines , "romanLyric" ) ;
229+ }
230+ }
231+
232+ return lines ;
233+ }
234+
146235 /**
147236 * 处理在线歌词
148237 * @param id 歌曲 ID
@@ -158,11 +247,94 @@ class LyricManager {
158247 const result : SongLyric = { lrcData : [ ] , yrcData : [ ] } ;
159248 // 是否采用了 TTML
160249 let ttmlAdopted = false ;
250+ // 是否采用了 QQ 音乐歌词
251+ let qqMusicAdopted = false ;
161252 // 过期判断
162253 const isStale = ( ) => this . activeLyricReq !== req || musicStore . playSong ?. id !== id ;
254+
255+ // 处理 QQ 音乐歌词
256+ const adoptQQMusic = async ( ) => {
257+ if ( ! settingStore . preferQQMusicLyric ) return ;
258+ const song = musicStore . playSong ;
259+ if ( ! song ) return ;
260+ // 先检查缓存
261+ const cached = await this . getRawLyricCache ( id , "qrc" ) ;
262+ let data : any = null ;
263+ if ( cached ) {
264+ try {
265+ data = JSON . parse ( cached ) ;
266+ } catch {
267+ data = null ;
268+ }
269+ }
270+ // 如果没有缓存,则请求 API
271+ if ( ! data ) {
272+ // 构建搜索关键词
273+ const artistsStr = Array . isArray ( song . artists )
274+ ? song . artists . map ( ( a ) => a . name ) . join ( "/" )
275+ : song . artists || "" ;
276+ const keyword = `${ song . name } -${ artistsStr } ` ;
277+ try {
278+ data = await qqMusicMatch ( keyword ) ;
279+ } catch ( error ) {
280+ console . warn ( "QQ 音乐歌词获取失败:" , error ) ;
281+ return ;
282+ }
283+ }
284+ if ( isStale ( ) ) return ;
285+ if ( ! data || data . code !== 200 ) return ;
286+
287+ // 验证时长匹配(相差超过 5 秒视为不匹配)
288+ if ( data . song ?. duration ) {
289+ const durationDiff = Math . abs ( data . song . duration - song . duration ) ;
290+ if ( durationDiff > 5000 ) {
291+ console . warn (
292+ `QQ 音乐歌词时长不匹配: ${ data . song . duration } ms vs ${ song . duration } ms (差异 ${ durationDiff } ms)` ,
293+ ) ;
294+ return ;
295+ }
296+ }
297+ // 保存到缓存
298+ if ( ! cached && data . code === 200 ) {
299+ this . saveRawLyricCache ( id , "qrc" , JSON . stringify ( data ) ) ;
300+ }
301+ // 解析 QRC 逐字歌词
302+ if ( data . qrc ) {
303+ const qrcLines = this . parseQRCLyric ( data . qrc , data . trans , data . roma ) ;
304+ if ( qrcLines . length > 0 ) {
305+ result . yrcData = qrcLines ;
306+ qqMusicAdopted = true ;
307+ }
308+ }
309+ // 解析 LRC 歌词(如果没有 QRC)
310+ if ( ! qqMusicAdopted && data . lrc ) {
311+ let lrcLines = parseLrc ( data . lrc ) || [ ] ;
312+ // 处理翻译
313+ if ( data . trans ) {
314+ const transLines = parseLrc ( data . trans ) ;
315+ if ( transLines ?. length ) {
316+ lrcLines = this . alignLyrics ( lrcLines , transLines , "translatedLyric" ) ;
317+ }
318+ }
319+ // 处理罗马音
320+ if ( data . roma ) {
321+ const romaLines = parseLrc ( data . roma ) ;
322+ if ( romaLines ?. length ) {
323+ lrcLines = this . alignLyrics ( lrcLines , romaLines , "romanLyric" ) ;
324+ }
325+ }
326+ if ( lrcLines . length > 0 ) {
327+ result . lrcData = lrcLines ;
328+ qqMusicAdopted = true ;
329+ }
330+ }
331+ } ;
332+
163333 // 处理 TTML 歌词
164334 const adoptTTML = async ( ) => {
165335 if ( ! settingStore . enableOnlineTTMLLyric ) return ;
336+ // 如果已经有 QQ 音乐歌词,跳过 TTML
337+ if ( qqMusicAdopted ) return ;
166338 let ttmlContent : string | null = await this . getRawLyricCache ( id , "ttml" ) ;
167339 if ( ! ttmlContent ) {
168340 ttmlContent = await songLyricTTML ( id ) ;
@@ -180,6 +352,8 @@ class LyricManager {
180352 } ;
181353 // 处理 LRC 歌词
182354 const adoptLRC = async ( ) => {
355+ // 如果已经有 QQ 音乐歌词,跳过网易云
356+ if ( qqMusicAdopted ) return ;
183357 let data : any = null ;
184358 const cached = await this . getRawLyricCache ( id , "lrc" ) ;
185359 if ( cached ) {
@@ -228,7 +402,16 @@ class LyricManager {
228402 const lyricData = this . handleLyricExclude ( result ) ;
229403 this . setFinalLyric ( lyricData , req ) ;
230404 } ;
231- // 设置 TTML
405+
406+ // 优先获取 QQ 音乐歌词
407+ if ( settingStore . preferQQMusicLyric ) {
408+ await adoptQQMusic ( ) ;
409+ }
410+ if ( qqMusicAdopted ) {
411+ statusStore . usingTTMLLyric = false ;
412+ return result ;
413+ }
414+ // 否则使用原有逻辑
232415 await Promise . allSettled ( [ adoptTTML ( ) , adoptLRC ( ) ] ) ;
233416 statusStore . usingTTMLLyric = ttmlAdopted ;
234417 return result ;
0 commit comments