music-metadata 12.2.0 → 12.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -177,6 +177,8 @@ parseFile(filePath: string, options?: IOptions): Promise<IAudioMetadata>
177
177
 
178
178
  - This function is **Node.js-only** and relies on Node.js-specific APIs to access the filesystem.
179
179
 
180
+ - The path must resolve to a regular file. Symlinks to regular files are supported. Directories, FIFOs (named pipes), and other non-regular filesystem objects are rejected with `NotRegularFileError` from `strtok3`. FIFOs are rejected without waiting for a writer. To parse streamed audio, use [`parseStream()`](#parsestream-function).
181
+
180
182
  - For browser environments, consider using the [parseBlob](#parseblob-function) to parse [File object](https://developer.mozilla.org/en-US/docs/Web/API/File) objects.
181
183
 
182
184
  ##### Example:
@@ -618,6 +620,7 @@ Returns a list of supported MIME-types. This may include some MIME-types which a
618
620
  Maximum bytes buffered for a single metadata payload, accumulated Ogg packet, or tokenizer stream lookahead.
619
621
  Must be a positive safe integer. Parsing rejects payloads above this limit before allocating them.
620
622
  The parser configures this limit once on its `strtok3` tokenizers, including tokenizers for embedded tags.
623
+ The same limit applies to `file-type` read buffers when detecting the media format or a cover art MIME-type.
621
624
  Oversized tokenizer allocations or stream lookahead reject with `strtok3.BufferSizeError`.
622
625
  When using `parseFromTokenizer()`, also configure `maxBufferSize` on the supplied tokenizer at creation.
623
626
  Increase it for files with unusually large metadata or cover art, or lower it for constrained environments.
@@ -631,10 +634,17 @@ Returns a list of supported MIME-types. This may include some MIME-types which a
631
634
  - Metadata not listed in the SeekHead may be skipped.
632
635
  - If the SeekHead is missing, this option has no effect.
633
636
 
637
+ For large MKV files on network shares, use `parseFile(filePath, { mkvUseIndex: true })`
638
+ when the file's SeekHead index is reliable. The default traversal reads every cluster header,
639
+ so network latency can dominate even though the audio/video payloads are skipped.
640
+
634
641
  - `observer`: `(update: IMetadataEvent) => void`:
635
642
 
636
643
  Callback function triggered when common tags or format properties are updated during parsing.
637
644
  Allows real-time monitoring of metadata as it becomes available.
645
+ The callback runs synchronously after the update; returned promises are not awaited.
646
+ Repeated assignments of unchanged primitive format values do not trigger callbacks.
647
+ Keep callback work lightweight, or batch expensive operations in the consuming application.
638
648
 
639
649
  - `skipCovers`: `boolean` (default: `false`)
640
650
 
@@ -1,5 +1,5 @@
1
1
  import { type MediaType } from 'media-typer';
2
- import type { ITokenizer } from 'strtok3';
2
+ import { type ITokenizer } from 'strtok3';
3
3
  import { type INativeMetadataCollector } from './common/MetadataCollector.js';
4
4
  import { type IAudioMetadata, type IOptions, type ParserType } from './type.js';
5
5
  export interface IParserLoader {
@@ -2,6 +2,7 @@ import { parse as parseContentType } from 'content-type';
2
2
  import initDebug from 'debug';
3
3
  import { fileTypeFromBuffer } from 'file-type';
4
4
  import { parse as mimeTypeParse } from 'media-typer';
5
+ import { isRandomAccessTokenizer } from 'strtok3';
5
6
  import { aiffParserLoader } from './aiff/AiffLoader.js';
6
7
  import { apeParserLoader } from './apev2/Apev2Loader.js';
7
8
  import { asfParserLoader } from './asf/AsfLoader.js';
@@ -59,8 +60,8 @@ export class ParserFactory {
59
60
  this.parsers.push(parser);
60
61
  }
61
62
  async parse(tokenizer, parserLoader, opts = {}) {
62
- getMaxMemoryAllocation(opts);
63
- if (tokenizer.supportsRandomAccess()) {
63
+ const maxBufferSize = getMaxMemoryAllocation(opts);
64
+ if (isRandomAccessTokenizer(tokenizer)) {
64
65
  debug('tokenizer supports random-access, scanning for appending headers');
65
66
  await scanAppendingHeaders(tokenizer, opts);
66
67
  }
@@ -79,7 +80,7 @@ export class ParserFactory {
79
80
  // Parser could not be determined on MIME-type or extension
80
81
  debug('Guess parser on content...');
81
82
  await tokenizer.peekBuffer(buf, { mayBeLess: true });
82
- const guessedType = await fileTypeFromBuffer(buf, { mpegOffsetTolerance: 10 });
83
+ const guessedType = await fileTypeFromBuffer(buf, { mpegOffsetTolerance: 10, maxBufferSize });
83
84
  if (!guessedType?.mime) {
84
85
  throw new CouldNotDetermineFileTypeError('Failed to determine audio format');
85
86
  }
@@ -1,10 +1,9 @@
1
1
  import initDebug from 'debug';
2
2
  import * as strtok3 from 'strtok3';
3
3
  import * as Token from 'token-types';
4
- import { getTokenizerOptions } from '../common/Allocation.js';
5
4
  import { BasicParser } from '../common/BasicParser.js';
6
5
  import { FourCcToken } from '../common/FourCC.js';
7
- import { ID3v2Parser } from '../id3v2/ID3v2Parser.js';
6
+ import { parseEmbeddedID3v2 } from '../id3v2/EmbeddedID3v2.js';
8
7
  import * as iff from '../iff/index.js';
9
8
  import * as AiffToken from './AiffToken.js';
10
9
  import { AiffContentError, compressionTypes } from './AiffToken.js';
@@ -77,9 +76,7 @@ export class AIFFParser extends BasicParser {
77
76
  }
78
77
  case 'ID3 ': {
79
78
  // ID3-meta-data
80
- const id3_data = await this.readToken(new Token.Uint8ArrayType(header.chunkSize));
81
- const rst = strtok3.fromBuffer(id3_data, getTokenizerOptions(this.options));
82
- await new ID3v2Parser().parse(this.metadata, rst, this.options);
79
+ await parseEmbeddedID3v2(this.metadata, this.tokenizer, this.options, header.chunkSize);
83
80
  return header.chunkSize;
84
81
  }
85
82
  case 'SSND': // Sound Data Chunk
@@ -1,4 +1,4 @@
1
- import { EndOfStreamError } from 'strtok3';
1
+ import { EndOfStreamError, isRandomAccessTokenizer } from 'strtok3';
2
2
  import { UnexpectedFileContentError } from '../ParseError.js';
3
3
  const defaultMaxMemoryAllocation = 64 * 1024 * 1024;
4
4
  const readChunkSize = 64 * 1024;
@@ -33,7 +33,7 @@ function validateAvailableBytes(tokenizer, length, position) {
33
33
  export async function readBuffer(tokenizer, length, options, position = tokenizer.position, makeError = allocationError) {
34
34
  validateAllocation(length, options, makeError);
35
35
  validateAvailableBytes(tokenizer, length, position);
36
- if ((!tokenizer.supportsRandomAccess() || tokenizer.fileInfo.size === undefined) && length > readChunkSize) {
36
+ if (!isRandomAccessTokenizer(tokenizer) && length > readChunkSize) {
37
37
  const chunks = [];
38
38
  for (let remaining = length; remaining > 0;) {
39
39
  const chunk = new Uint8Array(Math.min(remaining, readChunkSize));
@@ -18,7 +18,6 @@ export class CombinedTagMapper {
18
18
  new ID3v22TagMapper(),
19
19
  new ID3v24TagMapper(),
20
20
  new MP4TagMapper(),
21
- new MP4TagMapper(),
22
21
  new VorbisTagMapper(),
23
22
  new APEv2TagMapper(),
24
23
  new AsfTagMapper(),
@@ -1,4 +1,4 @@
1
- import { type AnyTagValue, type FormatId, type IAudioMetadata, type ICommonTagsResult, type IFormat, type INativeTags, type IOptions, type IQualityInformation, type ITrackInfo } from '../type.js';
1
+ import { type AnyTagValue, type IAudioMetadata, type ICommonTagsResult, type IFormat, type INativeTags, type IOptions, type IQualityInformation, type ITrackInfo } from '../type.js';
2
2
  import { type IGenericTag, type TagType } from './GenericTagTypes.js';
3
3
  /**
4
4
  * Combines all generic-tag-mappers for each tag type
@@ -21,15 +21,15 @@ export interface INativeMetadataCollector extends IWarningCollector {
21
21
  * @returns {boolean} true if one or more tags have been found
22
22
  */
23
23
  hasAny(): boolean;
24
- setFormat(key: FormatId, value: AnyTagValue): void;
24
+ setFormat<K extends keyof IFormat>(key: K, value: IFormat[K]): void;
25
25
  registerTagType(tagType: TagType): void;
26
26
  addTag(tagType: TagType, tagId: string, value: AnyTagValue): Promise<void>;
27
27
  addStreamInfo(streamInfo: ITrackInfo): void;
28
28
  setAudioOnly(): void;
29
29
  }
30
30
  /**
31
- * Provided to the parser to uodate the metadata result.
32
- * Responsible for triggering async updates
31
+ * Provided to the parser to update the metadata result.
32
+ * Responsible for notifying observers synchronously after updates.
33
33
  */
34
34
  export declare class MetadataCollector implements INativeMetadataCollector {
35
35
  readonly format: IFormat;
@@ -52,7 +52,7 @@ export declare class MetadataCollector implements INativeMetadataCollector {
52
52
  */
53
53
  hasAny(): boolean;
54
54
  addStreamInfo(streamInfo: ITrackInfo): void;
55
- setFormat(key: FormatId, value: AnyTagValue): void;
55
+ setFormat<K extends keyof IFormat>(key: K, value: IFormat[K]): void;
56
56
  setAudioOnly(): void;
57
57
  registerTagType(tagType: TagType): void;
58
58
  addTag(tagType: TagType, tagId: string, value: AnyTagValue): Promise<void>;
@@ -2,6 +2,7 @@ import initDebug from 'debug';
2
2
  import { fileTypeFromBuffer } from 'file-type';
3
3
  import { parseLyrics } from '../lrc/LyricsParser.js';
4
4
  import { TrackTypeValueToKeyMap } from '../type.js';
5
+ import { getMaxMemoryAllocation } from './Allocation.js';
5
6
  import { CombinedTagMapper } from './CombinedTagMapper.js';
6
7
  import { CommonTagMapper } from './GenericTagMapper.js';
7
8
  import { isSingleton, isUnique } from './GenericTagTypes.js';
@@ -23,8 +24,8 @@ const TagPriority = [
23
24
  'ID3v1'
24
25
  ];
25
26
  /**
26
- * Provided to the parser to uodate the metadata result.
27
- * Responsible for triggering async updates
27
+ * Provided to the parser to update the metadata result.
28
+ * Responsible for notifying observers synchronously after updates.
28
29
  */
29
30
  export class MetadataCollector {
30
31
  format = {
@@ -73,8 +74,13 @@ export class MetadataCollector {
73
74
  this.format.trackInfo.push(streamInfo);
74
75
  }
75
76
  setFormat(key, value) {
77
+ // Objects may have been mutated in place, so only skip unchanged primitives.
78
+ if ((value === null || typeof value !== 'object') && Object.is(this.format[key], value)) {
79
+ return;
80
+ }
76
81
  debug(`format: ${key} = ${value}`);
77
- this.format[key] = value; // as any to override readonly
82
+ // Allow internal writes while preserving each property's value type.
83
+ this.format[key] = value;
78
84
  if (this.opts?.observer) {
79
85
  this.opts.observer({ metadata: this, tag: { type: 'format', id: key, value } });
80
86
  }
@@ -260,7 +266,9 @@ export class MetadataCollector {
260
266
  async postFixPicture(picture) {
261
267
  if (picture.data && picture.data.length > 0) {
262
268
  if (!picture.format) {
263
- const fileType = await fileTypeFromBuffer(Uint8Array.from(picture.data)); // ToDO: remove Buffer
269
+ const fileType = await fileTypeFromBuffer(Uint8Array.from(picture.data), {
270
+ maxBufferSize: getMaxMemoryAllocation(this.opts ?? {})
271
+ }); // ToDO: remove Buffer
264
272
  if (fileType) {
265
273
  picture.format = fileType.mime;
266
274
  }
@@ -1,7 +1,7 @@
1
1
  export const dsdiffParserLoader = {
2
2
  parserType: 'dsdiff',
3
3
  extensions: ['.dff'],
4
- mimeTypes: ['audio/dsf', 'audio/dsd'],
4
+ mimeTypes: ['audio/dsf', 'audio/dsd', 'audio/dff'],
5
5
  async load() {
6
6
  return (await import('./DsdiffParser.js')).DsdiffParser;
7
7
  }
@@ -1,10 +1,8 @@
1
1
  import initDebug from 'debug';
2
- import * as strtok3 from 'strtok3';
3
2
  import * as Token from 'token-types';
4
- import { getTokenizerOptions } from '../common/Allocation.js';
5
3
  import { BasicParser } from '../common/BasicParser.js';
6
4
  import { FourCcToken } from '../common/FourCC.js';
7
- import { ID3v2Parser } from '../id3v2/ID3v2Parser.js';
5
+ import { parseEmbeddedID3v2 } from '../id3v2/EmbeddedID3v2.js';
8
6
  import { makeUnexpectedFileContentError } from '../ParseError.js';
9
7
  import { ChunkHeader64 } from './DsdiffToken.js';
10
8
  const debug = initDebug('music-metadata:parser:dsdiff');
@@ -94,9 +92,7 @@ export class DsdiffParser extends BasicParser {
94
92
  }
95
93
  case 'ID3': {
96
94
  // Unofficial ID3 tag support
97
- const id3_data = await this.readToken(new Token.Uint8ArrayType(Number(header.chunkSize)));
98
- const rst = strtok3.fromBuffer(id3_data, getTokenizerOptions(this.options));
99
- await new ID3v2Parser().parse(this.metadata, rst, this.options);
95
+ await parseEmbeddedID3v2(this.metadata, this.tokenizer, this.options, Number(header.chunkSize));
100
96
  break;
101
97
  }
102
98
  case 'DSD':
@@ -64,7 +64,7 @@ export class DsfParser extends AbstractID3Parser {
64
64
  this.metadata.setFormat('numberOfChannels', formatChunk.channelNum);
65
65
  this.metadata.setFormat('sampleRate', formatChunk.samplingFrequency);
66
66
  this.metadata.setFormat('bitsPerSample', formatChunk.bitsPerSample);
67
- this.metadata.setFormat('numberOfSamples', formatChunk.sampleCount);
67
+ this.metadata.setFormat('numberOfSamples', Number(formatChunk.sampleCount));
68
68
  this.metadata.setFormat('duration', Number(formatChunk.sampleCount) / formatChunk.samplingFrequency);
69
69
  const bitrate = formatChunk.bitsPerSample * formatChunk.samplingFrequency * formatChunk.channelNum;
70
70
  this.metadata.setFormat('bitrate', bitrate);
@@ -1,6 +1,6 @@
1
1
  import { type ITokenizer } from 'strtok3';
2
2
  import type { IOptions } from '../type.js';
3
- import { type IElementType, type ITree, type ValueType } from './types.js';
3
+ import { type DecodedElement, type ElementEvent, type IElementContext, type IElementType, type ValueType } from './types.js';
4
4
  declare const EbmlContentError_base: {
5
5
  new (message: string): {
6
6
  message: string;
@@ -16,13 +16,8 @@ declare const EbmlContentError_base: {
16
16
  };
17
17
  export declare class EbmlContentError extends EbmlContentError_base {
18
18
  }
19
- export interface ILinkedElementType extends IElementType {
20
- id: number;
21
- parent: ILinkedElementType | undefined;
22
- readonly container?: {
23
- [id: number]: ILinkedElementType;
24
- };
25
- }
19
+ /** @deprecated Use the per-visit IElementContext instead. */
20
+ export type ILinkedElementType = IElementContext;
26
21
  export declare const ParseAction: {
27
22
  readonly ReadNext: 0;
28
23
  readonly IgnoreElement: 2;
@@ -31,12 +26,19 @@ export declare const ParseAction: {
31
26
  readonly SkipElement: 5;
32
27
  };
33
28
  export type ParseAction = (typeof ParseAction)[keyof typeof ParseAction];
34
- /**
35
- * @return true, to quit the parser
36
- */
37
- export type IElementListener = {
38
- startNext: (dtdElement: ILinkedElementType) => ParseAction;
39
- elementValue: (dtdElement: ILinkedElementType, value: ValueType, offset: number) => Promise<void>;
29
+ /** Traversal callbacks and optional subtree retention. */
30
+ export type IElementListener<S extends IElementType = IElementType> = {
31
+ startNext: (element: IElementContext) => ParseAction | Promise<ParseAction>;
32
+ /** Legacy untyped events; new consumers should use onElement. */
33
+ elementValue?: (element: IElementContext, value: ValueType, offset: number) => Promise<void>;
34
+ onElement?: (event: ElementEvent<S>) => Promise<void>;
35
+ /**
36
+ * Collect only subtrees rooted at matching containers. Descendants of a selected
37
+ * container are retained until its event completes; other ancestors stay empty.
38
+ * Omit this callback to collect the entire document (legacy iterator behavior).
39
+ * Leaf events are delivered even when their values are not retained.
40
+ */
41
+ collect?: (element: IElementContext) => boolean;
40
42
  };
41
43
  /**
42
44
  * Extensible Binary Meta Language (EBML) iterator
@@ -51,19 +53,22 @@ export declare class EbmlIterator {
51
53
  private ebmlMaxIDLength;
52
54
  private ebmlMaxSizeLength;
53
55
  private tokenizer;
56
+ private readonly elementHeader;
54
57
  /**
55
58
  * @param {ITokenizer} tokenizer Input
56
59
  * @param tokenizer
57
60
  */
58
61
  constructor(tokenizer: ITokenizer, options?: IOptions);
59
- iterate(dtdElement: IElementType, posDone: number, listener: IElementListener): Promise<ITree>;
62
+ iterate<const S extends IElementType>(dtdElement: S, posDone: number, listener: IElementListener<S>): Promise<DecodedElement<S>>;
60
63
  private parseContainer;
64
+ private collect;
61
65
  private ignore;
62
66
  private readVintData;
63
67
  private readElement;
64
68
  private readFloat;
65
69
  private readFlag;
66
70
  private readUint;
71
+ private readInt;
67
72
  private readString;
68
73
  private readBuffer;
69
74
  }
@@ -1,8 +1,8 @@
1
1
  import initDebug from 'debug';
2
- import { EndOfStreamError } from 'strtok3';
2
+ import { EndOfStreamError, isRandomAccessTokenizer } from 'strtok3';
3
3
  import * as Token from 'token-types';
4
4
  import { Float32_BE, Float64_BE, StringType, UINT8 } from 'token-types';
5
- import { readBuffer } from '../common/Allocation.js';
5
+ import { getMaxMemoryAllocation, readBuffer } from '../common/Allocation.js';
6
6
  import { makeUnexpectedFileContentError } from '../ParseError.js';
7
7
  import { DataType } from './types.js';
8
8
  const debug = initDebug('music-metadata:parser:ebml');
@@ -28,6 +28,7 @@ export class EbmlIterator {
28
28
  ebmlMaxIDLength = 4;
29
29
  ebmlMaxSizeLength = 8;
30
30
  tokenizer;
31
+ elementHeader;
31
32
  /**
32
33
  * @param {ITokenizer} tokenizer Input
33
34
  * @param tokenizer
@@ -35,7 +36,9 @@ export class EbmlIterator {
35
36
  constructor(tokenizer, options = {}) {
36
37
  this.options = options;
37
38
  this.tokenizer = tokenizer;
39
+ this.elementHeader = new Uint8Array(Math.min(12, getMaxMemoryAllocation(options)));
38
40
  this.parserMap.set(DataType.uint, e => this.readUint(e));
41
+ this.parserMap.set(DataType.int, e => this.readInt(e));
39
42
  this.parserMap.set(DataType.string, e => this.readString(e));
40
43
  this.parserMap.set(DataType.binary, e => this.readBuffer(e));
41
44
  this.parserMap.set(DataType.uid, async (e) => this.readBuffer(e));
@@ -43,9 +46,20 @@ export class EbmlIterator {
43
46
  this.parserMap.set(DataType.float, e => this.readFloat(e));
44
47
  }
45
48
  async iterate(dtdElement, posDone, listener) {
46
- return this.parseContainer(linkParents(dtdElement), posDone, listener);
49
+ const root = { ...dtdElement, id: 0, parent: undefined };
50
+ const tree = await this.parseContainer(root, posDone, {
51
+ startNext: listener.startNext,
52
+ collect: listener.collect ?? (() => true),
53
+ elementValue: async (element, value, offset) => {
54
+ await listener.elementValue?.(element, value, offset);
55
+ // The schema selects both the decoder and the event ID. Keep this bridge here,
56
+ // rather than asserting unrelated domain types in each parser listener.
57
+ await listener.onElement?.({ id: element.id, element, value, offset });
58
+ }
59
+ }, listener.collect?.(root) ?? true);
60
+ return tree;
47
61
  }
48
- async parseContainer(dtdElement, posDone, listener) {
62
+ async parseContainer(dtdElement, posDone, listener, collect) {
49
63
  const tree = {};
50
64
  while (this.tokenizer.position < posDone) {
51
65
  let element;
@@ -59,34 +73,24 @@ export class EbmlIterator {
59
73
  }
60
74
  throw error;
61
75
  }
62
- const child = dtdElement.container[element.id];
63
- if (child) {
64
- const action = listener.startNext(child);
76
+ const definition = dtdElement.container?.[element.id];
77
+ if (definition) {
78
+ const child = { ...definition, id: element.id, parent: dtdElement };
79
+ const action = await listener.startNext(child);
65
80
  switch (action) {
66
81
  case ParseAction.ReadNext:
67
82
  {
68
- if (element.id === 0x1f43b675) {
69
- // Hack to ignore remaining segment, when cluster element received
70
- // await this.tokenizer.ignore(posDone - this.tokenizer.position);
71
- // break;
72
- }
73
83
  debug(`Read element: name=${getElementPath(child)}{id=0x${element.id.toString(16)}, container=${!!child.container}} at position=${elementPosition}`);
74
84
  if (child.container) {
75
85
  const childEnd = Math.min(posDone, this.tokenizer.fileInfo.size ?? Number.POSITIVE_INFINITY, element.len >= 0 ? this.tokenizer.position + element.len : posDone);
76
- const res = await this.parseContainer(child, childEnd, listener);
77
- if (child.multiple) {
78
- if (!tree[child.name]) {
79
- tree[child.name] = [];
80
- }
81
- tree[child.name].push(res);
82
- }
83
- else {
84
- tree[child.name] = res;
86
+ const res = await this.parseContainer(child, childEnd, listener, collect || listener.collect(child));
87
+ if (collect) {
88
+ this.collect(tree, child, res);
85
89
  }
86
90
  await listener.elementValue(child, res, elementPosition);
87
91
  }
88
92
  else {
89
- const parser = this.parserMap.get(child.value);
93
+ const parser = child.value === undefined ? undefined : this.parserMap.get(child.value);
90
94
  if (typeof parser === 'function') {
91
95
  // Validate before leaf readers allocate buffers or string tokens from the declared length.
92
96
  const end = Math.min(posDone, this.tokenizer.fileInfo.size ?? Number.POSITIVE_INFINITY);
@@ -96,9 +100,14 @@ export class EbmlIterator {
96
100
  throw new EbmlContentError(`Invalid element length: ${element.len}`);
97
101
  }
98
102
  const value = await parser(element);
99
- tree[child.name] = value;
103
+ if (collect) {
104
+ this.collect(tree, child, value);
105
+ }
100
106
  await listener.elementValue(child, value, elementPosition);
101
107
  }
108
+ else {
109
+ await this.ignore(element.len, posDone);
110
+ }
102
111
  }
103
112
  }
104
113
  break;
@@ -131,6 +140,17 @@ export class EbmlIterator {
131
140
  }
132
141
  return tree;
133
142
  }
143
+ collect(tree, element, value) {
144
+ if (element.multiple) {
145
+ const values = (tree[element.name] ??= []);
146
+ if (Array.isArray(values)) {
147
+ values.push(value);
148
+ }
149
+ }
150
+ else {
151
+ tree[element.name] = value;
152
+ }
153
+ }
134
154
  async ignore(length, posDone) {
135
155
  // Unknown-size EBML containers may advertise lengths above the safe integer range.
136
156
  // Bound skips by the enclosing container and known input before passing them to the tokenizer.
@@ -158,6 +178,33 @@ export class EbmlIterator {
158
178
  return id;
159
179
  }
160
180
  async readElement() {
181
+ if (isRandomAccessTokenizer(this.tokenizer) && this.elementHeader.length === 12) {
182
+ // RFC 8794 (July 2020), sections 4-6: an ID and size occupy at most 4 + 8 bytes.
183
+ // Peek both VINTs together to avoid four separate file/network reads per element.
184
+ // https://www.rfc-editor.org/rfc/rfc8794.html#section-4
185
+ const available = await this.tokenizer.peekBuffer(this.elementHeader, { mayBeLess: true });
186
+ const width = (offset, maximum) => {
187
+ if (offset >= available) {
188
+ throw new EndOfStreamError();
189
+ }
190
+ for (let length = 1; length <= maximum; ++length) {
191
+ if ((this.elementHeader[offset] & (0x80 >> (length - 1))) !== 0) {
192
+ if (offset + length > available) {
193
+ throw new EndOfStreamError();
194
+ }
195
+ return length;
196
+ }
197
+ }
198
+ throw new EbmlContentError('VINT value exceeding maximum size');
199
+ };
200
+ const idLength = width(0, this.ebmlMaxIDLength);
201
+ const sizeLength = width(idLength, this.ebmlMaxSizeLength);
202
+ const id = readUIntBE(this.elementHeader.subarray(0, idLength), idLength);
203
+ const lenField = this.elementHeader.subarray(idLength, idLength + sizeLength);
204
+ lenField[0] ^= 0x80 >> (sizeLength - 1);
205
+ this.tokenizer.setPosition(this.tokenizer.position + idLength + sizeLength);
206
+ return { id, len: readUIntBE(lenField, sizeLength) };
207
+ }
161
208
  const id = await this.readVintData(this.ebmlMaxIDLength);
162
209
  const lenField = await this.readVintData(this.ebmlMaxSizeLength);
163
210
  lenField[0] ^= 0x80 >> (lenField.length - 1);
@@ -187,6 +234,11 @@ export class EbmlIterator {
187
234
  const buf = await this.readBuffer(e);
188
235
  return readUIntBE(buf, e.len);
189
236
  }
237
+ async readInt(e) {
238
+ const buffer = await this.readBuffer(e);
239
+ const value = readUIntBeAsBigInt(buffer, e.len);
240
+ return Number(buffer[0] & 0x80 ? value - (1n << BigInt(8 * e.len)) : value);
241
+ }
190
242
  async readString(e) {
191
243
  const buf = await this.readBuffer(e);
192
244
  const rawString = new StringType(e.len, 'utf-8').get(buf, 0);
@@ -216,21 +268,6 @@ function readUIntBeAsBigInt(buf, len) {
216
268
  return BigInt(-1);
217
269
  }
218
270
  }
219
- function linkParents(element) {
220
- if (element.container) {
221
- Object.keys(element.container)
222
- .map(id => {
223
- const child = element.container[id];
224
- child.id = Number.parseInt(id, 10);
225
- return child;
226
- })
227
- .forEach(child => {
228
- child.parent = element;
229
- linkParents(child);
230
- });
231
- }
232
- return element;
233
- }
234
271
  export function getElementPath(element) {
235
272
  let path = '';
236
273
  if (element.parent && element.parent.name !== 'dtd') {
@@ -1,5 +1,6 @@
1
+ export type PrimitiveValue = string | number | boolean | Uint8Array;
1
2
  export interface ITree {
2
- [name: string]: string | number | boolean | Uint8Array | ITree | ITree[];
3
+ [name: string]: PrimitiveValue | ITree | (PrimitiveValue | ITree)[];
3
4
  }
4
5
  export declare const DataType: {
5
6
  readonly string: 0;
@@ -8,18 +9,19 @@ export declare const DataType: {
8
9
  readonly bool: 3;
9
10
  readonly binary: 4;
10
11
  readonly float: 5;
12
+ readonly int: 6;
11
13
  };
12
14
  export type DataType = (typeof DataType)[keyof typeof DataType];
13
- export type ValueType = string | number | Uint8Array | boolean | ITree | ITree[];
15
+ export type ValueType = PrimitiveValue | ITree;
14
16
  export interface IHeader {
15
17
  id: number;
16
18
  len: number;
17
19
  }
18
20
  export interface IEbmlElements {
19
- version?: number;
20
- readVersion?: number;
21
- maxIDWidth?: number;
22
- maxSizeWidth?: number;
21
+ ebmlVersion?: number;
22
+ ebmlReadVersion?: number;
23
+ ebmlMaxIDWidth?: number;
24
+ ebmlMaxSizeWidth?: number;
23
25
  docType?: string;
24
26
  docTypeVersion?: number;
25
27
  docTypeReadVersion?: number;
@@ -28,10 +30,36 @@ export interface IElementType {
28
30
  readonly name: string;
29
31
  readonly value?: DataType;
30
32
  readonly container?: {
31
- [id: number]: IElementType;
33
+ readonly [id: number]: IElementType;
32
34
  };
33
35
  readonly multiple?: boolean;
34
36
  }
35
37
  export interface IEbmlDoc {
36
- ebml: IEbmlElements;
38
+ ebml?: IEbmlElements;
37
39
  }
40
+ /** Values reflect what was actually read: even required EBML elements can be absent or skipped. */
41
+ export type DecodedElement<S extends IElementType> = S extends {
42
+ readonly container: infer C extends Readonly<Record<number, IElementType>>;
43
+ } ? {
44
+ -readonly [E in C[keyof C & number] as E['name']]?: E extends {
45
+ readonly multiple: true;
46
+ } ? DecodedElement<E>[] : DecodedElement<E>;
47
+ } : S extends {
48
+ readonly value: infer V extends DataType;
49
+ } ? V extends typeof DataType.string ? string : V extends typeof DataType.bool ? boolean : V extends typeof DataType.binary | typeof DataType.uid ? Uint8Array : number : ITree;
50
+ /** Per-visit context; the schema itself never gains IDs or parent links. */
51
+ export interface IElementContext extends IElementType {
52
+ readonly id: number;
53
+ readonly parent: IElementContext | undefined;
54
+ }
55
+ /** Discriminating by ID narrows the decoded value along with the element. */
56
+ export type ElementEvent<S extends IElementType> = S extends {
57
+ readonly container: infer C extends Readonly<Record<number, IElementType>>;
58
+ } ? {
59
+ [Id in keyof C & number]: {
60
+ id: Id;
61
+ element: IElementContext;
62
+ value: DecodedElement<C[Id]>;
63
+ offset: number;
64
+ } | ElementEvent<C[Id]>;
65
+ }[keyof C & number] : never;
package/lib/ebml/types.js CHANGED
@@ -4,5 +4,6 @@ export const DataType = {
4
4
  uid: 2,
5
5
  bool: 3,
6
6
  binary: 4,
7
- float: 5
7
+ float: 5,
8
+ int: 6
8
9
  };
@@ -92,24 +92,16 @@ export class FlacParser extends AbstractID3Parser {
92
92
  */
93
93
  async parseComment(data) {
94
94
  const decoder = new VorbisDecoder(data, 0);
95
- const vendor = decoder.readStringUtf8();
95
+ const { vendor, comments } = decoder.parseCommentList(message => new FlacContentError(message));
96
96
  if (vendor.length > 0) {
97
97
  this.metadata.setFormat('tool', vendor);
98
98
  }
99
- const commentListLength = decoder.readInt32();
100
- if (commentListLength > Math.floor(decoder.remaining / 4)) {
101
- throw new FlacContentError(`Vorbis comment count ${commentListLength} exceeds remaining data`);
102
- }
103
- const tags = [];
104
- for (let i = 0; i < commentListLength; i++) {
105
- tags.push(decoder.parseUserComment());
106
- }
107
- await Promise.all(tags.map(tag => {
99
+ for (const tag of comments) {
108
100
  if (tag.key === 'ENCODER') {
109
101
  this.metadata.setFormat('tool', tag.value);
110
102
  }
111
- return this.addTag(tag.key, tag.value);
112
- }));
103
+ await this.addTag(tag.key, tag.value);
104
+ }
113
105
  }
114
106
  async parsePicture(dataLen) {
115
107
  if (this.options.skipCovers) {
@@ -1,4 +1,4 @@
1
- import type { IRandomAccessTokenizer, ITokenizer } from 'strtok3';
1
+ import { type IRandomAccessTokenizer, type ITokenizer } from 'strtok3';
2
2
  import { BasicParser } from '../common/BasicParser.js';
3
3
  import type { INativeMetadataCollector } from '../common/MetadataCollector.js';
4
4
  import type { IPrivateOptions } from '../type.js';
@@ -1,5 +1,6 @@
1
1
  import { textDecode } from '@borewit/text-codec';
2
2
  import initDebug from 'debug';
3
+ import { isRandomAccessTokenizer } from 'strtok3';
3
4
  import { StringType, UINT8 } from 'token-types';
4
5
  import { APEv2Parser } from '../apev2/APEv2Parser.js';
5
6
  import { BasicParser } from '../common/BasicParser.js';
@@ -266,7 +267,7 @@ export class ID3v1Parser extends BasicParser {
266
267
  debug('Skip checking for ID3v1 because the file-size is unknown');
267
268
  return;
268
269
  }
269
- if (this.apeHeader && this.tokenizer.supportsRandomAccess()) {
270
+ if (this.apeHeader && isRandomAccessTokenizer(this.tokenizer)) {
270
271
  this.tokenizer.setPosition(this.apeHeader.offset);
271
272
  const apeParser = new APEv2Parser(this.metadata, this.tokenizer, this.options);
272
273
  await apeParser.parseTags(this.apeHeader.footer);