music-metadata 11.10.6 → 11.11.0

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
@@ -1,12 +1,11 @@
1
1
  [![CI](https://github.com/Borewit/music-metadata/actions/workflows/ci.yml/badge.svg)](https://github.com/Borewit/music-metadata/actions/workflows/ci.yml)
2
2
  [![NPM version](https://img.shields.io/npm/v/music-metadata.svg)](https://npmjs.org/package/music-metadata)
3
- [![npm downloads](http://img.shields.io/npm/dm/music-metadata.svg)](https://npmcharts.com/compare/music-metadata?start=600&interval=30)
3
+ [![npm downloads](http://img.shields.io/npm/dm/music-metadata.svg)](https://npmcharts.com/compare/music-metadata?start=600&interval=7)
4
4
  [![Coverage Status](https://coveralls.io/repos/github/Borewit/music-metadata/badge.svg?branch=master)](https://coveralls.io/github/Borewit/music-metadata?branch=master)
5
5
  [![Codacy Badge](https://api.codacy.com/project/badge/Grade/57d731b05c9e41889a2a17cb4b0384d7)](https://app.codacy.com/app/Borewit/music-metadata?utm_source=github.com&utm_medium=referral&utm_content=Borewit/music-metadata&utm_campaign=Badge_Grade_Dashboard)
6
6
  [![CodeQL](https://github.com/Borewit/music-metadata/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/Borewit/music-metadata/actions/workflows/codeql-analysis.yml)
7
7
  [![DeepScan grade](https://deepscan.io/api/teams/5165/projects/6938/branches/61821/badge/grade.svg)](https://deepscan.io/dashboard#view=project&tid=5165&pid=6938&bid=61821)
8
8
  [![Known Vulnerabilities](https://snyk.io/test/github/Borewit/music-metadata/badge.svg?targetFile=package.json)](https://snyk.io/test/github/Borewit/music-metadata?targetFile=package.json)
9
- [![Discord](https://img.shields.io/discord/460524735235883049.svg)](https://discord.gg/KyBr6sb)
10
9
 
11
10
  # music-metadata
12
11
 
@@ -74,7 +73,7 @@ Following tag header formats are supported:
74
73
  - [APE](https://wikipedia.org/wiki/APE_tag)
75
74
  - [ASF](https://wikipedia.org/wiki/Advanced_Systems_Format)
76
75
  - EXIF 2.3
77
- - [ID3](https://wikipedia.org/wiki/ID3): ID3v1, ID3v1.1, ID3v2.2, [ID3v2.3](http://id3.org/id3v2.3.0) & [ID3v2.4](http://id3.org/id3v2.4.0-frames)
76
+ - [ID3](https://wikipedia.org/wiki/ID3): ID3v1, ID3v1.1, ID3v2.2, [ID3v2.3](http://id3.org/id3v2.3.0), [ID3v2.4](http://id3.org/id3v2.4.0-frames) and [ID3v2 Chapters 1.0](https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2-chapters-1.0.html)
78
77
  - [iTunes](https://github.com/sergiomb2/libmp4v2/wiki/iTunesMetadata)
79
78
  - [RIFF](https://wikipedia.org/wiki/Resource_Interchange_File_Format)/INFO
80
79
  - [Vorbis comment](https://wikipedia.org/wiki/Vorbis_comment)
@@ -1,5 +1,6 @@
1
1
  import { type ID3v2MajorVersion, type ITextEncoding } from './ID3v2Token.js';
2
2
  import type { IWarningCollector } from '../common/MetadataCollector.js';
3
+ import { type IChapterInfo } from './ID3v2ChapterToken.js';
3
4
  interface ICustomTag {
4
5
  owner_identifier: string;
5
6
  }
@@ -24,6 +25,22 @@ export interface IGeneralEncapsulatedObject {
24
25
  description: string;
25
26
  data: Uint8Array;
26
27
  }
28
+ export type Chapter = {
29
+ label: string;
30
+ info: IChapterInfo;
31
+ frames: Map<string, unknown>;
32
+ };
33
+ export type TableOfContents = {
34
+ label: string;
35
+ flags: {
36
+ /** If set, this is the top-level table of contents */
37
+ topLevel: boolean;
38
+ /** If set, the child element IDs are in a defined order */
39
+ ordered: boolean;
40
+ };
41
+ childElementIds: string[];
42
+ frames: Map<string, unknown>;
43
+ };
27
44
  export declare function parseGenre(origVal: string): string[];
28
45
  export declare class FrameParser {
29
46
  private major;
@@ -5,6 +5,8 @@ import { AttachedPictureType, SyncTextHeader, TextEncodingToken, TextHeader } fr
5
5
  import { Genres } from '../id3v1/ID3v1Parser.js';
6
6
  import { makeUnexpectedFileContentError } from '../ParseError.js';
7
7
  import { decodeUintBE } from '../common/Util.js';
8
+ import { ChapterInfo } from './ID3v2ChapterToken.js';
9
+ import { getFrameHeaderLength, readFrameHeader } from './FrameHeader.js';
8
10
  const debug = initDebug('music-metadata:id3v2:frame-parser');
9
11
  const defaultEnc = 'latin1'; // latin1 == iso-8859-1;
10
12
  const urlEnc = { encoding: defaultEnc, bom: false };
@@ -109,6 +111,9 @@ export class FrameParser {
109
111
  case 'TRK':
110
112
  case 'TRCK':
111
113
  case 'TPOS':
114
+ case 'TIT1':
115
+ case 'TIT2':
116
+ case 'TIT3':
112
117
  output = text;
113
118
  break;
114
119
  case 'TCOM':
@@ -137,11 +142,10 @@ export class FrameParser {
137
142
  }
138
143
  case 'TXXX': {
139
144
  const idAndData = FrameParser.readIdentifierAndData(uint8Array.subarray(1), encoding);
140
- const textTag = {
145
+ output = {
141
146
  description: idAndData.id,
142
147
  text: this.splitValue(type, util.decodeString(idAndData.data, encoding).replace(/\x00+$/, ''))
143
148
  };
144
- output = textTag;
145
149
  break;
146
150
  }
147
151
  case 'PIC':
@@ -310,6 +314,67 @@ export class FrameParser {
310
314
  output = uint8Array.subarray(0, length);
311
315
  break;
312
316
  }
317
+ // ID3v2 Chapters 1.0
318
+ // https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2-chapters-1.0.html#chapter-frame
319
+ case 'CHAP': { // // Chapter frame
320
+ debug("Reading CHAP");
321
+ fzero = util.findZero(uint8Array, defaultEnc);
322
+ const chapter = {
323
+ label: util.decodeString(uint8Array.subarray(0, fzero), defaultEnc),
324
+ info: ChapterInfo.get(uint8Array, fzero + 1),
325
+ frames: new Map()
326
+ };
327
+ offset += fzero + 1 + ChapterInfo.len;
328
+ while (offset < length) {
329
+ const subFrame = readFrameHeader(uint8Array.subarray(offset), this.major, this.warningCollector);
330
+ const headerSize = getFrameHeaderLength(this.major);
331
+ offset += headerSize;
332
+ const subOutput = this.readData(uint8Array.subarray(offset, offset + subFrame.length), subFrame.id, includeCovers);
333
+ offset += subFrame.length;
334
+ chapter.frames.set(subFrame.id, subOutput);
335
+ }
336
+ output = chapter;
337
+ break;
338
+ }
339
+ // ID3v2 Chapters 1.0
340
+ // https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2-chapters-1.0.html#table-of-contents-frame
341
+ case 'CTOC': { // Table of contents frame
342
+ debug('Reading CTOC');
343
+ // Element ID (null-terminated latin1)
344
+ const idEnd = util.findZero(uint8Array, defaultEnc);
345
+ const label = util.decodeString(uint8Array.subarray(0, idEnd), defaultEnc);
346
+ offset = idEnd + 1;
347
+ // Flags
348
+ const flags = uint8Array[offset++];
349
+ const topLevel = (flags & 0x02) !== 0;
350
+ const ordered = (flags & 0x01) !== 0;
351
+ // Child element IDs
352
+ const entryCount = uint8Array[offset++];
353
+ const childElementIds = [];
354
+ for (let i = 0; i < entryCount && offset < length; i++) {
355
+ const end = util.findZero(uint8Array.subarray(offset), defaultEnc);
356
+ const childId = util.decodeString(uint8Array.subarray(offset, offset + end), defaultEnc);
357
+ childElementIds.push(childId);
358
+ offset += end + 1;
359
+ }
360
+ const toc = {
361
+ label,
362
+ flags: { topLevel, ordered },
363
+ childElementIds,
364
+ frames: new Map()
365
+ };
366
+ // Optional embedded sub-frames (e.g. TIT2) follow after the child list
367
+ while (offset < length) {
368
+ const subFrame = readFrameHeader(uint8Array.subarray(offset), this.major, this.warningCollector);
369
+ const headerSize = getFrameHeaderLength(this.major);
370
+ offset += headerSize;
371
+ const subOutput = this.readData(uint8Array.subarray(offset, offset + subFrame.length), subFrame.id, includeCovers);
372
+ offset += subFrame.length;
373
+ toc.frames.set(subFrame.id, subOutput);
374
+ }
375
+ output = toc;
376
+ break;
377
+ }
313
378
  default:
314
379
  debug(`Warning: unsupported id3v2-tag-type: ${type}`);
315
380
  break;
@@ -0,0 +1,11 @@
1
+ import type { IGetToken } from 'strtok3';
2
+ export interface IChapterInfo {
3
+ startTime: number;
4
+ endTime: number;
5
+ startOffset?: number;
6
+ endOffset?: number;
7
+ }
8
+ /**
9
+ * Data portion of `CHAP` sub frame
10
+ */
11
+ export declare const ChapterInfo: IGetToken<IChapterInfo>;
@@ -0,0 +1,17 @@
1
+ import * as Token from 'token-types';
2
+ /**
3
+ * Data portion of `CHAP` sub frame
4
+ */
5
+ export const ChapterInfo = {
6
+ len: 16,
7
+ get: (buf, off) => {
8
+ const startOffset = Token.UINT32_BE.get(buf, off + 8);
9
+ const endOffset = Token.UINT32_BE.get(buf, off + 12);
10
+ return {
11
+ startTime: Token.UINT32_BE.get(buf, off),
12
+ endTime: Token.UINT32_BE.get(buf, off + 4),
13
+ startOffset: startOffset === 0xFFFFFFFF ? undefined : startOffset,
14
+ endOffset: endOffset === 0xFFFFFFFF ? undefined : endOffset,
15
+ };
16
+ }
17
+ };
@@ -23,4 +23,11 @@ export declare class ID3v2Parser {
23
23
  private handleTag;
24
24
  private addTag;
25
25
  private parseMetadata;
26
+ /**
27
+ * Convert parsed ID3v2 chapter frames (CHAP / CTOC) to generic `format.chapters`.
28
+ *
29
+ * This function expects the `native` tags already to contain parsed `CHAP` and `CTOC` frame values,
30
+ * as produced by `FrameParser.readData`.
31
+ */
32
+ private static mapId3v2Chapters;
26
33
  }
@@ -62,7 +62,10 @@ export class ID3v2Parser {
62
62
  }
63
63
  this.id3Header = id3Header;
64
64
  this.headerType = (`ID3v2.${id3Header.version.major}`);
65
- return id3Header.flags.isExtendedHeader ? this.parseExtendedHeader() : this.parseId3Data(id3Header.size);
65
+ await (id3Header.flags.isExtendedHeader ? this.parseExtendedHeader() : this.parseId3Data(id3Header.size));
66
+ // Post process
67
+ const chapters = ID3v2Parser.mapId3v2Chapters(this.metadata.native[this.headerType], this.metadata.format.sampleRate);
68
+ this.metadata.setFormat('chapters', chapters);
66
69
  }
67
70
  async parseExtendedHeader() {
68
71
  const extendedHeader = await this.tokenizer.readToken(ExtendedHeader);
@@ -116,6 +119,48 @@ export class ID3v2Parser {
116
119
  }
117
120
  return tags;
118
121
  }
122
+ /**
123
+ * Convert parsed ID3v2 chapter frames (CHAP / CTOC) to generic `format.chapters`.
124
+ *
125
+ * This function expects the `native` tags already to contain parsed `CHAP` and `CTOC` frame values,
126
+ * as produced by `FrameParser.readData`.
127
+ */
128
+ static mapId3v2Chapters(id3Tags, sampleRate) {
129
+ const chapFrames = id3Tags.filter(t => t.id === 'CHAP');
130
+ if (!chapFrames?.length)
131
+ return;
132
+ const tocFrames = id3Tags.filter(t => t.id === 'CTOC');
133
+ const topLevelToc = tocFrames?.find(t => t.value.flags?.topLevel);
134
+ const chapterById = new Map();
135
+ for (const chap of chapFrames) {
136
+ chapterById.set(chap.value.label, chap.value);
137
+ }
138
+ const orderedIds = topLevelToc?.value.childElementIds;
139
+ const chapters = [];
140
+ const source = orderedIds ?? [...chapterById.keys()];
141
+ for (const id of source) {
142
+ const chap = chapterById.get(id);
143
+ if (!chap)
144
+ continue;
145
+ const frames = chap.frames;
146
+ const title = frames.get('TIT2');
147
+ if (!title)
148
+ continue; // title is required
149
+ chapters.push({
150
+ id,
151
+ title,
152
+ url: frames.get('WXXX'),
153
+ start: chap.info.startTime / 1000,
154
+ end: chap.info.endTime / 1000,
155
+ image: frames.get('APIC')
156
+ });
157
+ }
158
+ // If no ordered CTOC, sort by time
159
+ if (!orderedIds) {
160
+ chapters.sort((a, b) => a.start - b.start);
161
+ }
162
+ return chapters.length ? chapters : undefined;
163
+ }
119
164
  }
120
165
  function makeUnexpectedMajorVersionError(majorVer) {
121
166
  throw new Id3v2ContentError(`Unexpected majorVer: ${majorVer}`);
@@ -57,10 +57,7 @@ export type TimestampFormat = typeof TimestampFormat[keyof typeof TimestampForma
57
57
  * 28 bits (representing up to 256MB) integer, the msb is 0 to avoid 'false syncsignals'.
58
58
  * 4 * %0xxxxxxx
59
59
  */
60
- export declare const UINT32SYNCSAFE: {
61
- get: (buf: Uint8Array, off: number) => number;
62
- len: number;
63
- };
60
+ export declare const UINT32SYNCSAFE: IGetToken<number>;
64
61
  /**
65
62
  * ID3v2 tag header
66
63
  */
package/lib/type.d.ts CHANGED
@@ -484,25 +484,46 @@ export interface ITag {
484
484
  id: string;
485
485
  value: AnyTagValue;
486
486
  }
487
+ export interface IUrl {
488
+ url: string;
489
+ description: string;
490
+ }
487
491
  export interface IChapter {
492
+ /**
493
+ * Internal chapter reference
494
+ */
495
+ id?: string;
488
496
  /**
489
497
  * Chapter title
490
498
  */
491
499
  title: string;
500
+ /**
501
+ * URL
502
+ */
503
+ url?: IUrl;
492
504
  /**
493
505
  * Audio offset in sample number, 0 is the first sample.
494
506
  * Duration offset is sampleOffset / format.sampleRate
495
507
  */
496
- sampleOffset: number;
508
+ sampleOffset?: number;
497
509
  /**
498
510
  * Timestamp where the chapter starts
499
511
  * Chapter timestamp is start/timeScale in seconds.
500
512
  */
501
513
  start: number;
502
514
  /**
503
- * Time value that indicates the time scale for chapter tracks, the number of time units that pass per second in its time coordinate system.
515
+ * Timestamp where the chapter end
516
+ * Chapter timestamp is start/timeScale in seconds.
517
+ */
518
+ end?: number;
519
+ /**
520
+ * Time value that indicates the timescale for chapter tracks, the number of time units that pass per second in its time coordinate system.
521
+ */
522
+ timeScale?: number;
523
+ /**
524
+ * Picture
504
525
  */
505
- timeScale: number;
526
+ image?: IPicture;
506
527
  }
507
528
  /**
508
529
  * Flat list of tags
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "music-metadata",
3
3
  "description": "Music metadata parser for Node.js, supporting virtual any audio and tag format.",
4
- "version": "11.10.6",
4
+ "version": "11.11.0",
5
5
  "author": {
6
6
  "name": "Borewit",
7
7
  "url": "https://github.com/Borewit"
@@ -85,7 +85,9 @@
85
85
  "parser",
86
86
  "bwf",
87
87
  "slt",
88
- "lyrics"
88
+ "lyrics",
89
+ "Chapters",
90
+ "ID3v2 Chapters"
89
91
  ],
90
92
  "scripts": {
91
93
  "clean": "del-cli 'lib/**/*.js' 'lib/**/*.js.map' 'lib/**/*.d.ts' 'test/**/*.js' 'test/**/*.js.map' 'test/**/*.js' 'test/**/*.js.map' 'doc-gen/**/*.js' 'doc-gen/**/*.js.map'",
@@ -115,7 +117,7 @@
115
117
  "strtok3": "^10.3.4",
116
118
  "token-types": "^6.1.2",
117
119
  "uint8array-extras": "^1.5.0",
118
- "win-guid": "^0.1.3"
120
+ "win-guid": "^0.2.0"
119
121
  },
120
122
  "devDependencies": {
121
123
  "@biomejs/biome": "2.3.11",
@@ -125,7 +127,7 @@
125
127
  "@types/debug": "^4.1.12",
126
128
  "@types/media-typer": "^1.1.3",
127
129
  "@types/mocha": "^10.0.10",
128
- "@types/node": "^25.0.3",
130
+ "@types/node": "^25.0.10",
129
131
  "c8": "^10.1.3",
130
132
  "chai": "^6.2.2",
131
133
  "chai-as-promised": "^8.0.2",