fix: improve lyrics error detection and add new donor

- Detect error JSON payloads from Apple Music and QQMusic proxies
- Add lyricsHasUsableText validation for usable lyrics content
- Skip empty word lines in synced lyrics parsing
- Add ClearAll method to lyrics cache
- Handle TimeoutException properly in track metadata screen
- Add laflame to recent donors list
This commit is contained in:
zarzet
2026-02-14 17:25:17 +07:00
parent b74186464b
commit 3e90b29d2b
5 changed files with 112 additions and 28 deletions
+70 -1
View File
@@ -224,6 +224,15 @@ func (c *lyricsCache) Size() int {
return len(c.cache)
}
func (c *lyricsCache) ClearAll() int {
c.mu.Lock()
defer c.mu.Unlock()
cleared := len(c.cache)
c.cache = make(map[string]*lyricsCacheEntry)
return cleared
}
type LRCLibResponse struct {
ID int `json:"id"`
Name string `json:"name"`
@@ -399,7 +408,7 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
}
isValidResult := func(l *LyricsResponse) bool {
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
return lyricsHasUsableText(l)
}
// Try extension lyrics providers first
@@ -619,6 +628,9 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
if len(matches) == 5 {
startMs := lrcTimestampToMs(matches[1], matches[2], matches[3])
words := strings.TrimSpace(matches[4])
if words == "" {
continue
}
lines = append(lines, LyricsLine{
StartTimeMs: startMs,
@@ -639,6 +651,63 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
return lines
}
func lyricsHasUsableText(lyrics *LyricsResponse) bool {
if lyrics == nil {
return false
}
if lyrics.Instrumental {
return true
}
if strings.TrimSpace(lyrics.PlainLyrics) != "" {
return true
}
for _, line := range lyrics.Lines {
if strings.TrimSpace(line.Words) != "" {
return true
}
}
return false
}
// detectLyricsErrorPayload extracts human-readable error messages from
// JSON payloads returned by lyrics proxies when no lyric is available.
func detectLyricsErrorPayload(raw string) (string, bool) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
return "", false
}
var payload map[string]interface{}
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return "", false
}
lyricsKeys := []string{"lyrics", "lyric", "lrc", "content", "lines", "syncedLyrics", "unsyncedLyrics"}
hasLyricsKey := false
for _, key := range lyricsKeys {
if _, ok := payload[key]; ok {
hasLyricsKey = true
break
}
}
errorKeys := []string{"message", "error", "detail", "reason"}
for _, key := range errorKeys {
if msg, ok := payload[key].(string); ok {
msg = strings.TrimSpace(msg)
if msg != "" && !hasLyricsKey {
return msg, true
}
}
}
if success, ok := payload["success"].(bool); ok && !success && !hasLyricsKey {
return "request unsuccessful", true
}
return "", false
}
func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 {
min, _ := strconv.ParseInt(minutes, 10, 64)
sec, _ := strconv.ParseInt(seconds, 10, 64)
+3
View File
@@ -333,6 +333,9 @@ func (c *AppleMusicClient) FetchLyrics(
if err != nil {
return nil, err
}
if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload {
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
}
// Try to parse as pax format (word-by-word or line)
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
+3
View File
@@ -163,6 +163,9 @@ func (c *QQMusicClient) FetchLyrics(
if err != nil {
return nil, err
}
if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload {
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
}
// Try to parse as pax format (word-by-word or line)
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
+20 -12
View File
@@ -68,7 +68,9 @@ class DonatePage extends StatelessWidget {
// Combined notice card
Card(
elevation: 0,
color: colorScheme.secondaryContainer.withValues(alpha: 0.3),
color: colorScheme.secondaryContainer.withValues(
alpha: 0.3,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
@@ -98,7 +100,8 @@ class DonatePage extends StatelessWidget {
const SizedBox(height: 10),
_NoticeLine(
icon: Icons.block,
text: 'Not selling early access, premium features, or paywalls',
text:
'Not selling early access, premium features, or paywalls',
colorScheme: colorScheme,
),
const SizedBox(height: 6),
@@ -110,36 +113,40 @@ class DonatePage extends StatelessWidget {
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.favorite_border,
text: 'Your support is the only way to keep this project alive',
text:
'Your support is the only way to keep this project alive',
colorScheme: colorScheme,
),
Divider(
height: 24,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
color: colorScheme.outlineVariant.withValues(
alpha: 0.3,
),
),
_NoticeLine(
icon: Icons.history,
text: 'Your name stays permanently in every version it was included in',
text:
'Your name stays permanently in every version it was included in',
colorScheme: colorScheme,
),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.update,
text: 'Supporter list is updated monthly and embedded in the app',
text:
'Supporter list is updated monthly and embedded in the app',
colorScheme: colorScheme,
),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.cloud_off,
text: 'No remote server -- everything is stored locally',
text:
'No remote server -- everything is stored locally',
colorScheme: colorScheme,
),
],
),
),
),
],
),
),
@@ -205,6 +212,7 @@ class _RecentDonorsCard extends StatelessWidget {
_DonorTile(name: 'matt_3050', colorScheme: colorScheme),
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
_DonorTile(name: '283Fabio', colorScheme: colorScheme),
_DonorTile(name: 'laflame', colorScheme: colorScheme),
_DonorTile(
name: 'Elias el Autentico',
colorScheme: colorScheme,
@@ -414,9 +422,9 @@ class _NoticeLine extends StatelessWidget {
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface,
),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colorScheme.onSurface),
),
),
],
+16 -15
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
@@ -1519,17 +1520,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
// No embedded lyrics, fetch from online
final result =
await PlatformBridge.getLyricsLRCWithSource(
_spotifyId ?? '',
trackName,
artistName,
filePath: null, // Don't check file again
durationMs: durationMs,
).timeout(
const Duration(seconds: 20),
onTimeout: () => <String, dynamic>{'lyrics': '', 'source': ''},
);
final result = await PlatformBridge.getLyricsLRCWithSource(
_spotifyId ?? '',
trackName,
artistName,
filePath: null, // Don't check file again
durationMs: durationMs,
).timeout(const Duration(seconds: 20));
final lrcText = result['lyrics']?.toString() ?? '';
final source = result['source']?.toString() ?? '';
@@ -1561,13 +1558,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
});
}
}
} on TimeoutException {
if (mounted) {
setState(() {
_lyricsError = context.l10n.trackLyricsTimeout;
_lyricsLoading = false;
});
}
} catch (e) {
if (mounted) {
final errorMsg = e.toString().contains('TimeoutException')
? context.l10n.trackLyricsTimeout
: context.l10n.trackLyricsLoadFailed;
setState(() {
_lyricsError = errorMsg;
_lyricsError = context.l10n.trackLyricsLoadFailed;
_lyricsLoading = false;
});
}