fix(lyrics): reject empty embedded payloads

This commit is contained in:
zarzet
2026-08-01 15:02:03 +07:00
parent 3e41aa0eee
commit dc58353c07
8 changed files with 243 additions and 79 deletions
+12 -3
View File
@@ -73,16 +73,25 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState {
try {
final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath);
final refreshedLyrics = refreshed['lyrics']?.toString().trim() ?? '';
final refreshedInstrumental = isInstrumentalLyricsMarker(
refreshedLyrics,
);
final refreshedDisplayLyrics = cleanLyricsForDisplay(refreshedLyrics);
_setState(() {
_editedMetadata = refreshed;
_lyricsError = null;
_isInstrumental = false;
_isInstrumental = refreshedInstrumental;
_embeddedLyricsChecked = true;
if (refreshedLyrics.isNotEmpty) {
_lyrics = _cleanLrcForDisplay(refreshedLyrics);
if (refreshedDisplayLyrics.isNotEmpty) {
_lyrics = refreshedDisplayLyrics;
_rawLyrics = refreshedLyrics;
_lyricsSource = context.l10n.trackLyricsEmbeddedSource;
_lyricsEmbedded = true;
} else if (refreshedInstrumental) {
_lyrics = null;
_rawLyrics = null;
_lyricsSource = context.l10n.trackLyricsEmbeddedSource;
_lyricsEmbedded = false;
} else {
_lyrics = null;
_rawLyrics = null;
+45 -61
View File
@@ -2,6 +2,10 @@ part of 'track_metadata_screen.dart';
extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
Widget _buildLyricsCard(BuildContext context, ColorScheme colorScheme) {
final hasDisplayLyrics = _lyrics?.trim().isNotEmpty == true;
final hasDisplaySource =
(hasDisplayLyrics || _isInstrumental) &&
_lyricsSource?.trim().isNotEmpty == true;
return Card(
elevation: 0,
color: settingsGroupColor(context),
@@ -27,7 +31,7 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
),
),
const Spacer(),
if (_lyrics != null)
if (hasDisplayLyrics)
IconButton(
icon: const Icon(Icons.copy, size: 20),
onPressed: () => _copyToClipboard(context, _lyrics!),
@@ -35,7 +39,7 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
),
],
),
if (_lyricsSource != null && _lyricsSource!.trim().isNotEmpty)
if (hasDisplaySource)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
@@ -108,7 +112,7 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
],
),
)
else if (_lyrics != null)
else if (hasDisplayLyrics)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -232,20 +236,26 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
if (mounted &&
generation == _metadataLoadGeneration &&
sourcePath == cleanFilePath) {
if (embeddedLyrics.isNotEmpty) {
final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics);
final instrumental = isInstrumentalLyricsMarker(embeddedLyrics);
final cleanLyrics = cleanLyricsForDisplay(embeddedLyrics);
if (instrumental || cleanLyrics.isNotEmpty) {
_setState(() {
_lyrics = cleanLyrics;
_rawLyrics = embeddedLyrics;
_lyrics = instrumental ? null : cleanLyrics;
_rawLyrics = instrumental ? null : embeddedLyrics;
_lyricsSource = embeddedSource.isNotEmpty
? embeddedSource
: context.l10n.trackLyricsEmbeddedSource;
_lyricsEmbedded = true;
_lyricsEmbedded = !instrumental;
_isInstrumental = instrumental;
_lyricsLoading = false;
_embeddedLyricsChecked = true;
});
} else {
_setState(() {
_lyrics = null;
_rawLyrics = null;
_lyricsSource = null;
_lyricsEmbedded = false;
_lyricsLoading = false;
_embeddedLyricsChecked = true;
});
@@ -289,22 +299,29 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
final source = result['source']?.toString() ?? '';
final instrumental =
(result['instrumental'] as bool? ?? false) ||
lrcText == '[instrumental:true]';
isInstrumentalLyricsMarker(lrcText);
final cleanLyrics = cleanLyricsForDisplay(lrcText);
if (mounted) {
if (instrumental) {
_setState(() {
_lyrics = null;
_rawLyrics = null;
_isInstrumental = true;
_lyricsSource = source.isNotEmpty ? source : null;
_lyricsEmbedded = false;
_lyricsLoading = false;
});
} else if (lrcText.isEmpty) {
} else if (cleanLyrics.isEmpty) {
_setState(() {
_lyrics = null;
_rawLyrics = null;
_lyricsSource = null;
_lyricsEmbedded = false;
_lyricsError = context.l10n.trackLyricsNotAvailable;
_lyricsLoading = false;
});
} else {
final cleanLyrics = _cleanLrcForDisplay(lrcText);
_setState(() {
_lyrics = cleanLyrics;
_rawLyrics = lrcText;
@@ -362,16 +379,18 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? '';
final embeddedSource = embeddedResult['source']?.toString() ?? '';
if (embeddedLyrics.isNotEmpty) {
final embeddedInstrumental = isInstrumentalLyricsMarker(embeddedLyrics);
final cleanEmbeddedLyrics = cleanLyricsForDisplay(embeddedLyrics);
if (embeddedInstrumental || cleanEmbeddedLyrics.isNotEmpty) {
if (mounted) {
final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics);
_setState(() {
_lyrics = cleanLyrics;
_rawLyrics = embeddedLyrics;
_lyrics = embeddedInstrumental ? null : cleanEmbeddedLyrics;
_rawLyrics = embeddedInstrumental ? null : embeddedLyrics;
_lyricsSource = embeddedSource.isNotEmpty
? embeddedSource
: context.l10n.trackLyricsEmbeddedSource;
_lyricsEmbedded = true;
_lyricsEmbedded = !embeddedInstrumental;
_isInstrumental = embeddedInstrumental;
_lyricsLoading = false;
_embeddedLyricsChecked = true;
});
@@ -392,22 +411,29 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
final source = result['source']?.toString() ?? '';
final instrumental =
(result['instrumental'] as bool? ?? false) ||
lrcText == '[instrumental:true]';
isInstrumentalLyricsMarker(lrcText);
final cleanLyrics = cleanLyricsForDisplay(lrcText);
if (mounted) {
if (instrumental) {
_setState(() {
_lyrics = null;
_rawLyrics = null;
_isInstrumental = true;
_lyricsSource = source.isNotEmpty ? source : null;
_lyricsEmbedded = false;
_lyricsLoading = false;
});
} else if (lrcText.isEmpty) {
} else if (cleanLyrics.isEmpty) {
_setState(() {
_lyrics = null;
_rawLyrics = null;
_lyricsSource = null;
_lyricsEmbedded = false;
_lyricsError = context.l10n.trackLyricsNotAvailable;
_lyricsLoading = false;
});
} else {
final cleanLyrics = _cleanLrcForDisplay(lrcText);
_setState(() {
_lyrics = cleanLyrics;
_rawLyrics = lrcText;
@@ -1208,46 +1234,4 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
_log.w('Failed to sync download history metadata: $e');
}
}
String _cleanLrcForDisplay(String lrc) {
final lines = lrc.split('\n');
final cleanLines = <String>[];
for (final line in lines) {
var cleaned = line.trim();
if (_TrackMetadataScreenState._lrcMetadataPattern.hasMatch(cleaned) &&
!_TrackMetadataScreenState._lrcBackgroundLinePattern.hasMatch(
cleaned,
)) {
continue;
}
// Convert [bg:...] wrapper to a plain secondary vocal line.
final bgMatch = _TrackMetadataScreenState._lrcBackgroundLinePattern
.firstMatch(cleaned);
if (bgMatch != null) {
cleaned = bgMatch.group(1)?.trim() ?? '';
}
cleaned = cleaned
.replaceAll(_TrackMetadataScreenState._lrcTimestampPattern, '')
.trim();
cleaned = cleaned.replaceAll(
_TrackMetadataScreenState._lrcInlineTimestampPattern,
'',
);
cleaned = cleaned.replaceFirst(
_TrackMetadataScreenState._lrcSpeakerPrefixPattern,
'',
);
cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' ').trim();
if (cleaned.isNotEmpty) {
cleanLines.add(cleaned);
}
}
return cleanLines.join('\n');
}
}
-9
View File
@@ -138,15 +138,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
Map<String, dynamic>? _editedMetadata;
String? _resolvedAudioFormat;
String? _embeddedCoverPreviewPath;
static final RegExp _lrcTimestampPattern = RegExp(
r'^\[\d{2}:\d{2}\.\d{2,3}\]',
);
static final RegExp _lrcMetadataPattern = RegExp(r'^\[[a-zA-Z]+:.*\]$');
static final RegExp _lrcInlineTimestampPattern = RegExp(
r'<\d{2}:\d{2}\.\d{2,3}>',
);
static final RegExp _lrcSpeakerPrefixPattern = RegExp(r'^(v1|v2):\s*');
static final RegExp _lrcBackgroundLinePattern = RegExp(r'^\[bg:(.*)\]$');
static final RegExp _invalidFileNameChars = RegExp(r'[<>:"/\\|?*\x00-\x1f]');
static final RegExp _multiUnderscore = RegExp(r'_+');
static final RegExp _leadingOrTrailingDots = RegExp(r'^\.+|\.+$');
+62 -2
View File
@@ -3,12 +3,72 @@ import 'dart:io';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
final RegExp _lrcDisplayTimestampPattern = RegExp(
r'^\[\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?\]',
);
final RegExp _lrcDisplayMetadataPattern = RegExp(
r'^\[[a-zA-Z][a-zA-Z0-9_]*:.*\]$',
);
final RegExp _lrcDisplayInlineTimestampPattern = RegExp(
r'<\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?>',
);
final RegExp _lrcDisplaySpeakerPrefixPattern = RegExp(
r'^(v1|v2):\s*',
caseSensitive: false,
);
final RegExp _lrcDisplayBackgroundLinePattern = RegExp(
r'^\[bg:(.*)\]$',
caseSensitive: false,
);
bool isInstrumentalLyricsMarker(String lyrics) =>
lyrics.trim().toLowerCase() == '[instrumental:true]';
/// Converts embedded or fetched LRC into text suitable for the metadata UI.
/// Header-only payloads intentionally produce an empty string.
String cleanLyricsForDisplay(String lyrics) {
final cleanLines = <String>[];
for (final line in lyrics.split('\n')) {
var cleaned = line.trim();
if (_lrcDisplayMetadataPattern.hasMatch(cleaned) &&
!_lrcDisplayBackgroundLinePattern.hasMatch(cleaned)) {
continue;
}
final backgroundMatch = _lrcDisplayBackgroundLinePattern.firstMatch(
cleaned,
);
if (backgroundMatch != null) {
cleaned = backgroundMatch.group(1)?.trim() ?? '';
}
while (_lrcDisplayTimestampPattern.hasMatch(cleaned)) {
cleaned = cleaned.replaceFirst(_lrcDisplayTimestampPattern, '').trim();
}
cleaned = cleaned.replaceAll(_lrcDisplayInlineTimestampPattern, '');
cleaned = cleaned.replaceFirst(_lrcDisplaySpeakerPrefixPattern, '');
cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' ').trim();
if (cleaned.isNotEmpty) {
cleanLines.add(cleaned);
}
}
return cleanLines.join('\n');
}
bool hasUsableLyricsContent(String lyrics) =>
isInstrumentalLyricsMarker(lyrics) ||
cleanLyricsForDisplay(lyrics).trim().isNotEmpty;
bool hasEmbeddedLyricsMetadata(Map<String, String> metadata) {
final lyrics = (metadata['LYRICS'] ?? '').trim();
if (lyrics.isNotEmpty) return true;
if (hasUsableLyricsContent(lyrics)) return true;
final unsyncedLyrics = (metadata['UNSYNCEDLYRICS'] ?? '').trim();
if (unsyncedLyrics.isNotEmpty) return true;
if (hasUsableLyricsContent(unsyncedLyrics)) return true;
return false;
}