Skip to content

Commit 2eaab99

Browse files
committed
feat: release version 0.6.2 with fixes for MP4/M4B parsing and large 64-bit timestamp handling
1 parent feecba1 commit 2eaab99

6 files changed

Lines changed: 341 additions & 107 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.6.2
2+
3+
- **Fixed**: MP4/M4B parsing now handles version-1 `mvhd` atoms with large 64-bit timestamps without crashing, including the audiobook URL regression case that previously overflowed `DateTime` conversion on some platforms.
4+
15
## 0.6.1
26

37
- **Fixed**: MP3 URL parsing now routes through the MPEG parser instead of the tag-only ID3 loader, restoring duration and bitrate extraction for real-world app usage.

lib/src/mp4/atom_token.dart

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,16 @@ class AtomToken {
118118
);
119119
}
120120

121-
final extendedLength = readUint64Be(bytes, 8);
122-
if (extendedLength > 0x7FFFFFFF) {
121+
final extendedLength = _readUint64BeAsBigInt(bytes, 8);
122+
if (extendedLength > BigInt.from(0x7FFFFFFF)) {
123123
throw Mp4ContentError('Atom too large for parser: $extendedLength bytes');
124124
}
125125

126-
return AtomHeader(length: extendedLength, name: name, headerLength: 16);
126+
return AtomHeader(
127+
length: extendedLength.toInt(),
128+
name: name,
129+
headerLength: 16,
130+
);
127131
}
128132

129133
static List<String> parseFtypBrands(List<int> bytes) {
@@ -150,15 +154,15 @@ class AtomToken {
150154
if (bytes.length < 32) {
151155
throw Mp4ContentError('mvhd version 1 payload too short');
152156
}
153-
final creation = readUint64Be(bytes, 4);
154-
final modification = readUint64Be(bytes, 12);
157+
final creation = _readUint64BeAsBigInt(bytes, 4);
158+
final modification = _readUint64BeAsBigInt(bytes, 12);
155159
final timeScale = readUint32Be(bytes, 20);
156-
final duration = readUint64Be(bytes, 24);
160+
final duration = _readUint64BeAsBigInt(bytes, 24);
157161
return MvhdAtomData(
158-
creationTime: _macEpochToDate(creation),
159-
modificationTime: _macEpochToDate(modification),
162+
creationTime: _macEpochToDateBigInt(creation),
163+
modificationTime: _macEpochToDateBigInt(modification),
160164
timeScale: timeScale,
161-
duration: duration,
165+
duration: duration.toInt(),
162166
);
163167
}
164168

@@ -167,8 +171,8 @@ class AtomToken {
167171
final timeScale = readUint32Be(bytes, 12);
168172
final duration = readUint32Be(bytes, 16);
169173
return MvhdAtomData(
170-
creationTime: _macEpochToDate(creation),
171-
modificationTime: _macEpochToDate(modification),
174+
creationTime: _macEpochToDateInt(creation),
175+
modificationTime: _macEpochToDateInt(modification),
172176
timeScale: timeScale,
173177
duration: duration,
174178
);
@@ -186,7 +190,7 @@ class AtomToken {
186190
}
187191
return MdhdAtomData(
188192
timeScale: readUint32Be(bytes, 20),
189-
duration: readUint64Be(bytes, 24),
193+
duration: _readUint64BeAsBigInt(bytes, 24).toInt(),
190194
);
191195
}
192196

@@ -381,27 +385,55 @@ class AtomToken {
381385
}
382386

383387
static int readUint64Be(List<int> bytes, int offset) {
388+
final value = _readUint64BeAsBigInt(bytes, offset);
389+
if (value > BigInt.from(0x7FFFFFFFFFFFFFFF)) {
390+
throw Mp4ContentError('64-bit integer overflow for parser runtime');
391+
}
392+
return value.toInt();
393+
}
394+
395+
static BigInt _readUint64BeAsBigInt(List<int> bytes, int offset) {
384396
_ensureRange(bytes, offset, 8);
385-
final value =
386-
(BigInt.from(bytes[offset]) << 56) |
397+
return (BigInt.from(bytes[offset]) << 56) |
387398
(BigInt.from(bytes[offset + 1]) << 48) |
388399
(BigInt.from(bytes[offset + 2]) << 40) |
389400
(BigInt.from(bytes[offset + 3]) << 32) |
390401
(BigInt.from(bytes[offset + 4]) << 24) |
391402
(BigInt.from(bytes[offset + 5]) << 16) |
392403
(BigInt.from(bytes[offset + 6]) << 8) |
393404
BigInt.from(bytes[offset + 7]);
394-
// Dart int is 64-bit on native platforms, allow full signed 64-bit range
395-
if (value > BigInt.from(0x7FFFFFFFFFFFFFFF)) {
396-
throw Mp4ContentError('64-bit integer overflow for parser runtime');
397-
}
398-
return value.toInt();
399405
}
400406

401-
static DateTime _macEpochToDate(int secondsSinceMacEpoch) {
407+
static DateTime _macEpochToDateInt(int secondsSinceMacEpoch) {
408+
return _macEpochToDateBigInt(BigInt.from(secondsSinceMacEpoch));
409+
}
410+
411+
static DateTime _macEpochToDateBigInt(BigInt secondsSinceMacEpoch) {
402412
const macToUnixEpochSeconds = 2082844800;
403-
final unixSeconds = secondsSinceMacEpoch - macToUnixEpochSeconds;
404-
return DateTime.fromMillisecondsSinceEpoch(unixSeconds * 1000, isUtc: true);
413+
const maxDateTimeMilliseconds = 8640000000000000;
414+
const minDateTimeMilliseconds = -8640000000000000;
415+
final unixSeconds =
416+
secondsSinceMacEpoch - BigInt.from(macToUnixEpochSeconds);
417+
final milliseconds = unixSeconds * BigInt.from(1000);
418+
419+
if (milliseconds > BigInt.from(maxDateTimeMilliseconds)) {
420+
return DateTime.fromMillisecondsSinceEpoch(
421+
maxDateTimeMilliseconds,
422+
isUtc: true,
423+
);
424+
}
425+
426+
if (milliseconds < BigInt.from(minDateTimeMilliseconds)) {
427+
return DateTime.fromMillisecondsSinceEpoch(
428+
minDateTimeMilliseconds,
429+
isUtc: true,
430+
);
431+
}
432+
433+
return DateTime.fromMillisecondsSinceEpoch(
434+
milliseconds.toInt(),
435+
isUtc: true,
436+
);
405437
}
406438

407439
static void _ensureRange(List<int> bytes, int offset, int length) {

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ description: |
44
metadata extraction for various audio formats including MP3, FLAC, Ogg, MP4,
55
WAV, AIFF, APE, ASF, Matroska, and more. Ported from music-metadata with
66
architecture parity and TDD approach.
7-
version: 0.6.1
7+
version: 0.6.2
88
repository: https://github.com/ketanchoyal/metadata_audio
99
homepage: https://github.com/ketanchoyal/metadata_audio
1010
issue_tracker: https://github.com/ketanchoyal/metadata_audio/issues

test/file_tests/test_file_mp4_test.dart

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import 'dart:convert';
12
import 'dart:io';
3+
import 'dart:typed_data';
24

35
import 'package:metadata_audio/metadata_audio.dart';
46
import 'package:metadata_audio/src/mp4/mp4_loader.dart';
@@ -41,5 +43,107 @@ void main() {
4143
expect(metadata.format.chapters![2].title, 'Chapter 3');
4244
expect(metadata.format.chapters![2].start, 4000);
4345
});
46+
47+
test(
48+
'parses mirrored Dune m4b sample with version 1 mvhd timestamps',
49+
() async {
50+
// This fixture mirrors the failure mode from the shared audiobook URL:
51+
// version 1 mvhd fields with very large 64-bit timestamps.
52+
final file = await _writeMirroredMvhdSample();
53+
addTearDown(() async {
54+
if (await file.exists()) {
55+
await file.delete();
56+
}
57+
});
58+
59+
final metadata = await parseFile(
60+
file.path,
61+
options: const ParseOptions(duration: true),
62+
);
63+
64+
expect(metadata.format.container, startsWith('M4A/isom'));
65+
expect(
66+
metadata.format.creationTime,
67+
DateTime.fromMillisecondsSinceEpoch(8640000000000000, isUtc: true),
68+
);
69+
expect(
70+
metadata.format.modificationTime,
71+
DateTime.fromMillisecondsSinceEpoch(8640000000000000, isUtc: true),
72+
);
73+
expect(metadata.format.duration, closeTo(2.0, 0.0001));
74+
},
75+
);
4476
});
4577
}
78+
79+
Future<File> _writeMirroredMvhdSample() async {
80+
final file = File(
81+
p.join(
82+
Directory.systemTemp.path,
83+
'metadata_audio_mirrored_mvhd_v1_${DateTime.now().microsecondsSinceEpoch}.m4b',
84+
),
85+
);
86+
await file.writeAsBytes(_buildMirroredMvhdSample());
87+
return file;
88+
}
89+
90+
List<int> _buildMirroredMvhdSample() {
91+
final ftyp = _atom('ftyp', <int>[
92+
...latin1.encode('M4A '),
93+
...latin1.encode('isom'),
94+
...latin1.encode('mp42'),
95+
]);
96+
97+
final mvhd = _atom(
98+
'mvhd',
99+
_mvhdPayloadV1(
100+
creationTime: 0x7FFFFFFFFFFFFFFF,
101+
modificationTime: 0x7FFFFFFFFFFFFFFF,
102+
timeScale: 1000,
103+
duration: 2000,
104+
),
105+
);
106+
107+
final moov = _atom('moov', mvhd);
108+
return <int>[...ftyp, ...moov];
109+
}
110+
111+
List<int> _mvhdPayloadV1({
112+
required int creationTime,
113+
required int modificationTime,
114+
required int timeScale,
115+
required int duration,
116+
}) => <int>[
117+
1,
118+
0,
119+
0,
120+
0,
121+
..._u64(creationTime),
122+
..._u64(modificationTime),
123+
..._u32(timeScale),
124+
..._u64(duration),
125+
...List<int>.filled(80, 0),
126+
];
127+
128+
List<int> _atom(String name, List<int> payload) {
129+
final length = 8 + payload.length;
130+
return <int>[..._u32(length), ...latin1.encode(name), ...payload];
131+
}
132+
133+
List<int> _u32(int value) => <int>[
134+
(value >> 24) & 0xFF,
135+
(value >> 16) & 0xFF,
136+
(value >> 8) & 0xFF,
137+
value & 0xFF,
138+
];
139+
140+
List<int> _u64(int value) => <int>[
141+
(value >> 56) & 0xFF,
142+
(value >> 48) & 0xFF,
143+
(value >> 40) & 0xFF,
144+
(value >> 32) & 0xFF,
145+
(value >> 24) & 0xFF,
146+
(value >> 16) & 0xFF,
147+
(value >> 8) & 0xFF,
148+
value & 0xFF,
149+
];

0 commit comments

Comments
 (0)