diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index ccda2906..576772f7 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -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) diff --git a/go_backend/lyrics_apple.go b/go_backend/lyrics_apple.go index d3fef0b2..41d650f8 100644 --- a/go_backend/lyrics_apple.go +++ b/go_backend/lyrics_apple.go @@ -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) diff --git a/go_backend/lyrics_qqmusic.go b/go_backend/lyrics_qqmusic.go index 654a3543..6971ba24 100644 --- a/go_backend/lyrics_qqmusic.go +++ b/go_backend/lyrics_qqmusic.go @@ -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) diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 56a7aefe..c71cd4db 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -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), ), ), ], diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index ecb2e5b7..ca4f22b0 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -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 { } // 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: () => {'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 { }); } } + } 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; }); }