diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 91ce9eed..0a553caa 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -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'); diff --git a/lib/services/id3v23_lyrics.dart b/lib/services/id3v23_lyrics.dart index deab1445..47d175df 100644 --- a/lib/services/id3v23_lyrics.dart +++ b/lib/services/id3v23_lyrics.dart @@ -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 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); diff --git a/test/id3v23_lyrics_file_test.dart b/test/id3v23_lyrics_file_test.dart new file mode 100644 index 00000000..2835c046 --- /dev/null +++ b/test/id3v23_lyrics_file_test.dart @@ -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); + } + }, + ); +}