music-metadata 11.10.6 → 11.11.1
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 +4 -3
- package/lib/id3v2/FrameParser.d.ts +17 -0
- package/lib/id3v2/FrameParser.js +67 -2
- package/lib/id3v2/ID3v2ChapterToken.d.ts +11 -0
- package/lib/id3v2/ID3v2ChapterToken.js +17 -0
- package/lib/id3v2/ID3v2Parser.d.ts +7 -0
- package/lib/id3v2/ID3v2Parser.js +48 -1
- package/lib/id3v2/ID3v2Token.d.ts +1 -4
- package/lib/type.d.ts +24 -3
- package/package.json +7 -5
package/README.md
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
[](https://github.com/Borewit/music-metadata/actions/workflows/ci.yml)
|
|
2
2
|
[](https://npmjs.org/package/music-metadata)
|
|
3
|
-
[](https://npmcharts.com/compare/music-metadata?start=600&interval=
|
|
3
|
+
[](https://npmcharts.com/compare/music-metadata?start=600&interval=7)
|
|
4
4
|
[](https://coveralls.io/github/Borewit/music-metadata?branch=master)
|
|
5
5
|
[](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
|
[](https://github.com/Borewit/music-metadata/actions/workflows/codeql-analysis.yml)
|
|
7
7
|
[](https://deepscan.io/dashboard#view=project&tid=5165&pid=6938&bid=61821)
|
|
8
8
|
[](https://snyk.io/test/github/Borewit/music-metadata?targetFile=package.json)
|
|
9
|
-
[](https://discord.gg/KyBr6sb)
|
|
10
9
|
|
|
11
10
|
# music-metadata
|
|
12
11
|
|
|
12
|
+
<img src="image/logo-music-metadata.jpg" width="60%" style="display: block; margin: auto;" alt="logo">
|
|
13
|
+
|
|
13
14
|
Key features:
|
|
14
15
|
- **Comprehensive Format Support**: Supports popular audio formats like MP3, MP4, FLAC, Ogg, WAV, AIFF, and more.
|
|
15
16
|
- **Extensive Metadata Extraction**: Extracts detailed metadata, including ID3v1, ID3v2, APE, Vorbis, and iTunes/MP4 tags.
|
|
@@ -74,7 +75,7 @@ Following tag header formats are supported:
|
|
|
74
75
|
- [APE](https://wikipedia.org/wiki/APE_tag)
|
|
75
76
|
- [ASF](https://wikipedia.org/wiki/Advanced_Systems_Format)
|
|
76
77
|
- EXIF 2.3
|
|
77
|
-
- [ID3](https://wikipedia.org/wiki/ID3): ID3v1, ID3v1.1, ID3v2.2, [ID3v2.3](http://id3.org/id3v2.3.0)
|
|
78
|
+
- [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
79
|
- [iTunes](https://github.com/sergiomb2/libmp4v2/wiki/iTunesMetadata)
|
|
79
80
|
- [RIFF](https://wikipedia.org/wiki/Resource_Interchange_File_Format)/INFO
|
|
80
81
|
- [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;
|
package/lib/id3v2/FrameParser.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/id3v2/ID3v2Parser.js
CHANGED
|
@@ -62,7 +62,10 @@ export class ID3v2Parser {
|
|
|
62
62
|
}
|
|
63
63
|
this.id3Header = id3Header;
|
|
64
64
|
this.headerType = (`ID3v2.${id3Header.version.major}`);
|
|
65
|
-
|
|
65
|
+
await (id3Header.flags.isExtendedHeader ? this.parseExtendedHeader() : this.parseId3Data(id3Header.size));
|
|
66
|
+
// Post process
|
|
67
|
+
const chapters = ID3v2Parser.mapId3v2Chapters(this.metadata.native[this.headerType]);
|
|
68
|
+
this.metadata.setFormat('chapters', chapters);
|
|
66
69
|
}
|
|
67
70
|
async parseExtendedHeader() {
|
|
68
71
|
const extendedHeader = await this.tokenizer.readToken(ExtendedHeader);
|
|
@@ -116,6 +119,50 @@ 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) {
|
|
129
|
+
if (!id3Tags)
|
|
130
|
+
return;
|
|
131
|
+
const chapFrames = id3Tags.filter(t => t.id === 'CHAP');
|
|
132
|
+
if (!chapFrames?.length)
|
|
133
|
+
return;
|
|
134
|
+
const tocFrames = id3Tags.filter(t => t.id === 'CTOC');
|
|
135
|
+
const topLevelToc = tocFrames?.find(t => t.value.flags?.topLevel);
|
|
136
|
+
const chapterById = new Map();
|
|
137
|
+
for (const chap of chapFrames) {
|
|
138
|
+
chapterById.set(chap.value.label, chap.value);
|
|
139
|
+
}
|
|
140
|
+
const orderedIds = topLevelToc?.value.childElementIds;
|
|
141
|
+
const chapters = [];
|
|
142
|
+
const source = orderedIds ?? [...chapterById.keys()];
|
|
143
|
+
for (const id of source) {
|
|
144
|
+
const chap = chapterById.get(id);
|
|
145
|
+
if (!chap)
|
|
146
|
+
continue;
|
|
147
|
+
const frames = chap.frames;
|
|
148
|
+
const title = frames.get('TIT2');
|
|
149
|
+
if (!title)
|
|
150
|
+
continue; // title is required
|
|
151
|
+
chapters.push({
|
|
152
|
+
id,
|
|
153
|
+
title,
|
|
154
|
+
url: frames.get('WXXX'),
|
|
155
|
+
start: chap.info.startTime / 1000,
|
|
156
|
+
end: chap.info.endTime / 1000,
|
|
157
|
+
image: frames.get('APIC')
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// If no ordered CTOC, sort by time
|
|
161
|
+
if (!orderedIds) {
|
|
162
|
+
chapters.sort((a, b) => a.start - b.start);
|
|
163
|
+
}
|
|
164
|
+
return chapters.length ? chapters : undefined;
|
|
165
|
+
}
|
|
119
166
|
}
|
|
120
167
|
function makeUnexpectedMajorVersionError(majorVer) {
|
|
121
168
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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.
|
|
4
|
+
"version": "11.11.1",
|
|
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,17 +117,17 @@
|
|
|
115
117
|
"strtok3": "^10.3.4",
|
|
116
118
|
"token-types": "^6.1.2",
|
|
117
119
|
"uint8array-extras": "^1.5.0",
|
|
118
|
-
"win-guid": "^0.
|
|
120
|
+
"win-guid": "^0.2.0"
|
|
119
121
|
},
|
|
120
122
|
"devDependencies": {
|
|
121
|
-
"@biomejs/biome": "2.3.
|
|
123
|
+
"@biomejs/biome": "2.3.13",
|
|
122
124
|
"@types/chai": "^5.2.3",
|
|
123
125
|
"@types/chai-as-promised": "^8.0.2",
|
|
124
126
|
"@types/content-type": "^1.1.9",
|
|
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
|
|
130
|
+
"@types/node": "^25.1.0",
|
|
129
131
|
"c8": "^10.1.3",
|
|
130
132
|
"chai": "^6.2.2",
|
|
131
133
|
"chai-as-promised": "^8.0.2",
|