mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-14 13:59:11 +02:00
Trim after removing inline timestamps before matching speaker prefixes in Dart. Run one 21-case usability corpus through Dart, Go, and Kotlin tests. Document the shared fixture and trigger Dart and Go CI when its resources change.
247 lines
7.3 KiB
Dart
247 lines
7.3 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/utils/file_access.dart';
|
|
import 'package:spotiflac_android/utils/string_utils.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, '').trim();
|
|
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 (hasUsableLyricsContent(lyrics)) return true;
|
|
|
|
final unsyncedLyrics = (metadata['UNSYNCEDLYRICS'] ?? '').trim();
|
|
if (hasUsableLyricsContent(unsyncedLyrics)) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
String _sidecarLrcPath(String path) {
|
|
final slash = path.lastIndexOf(Platform.pathSeparator);
|
|
final dot = path.lastIndexOf('.');
|
|
if (dot > slash) {
|
|
return '${path.substring(0, dot)}.lrc';
|
|
}
|
|
return '$path.lrc';
|
|
}
|
|
|
|
/// Writes a ".lrc" sidecar next to a re-enriched audio file when the Go backend
|
|
/// result requests it (`write_external_lrc`), honoring the user's lyrics mode.
|
|
///
|
|
/// This handles the filesystem case only. SAF (`content://`) files are written
|
|
/// centrally by the Kotlin `reEnrichFile` handler, which still holds the
|
|
/// original document URI, so callers should skip those here (they are detected
|
|
/// and ignored). Best-effort: returns true only when a sidecar was actually
|
|
/// written, and never throws.
|
|
Future<bool> writeReEnrichSidecarLrc({
|
|
required String audioFilePath,
|
|
required Map<String, dynamic> reEnrichResult,
|
|
}) async {
|
|
if (reEnrichResult['write_external_lrc'] != true) return false;
|
|
|
|
// SAF documents are handled natively in Kotlin; nothing to do from Dart.
|
|
if (isContentUri(audioFilePath)) return false;
|
|
|
|
final lrc = (reEnrichResult['lyrics'] as String?)?.trim() ?? '';
|
|
if (lrc.isEmpty) return false;
|
|
|
|
try {
|
|
final lrcPath = _sidecarLrcPath(audioFilePath);
|
|
await File(lrcPath).writeAsString(lrc);
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Writes a SAF ".lrc" sidecar after a FFmpeg re-enrich write-back succeeds.
|
|
///
|
|
/// Native FLAC re-enrich handles SAF sidecars in Kotlin after the direct
|
|
/// write-back. This helper is for the FFmpeg path, where Dart owns the final
|
|
/// `writeTempToSaf` success/failure decision.
|
|
Future<bool> writeReEnrichSafSidecarLrc({
|
|
required String safUri,
|
|
required Map<String, dynamic> reEnrichResult,
|
|
}) async {
|
|
if (reEnrichResult['write_external_lrc'] != true) return false;
|
|
if (!isContentUri(safUri)) return false;
|
|
|
|
final lrc = (reEnrichResult['lyrics'] as String?)?.trim() ?? '';
|
|
if (lrc.isEmpty) return false;
|
|
|
|
try {
|
|
return await PlatformBridge.writeSafSidecarLrc(safUri, lrc);
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> ensureLyricsMetadataForConversion({
|
|
required Map<String, String> metadata,
|
|
required String sourcePath,
|
|
required bool shouldEmbedLyrics,
|
|
required String trackName,
|
|
required String artistName,
|
|
String spotifyId = '',
|
|
int durationMs = 0,
|
|
}) async {
|
|
if (!shouldEmbedLyrics || hasEmbeddedLyricsMetadata(metadata)) {
|
|
return;
|
|
}
|
|
|
|
String? lyrics;
|
|
|
|
// Prefer sidecar .lrc when available to avoid network calls.
|
|
if (!isContentUri(sourcePath)) {
|
|
try {
|
|
final lrcPath = _sidecarLrcPath(sourcePath);
|
|
final lrcFile = File(lrcPath);
|
|
if (await lrcFile.exists()) {
|
|
final content = (await lrcFile.readAsString()).trim();
|
|
if (content.isNotEmpty) {
|
|
lyrics = content;
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (lyrics == null || lyrics.isEmpty) {
|
|
try {
|
|
final fetched = await PlatformBridge.getLyricsLRC(
|
|
spotifyId,
|
|
trackName,
|
|
artistName,
|
|
durationMs: durationMs,
|
|
);
|
|
final normalized = fetched.trim();
|
|
if (normalized.isNotEmpty &&
|
|
normalized.toLowerCase() != '[instrumental:true]') {
|
|
lyrics = normalized;
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (lyrics == null || lyrics.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
metadata['LYRICS'] = lyrics;
|
|
metadata['UNSYNCEDLYRICS'] = lyrics;
|
|
}
|
|
|
|
void mergePlatformMetadataForTagEmbed({
|
|
required Map<String, String> target,
|
|
required Map<String, dynamic> source,
|
|
}) {
|
|
void put(String key, dynamic value) {
|
|
final normalized = value?.toString().trim();
|
|
if (normalized == null || normalized.isEmpty) return;
|
|
target[key] = normalized;
|
|
}
|
|
|
|
put('TITLE', source['title']);
|
|
put('ARTIST', source['artist']);
|
|
put('ALBUM', source['album']);
|
|
put('ALBUMARTIST', source['album_artist']);
|
|
put('DATE', source['date']);
|
|
put('ISRC', source['isrc']);
|
|
put('GENRE', source['genre']);
|
|
put('ORGANIZATION', source['label']);
|
|
put('COPYRIGHT', source['copyright']);
|
|
put('COMPOSER', source['composer']);
|
|
put('COMMENT', source['comment']);
|
|
put('LYRICS', source['lyrics']);
|
|
put('UNSYNCEDLYRICS', source['lyrics']);
|
|
|
|
final explicit = parseExplicitFlag(source['explicit']);
|
|
if (explicit == true) {
|
|
target['ITUNESADVISORY'] = '1';
|
|
} else if (explicit == false) {
|
|
target.remove('ITUNESADVISORY');
|
|
}
|
|
|
|
final trackNumber = source['track_number'];
|
|
final totalTracks = source['total_tracks'];
|
|
if (trackNumber != null && trackNumber.toString() != '0') {
|
|
put(
|
|
'TRACKNUMBER',
|
|
totalTracks != null &&
|
|
totalTracks.toString().isNotEmpty &&
|
|
totalTracks.toString() != '0'
|
|
? '${trackNumber.toString()}/${totalTracks.toString()}'
|
|
: trackNumber,
|
|
);
|
|
}
|
|
|
|
final discNumber = source['disc_number'];
|
|
final totalDiscs = source['total_discs'];
|
|
if (discNumber != null && discNumber.toString() != '0') {
|
|
put(
|
|
'DISCNUMBER',
|
|
totalDiscs != null &&
|
|
totalDiscs.toString().isNotEmpty &&
|
|
totalDiscs.toString() != '0'
|
|
? '${discNumber.toString()}/${totalDiscs.toString()}'
|
|
: discNumber,
|
|
);
|
|
}
|
|
}
|