mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-01 05:39:34 +02:00
perf(lyrics): stream MP3 audio when updating ID3 lyrics
This commit is contained in:
1 parent
cd4b8ead90
commit
39a3e680a4
3 files changed
+148
-4
No files matched your search
@@ -1458,14 +1458,15 @@ class FFmpegService {
|
||||
final file = File(mp3Path);
|
||||
if (!await file.exists()) return;
|
||||
|
||||
final bytes = await file.readAsBytes();
|
||||
final updated = Id3v23Lyrics.writeUnsyncedLyrics(bytes, lyrics);
|
||||
if (updated == null) {
|
||||
final written = await Id3v23Lyrics.writeUnsyncedLyricsToFile(
|
||||
file,
|
||||
lyrics,
|
||||
);
|
||||
if (!written) {
|
||||
_log.w('Skipping MP3 USLT lyrics frame update: unsupported ID3 tag');
|
||||
return;
|
||||
}
|
||||
|
||||
await file.writeAsBytes(updated, flush: true);
|
||||
_log.d('MP3 USLT lyrics frame written (${lyrics.length} chars)');
|
||||
} catch (e) {
|
||||
_log.w('Failed to write MP3 USLT lyrics frame: $e');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Minimal ID3v2.3 USLT writer used after FFmpeg metadata embedding.
|
||||
@@ -8,6 +9,62 @@ import 'dart:typed_data';
|
||||
class Id3v23Lyrics {
|
||||
const Id3v23Lyrics._();
|
||||
|
||||
// Artwork can make tags large, but never allocate an audio-sized buffer for
|
||||
// a malformed size field. Unsupported/oversized tags leave the file intact.
|
||||
static const int _maxTagBytes = 32 << 20;
|
||||
|
||||
/// Replaces lyrics while streaming the audio tail to a sibling temporary
|
||||
/// file. The original is replaced only after all output has been flushed.
|
||||
static Future<bool> writeUnsyncedLyricsToFile(
|
||||
File file,
|
||||
String lyrics,
|
||||
) async {
|
||||
final input = await file.open();
|
||||
late final Uint8List updatedTag;
|
||||
var audioOffset = 0;
|
||||
try {
|
||||
final header = await input.read(10);
|
||||
if (_hasId3Header(header)) {
|
||||
final tagSize = _readSynchsafeInt(header, 6);
|
||||
if (header[3] != 3 ||
|
||||
(header[5] & (0x80 | 0x40 | 0x20)) != 0 ||
|
||||
tagSize == null ||
|
||||
tagSize > _maxTagBytes ||
|
||||
tagSize + 10 > await input.length()) {
|
||||
return false;
|
||||
}
|
||||
await input.setPosition(0);
|
||||
final tag = await input.read(tagSize + 10);
|
||||
if (tag.length != tagSize + 10) return false;
|
||||
final updated = writeUnsyncedLyrics(tag, lyrics);
|
||||
if (updated == null) return false;
|
||||
updatedTag = updated;
|
||||
audioOffset = tagSize + 10;
|
||||
} else {
|
||||
updatedTag = _buildTag(_buildUnsyncedLyricsFrame(lyrics));
|
||||
}
|
||||
} finally {
|
||||
await input.close();
|
||||
}
|
||||
|
||||
final staging = await file.parent.createTemp('.spotiflac-lyrics-');
|
||||
try {
|
||||
final output = File('${staging.path}${Platform.pathSeparator}audio.mp3');
|
||||
final sink = output.openWrite();
|
||||
try {
|
||||
sink.add(updatedTag);
|
||||
await sink.addStream(file.openRead(audioOffset));
|
||||
await sink.flush();
|
||||
} finally {
|
||||
await sink.close();
|
||||
}
|
||||
await output.rename(file.path);
|
||||
return true;
|
||||
} finally {
|
||||
await staging.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
static Uint8List? writeUnsyncedLyrics(Uint8List bytes, String lyrics) {
|
||||
final lyricsFrame = _buildUnsyncedLyricsFrame(lyrics);
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/id3v23_lyrics.dart';
|
||||
|
||||
void main() {
|
||||
late Directory root;
|
||||
setUp(() async {
|
||||
root = await Directory.systemTemp.createTemp('id3-lyrics-test-');
|
||||
});
|
||||
tearDown(() => root.delete(recursive: true));
|
||||
|
||||
test(
|
||||
'streams audio unchanged across insertion and replacement of lyrics',
|
||||
() async {
|
||||
final file = File('${root.path}/track.mp3');
|
||||
final chunk = Uint8List.fromList(List.generate(65536, (i) => i % 251));
|
||||
final sink = file.openWrite();
|
||||
for (var i = 0; i < 128; i++) {
|
||||
sink.add(chunk);
|
||||
}
|
||||
await sink.close();
|
||||
final audioSize = await file.length();
|
||||
final audioHash = await sha256.bind(file.openRead()).single;
|
||||
|
||||
for (final lyrics in ['First lyrics', 'Replacement 歌詞']) {
|
||||
expect(
|
||||
await Id3v23Lyrics.writeUnsyncedLyricsToFile(file, lyrics),
|
||||
isTrue,
|
||||
);
|
||||
final input = await file.open();
|
||||
final header = await input.read(10);
|
||||
final tagSize =
|
||||
(header[6] << 21) |
|
||||
(header[7] << 14) |
|
||||
(header[8] << 7) |
|
||||
header[9];
|
||||
await input.setPosition(0);
|
||||
final tag = await input.read(tagSize + 10);
|
||||
await input.close();
|
||||
expect(tag, Id3v23Lyrics.writeUnsyncedLyrics(Uint8List(0), lyrics));
|
||||
expect(await file.length(), audioSize + tag.length);
|
||||
expect(await sha256.bind(file.openRead(tag.length)).single, audioHash);
|
||||
expect(await root.list().length, 1);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('preserves other frames when updating an existing tag', () async {
|
||||
final file = File('${root.path}/track.mp3');
|
||||
final bytes = Id3v23Lyrics.writeUnsyncedLyrics(
|
||||
Uint8List.fromList([1, 2, 3, 4]),
|
||||
'old lyrics',
|
||||
)!;
|
||||
// Re-label the valid frame so it must be preserved alongside new USLT.
|
||||
bytes.setRange(10, 14, [0x54, 0x49, 0x54, 0x32]);
|
||||
await file.writeAsBytes(bytes);
|
||||
expect(await Id3v23Lyrics.writeUnsyncedLyricsToFile(file, 'new'), isTrue);
|
||||
expect(
|
||||
await file.readAsBytes(),
|
||||
Id3v23Lyrics.writeUnsyncedLyrics(bytes, 'new'),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'unsupported, malformed and truncated tags leave the file intact',
|
||||
() async {
|
||||
for (final header in [
|
||||
[73, 68, 51, 4, 0, 0, 0, 0, 0, 0],
|
||||
[73, 68, 51, 3, 0, 0x80, 0, 0, 0, 0],
|
||||
[73, 68, 51, 3, 0, 0, 0x80, 0, 0, 0],
|
||||
[73, 68, 51, 3, 0, 0, 0, 0, 0, 100],
|
||||
]) {
|
||||
final file = await File('${root.path}/track.mp3').writeAsBytes(header);
|
||||
expect(
|
||||
await Id3v23Lyrics.writeUnsyncedLyricsToFile(file, 'new'),
|
||||
isFalse,
|
||||
);
|
||||
expect(await file.readAsBytes(), header);
|
||||
expect(await root.list().length, 1);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user