Initial working version
This commit is contained in:
+432
@@ -0,0 +1,432 @@
|
||||

|
||||
[](https://ci.appveyor.com/project/Borewit/music-metadata/branch/master)
|
||||
[](https://npmjs.org/package/music-metadata)
|
||||
[](https://npmcharts.com/compare/music-metadata,jsmediatags,musicmetadata,node-id3,mp3-parser,id3-parser,wav-file-info?start=600)
|
||||
[](https://coveralls.io/github/Borewit/music-metadata?branch=master)
|
||||
[](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)
|
||||
[](https://lgtm.com/projects/g/Borewit/music-metadata/context:javascript)
|
||||
[](https://lgtm.com/projects/g/Borewit/music-metadata/alerts/)
|
||||
[](https://deepscan.io/dashboard#view=project&tid=5165&pid=6938&bid=61821)
|
||||
[](https://snyk.io/test/github/Borewit/music-metadata?targetFile=package.json)
|
||||
[](https://discord.gg/KyBr6sb)
|
||||
|
||||
# music-metadata
|
||||
|
||||
Stream and file based music metadata parser for [node.js](https://nodejs.org/).
|
||||
Supports any common audio and tagging format.
|
||||
[TypeScript](https://www.typescriptlang.org/) definitions are included.
|
||||
|
||||
## Features
|
||||
|
||||
### Support for audio file types
|
||||
|
||||
| Audio format | Description | Wiki | |
|
||||
| ------------- |---------------------------------| -------------------------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------:|
|
||||
| AIFF / AIFF-C | Audio Interchange File Format | [:link:](https://wikipedia.org/wiki/Audio_Interchange_File_Format) | <img src="https://upload.wikimedia.org/wikipedia/commons/8/84/Apple_Computer_Logo_rainbow.svg" width="40" alt="Apple rainbow logo"> |
|
||||
| AAC | ADTS / Advanced Audio Coding | [:link:](https://en.wikipedia.org/wiki/Advanced_Audio_Coding) | <img src="https://svgshare.com/i/UT8.svg" width="40" alt="AAC logo"> |
|
||||
| APE | Monkey's Audio | [:link:](https://wikipedia.org/wiki/Monkey's_Audio) | <img src="https://foreverhits.files.wordpress.com/2015/05/ape_audio.jpg" width="40" alt="Monkey's Audio logo"> |
|
||||
| ASF | Advanced Systems Format | [:link:](https://wikipedia.org/wiki/Advanced_Systems_Format) | |
|
||||
| DSDIFF | Philips DSDIFF | [:link:](https://wikipedia.org/wiki/Direct_Stream_Digital) | <img src="https://upload.wikimedia.org/wikipedia/commons/b/bc/DSDlogo.svg" width="80" alt="DSD logo"> |
|
||||
| DSF | Sony's DSD Stream File | [:link:](https://wikipedia.org/wiki/Direct_Stream_Digital) | <img src="https://upload.wikimedia.org/wikipedia/commons/b/bc/DSDlogo.svg" width="80" alt="DSD logo"> |
|
||||
| FLAC | Free Lossless Audio Codec | [:link:](https://wikipedia.org/wiki/FLAC) | <img src="https://upload.wikimedia.org/wikipedia/commons/a/a2/FLAC_logo_vector.svg" width="80" alt="FLAC logo"> |
|
||||
| MP2 | MPEG-1 Audio Layer II | [:link:](https://wikipedia.org/wiki/MPEG-1_Audio_Layer_II) | |
|
||||
| Matroska | Matroska (EBML), mka, mkv | [:link:](https://wikipedia.org/wiki/Matroska) | <img src="https://upload.wikimedia.org/wikipedia/commons/1/1a/Matroska_2010.svg" width="80" alt="Matroska logo"> |
|
||||
| MP3 | MPEG-1 / MPEG-2 Audio Layer III | [:link:](https://wikipedia.org/wiki/MP3) | <img src="https://upload.wikimedia.org/wikipedia/commons/e/ea/Mp3.svg" width="80" alt="MP3 logo"> |
|
||||
| MPC | Musepack SV7 | [:link:](https://wikipedia.org/wiki/Musepack) | <img src="https://www.musepack.net/pictures/musepack_logo.png" width="80" alt="musepack logo"> |
|
||||
| MPEG 4 | mp4, m4a, m4v | [:link:](https://wikipedia.org/wiki/MPEG-4) | <img src="https://svgshare.com/i/UU3.svg" width="80" alt="mpeg 4 logo"> |
|
||||
| Ogg | Open container format | [:link:](https://en.wikipedia.org/wiki/Ogg) | <img src="https://upload.wikimedia.org/wikipedia/commons/a/a1/Ogg_Logo.svg" width="80" alt="Ogg logo"> |
|
||||
| Opus | | [:link:](https://wikipedia.org/wiki/Opus_(audio_format)) | <img src="https://upload.wikimedia.org/wikipedia/commons/0/02/Opus_logo2.svg" width="80" alt="Opus logo"> |
|
||||
| Speex | | [:link:](https://wikipedia.org/wiki/Speex) | <img src="https://upload.wikimedia.org/wikipedia/commons/b/b5/Speex_logo_2006.svg" width="80" alt="Speex logo"> |
|
||||
| Theora | | [:link:](https://en.wikipedia.org/wiki/Theora) | <img src="https://upload.wikimedia.org/wikipedia/commons/5/57/Theora_logo_2007.svg" width="70" alt="Theora logo"> |
|
||||
| Vorbis | Vorbis audio compression | [:link:](https://wikipedia.org/wiki/Ogg_Vorbis) | <img src="https://upload.wikimedia.org/wikipedia/commons/8/8d/Xiph.Org_logo_square.svg" width="70" alt="Vorbis logo"> |
|
||||
| WAV | RIFF WAVE | [:link:](https://wikipedia.org/wiki/WAV) | |
|
||||
| WebM | webm | [:link:](https://wikipedia.org/wiki/WebM) | <img src="https://upload.wikimedia.org/wikipedia/commons/3/34/WebM_logo.svg" width="80" alt="Matroska logo"> |
|
||||
| WV | WavPack | [:link:](https://wikipedia.org/wiki/WavPack) | <img src="http://www.wavpack.com/wavpacklogo.svg" width="60" alt="WavPack logo"> |
|
||||
| WMA | Windows Media Audio | [:link:](https://wikipedia.org/wiki/Windows_Media_Audio) | <img src="https://upload.wikimedia.org/wikipedia/commons/7/76/Windows_Media_Player_simplified_logo.svg" width="40" alt="Windows Media logo"> |
|
||||
|
||||
### Supported tag headers
|
||||
|
||||
Following tag header formats are supported:
|
||||
* [APE](https://wikipedia.org/wiki/APE_tag)
|
||||
* [ASF](https://wikipedia.org/wiki/Advanced_Systems_Format)
|
||||
* EXIF 2.3
|
||||
* [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)
|
||||
* [iTunes](https://github.com/sergiomb2/libmp4v2/wiki/iTunesMetadata)
|
||||
* [RIFF](https://wikipedia.org/wiki/Resource_Interchange_File_Format)/INFO
|
||||
* [Vorbis comment](https://wikipedia.org/wiki/Vorbis_comment)
|
||||
|
||||
It allows many tags to be accessed in audio format, and tag format independent way.
|
||||
|
||||
Support for [MusicBrainz](https://musicbrainz.org/) tags as written by [Picard](https://picard.musicbrainz.org/).
|
||||
[ReplayGain](https://wiki.hydrogenaud.io/index.php?title=ReplayGain) tags are supported.
|
||||
|
||||
### Audio format & encoding details
|
||||
|
||||
Support for encoding / format details:
|
||||
* [Bit rate](https://wikipedia.org/wiki/Bit_rate)
|
||||
* [Audio bit depth](https://wikipedia.org/wiki/Audio_bit_depth)
|
||||
* Duration
|
||||
* Encoding profile (e.g. [CBR](https://en.wikipedia.org/wiki/Constant_bitrate), V0, V2)
|
||||
|
||||
|
||||
## Online demo's
|
||||
* [<img src="https://gitcdn.xyz/repo/Borewit/audio-tag-analyzer/master/src/assets/icon/audio-tag-analyzer.svg" width="40">Audio Tag Analyzer](https://audio-tag-analyzer.netlify.com/)
|
||||
* [<img src="https://cdn.sanity.io/images/3do82whm/next/ba8c847f13a5fa39d88f8bc9b7846b7886531b18-2500x2500.svg" width="40"> Webamp](https://webamp.org/)
|
||||
|
||||
## Compatibility
|
||||
|
||||
The JavaScript in runtime is compliant with [ECMAScript 2017 (ES8)](https://en.wikipedia.org/wiki/ECMAScript#8th_Edition_-_ECMAScript_2017).
|
||||
Requires [Node.js®](https://nodejs.org/) version 6 or higher.
|
||||
|
||||
### Browser Support
|
||||
|
||||
Although music-metadata is designed to run the node.js. [music-metadata-browser](https://github.com/Borewit/music-metadata-browser) can be used on the browser side.
|
||||
|
||||
To avoid Node `fs` dependency inclusion, you may use a sub-module inclusion:
|
||||
```js
|
||||
import * as mm from 'music-metadata/lib/core';
|
||||
```
|
||||
|
||||
| function | `music-metadata` | `music-metadata/lib/core` |
|
||||
| -----------------------------------------------------| ---------------------------|----------------------------|
|
||||
| [`parseBuffer`](#parsefile-function) | ✓ | ✓ |
|
||||
| [`parseStream`](#parsestream-function) * | ✓ | ✓ |
|
||||
| [`parseFromTokenizer`](#parsefromtokenizer-function) | ✓ | ✓ |
|
||||
| [`parseFile`](#parsefile-function) | ✓ | |
|
||||
|
||||
### Sponsor
|
||||
[Become a sponsor to Borewit](https://github.com/sponsors/Borewit)
|
||||
|
||||
## Usage
|
||||
|
||||
### Installation
|
||||
Install using [npm](http://npmjs.org/):
|
||||
|
||||
```bash
|
||||
npm install music-metadata
|
||||
```
|
||||
or using [yarn](https://yarnpkg.com/):
|
||||
```bash
|
||||
yarn add music-metadata
|
||||
```
|
||||
|
||||
### Import music-metadata
|
||||
|
||||
Import music-metadata in JavaScript:
|
||||
```JavaScript
|
||||
const mm = require('music-metadata');
|
||||
```
|
||||
|
||||
This is how it's done in TypeScript:
|
||||
```ts
|
||||
import * as mm from 'music-metadata';
|
||||
```
|
||||
|
||||
### Module Functions
|
||||
|
||||
There are two ways to parse (read) audio tracks:
|
||||
1) Audio (music) files can be parsed using direct file access using the [parseFile function](#parsefile)
|
||||
2) Using [Node.js streams](https://nodejs.org/api/stream.html) using the [parseStream function](#parseStream).
|
||||
|
||||
Direct file access tends to be a little faster, because it can 'jump' to various parts in the file without being obliged to read intermediate data.
|
||||
|
||||
#### parseFile function
|
||||
|
||||
Parses the specified file (`filePath`) and returns a promise with the metadata result (`IAudioMetadata`).
|
||||
|
||||
```
|
||||
parseFile(filePath: string, opts: IOptions = {}): Promise<IAudioMetadata>`
|
||||
```
|
||||
|
||||
Example:
|
||||
```js
|
||||
const mm = require('music-metadata');
|
||||
const util = require('util');
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const metadata = await mm.parseFile('../music-metadata/test/samples/MusicBrainz - Beth Hart - Sinner\'s Prayer [id3v2.3].V2.mp3');
|
||||
console.log(util.inspect(metadata, { showHidden: false, depth: null }));
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
#### parseStream function
|
||||
|
||||
Parses the provided audio stream for metadata.
|
||||
It is recommended to provide the corresponding [MIME-type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types).
|
||||
An extension (e.g.: `.mp3`), filename or path will also work.
|
||||
If the MIME-type or filename (via `fileInfo.path`) is not provided, or not understood, music-metadata will try to derive the type from the content.
|
||||
|
||||
```ts
|
||||
parseStream(stream: Stream.Readable, fileInfo?: IFileInfo | string, opts?: IOptions = {}): Promise<IAudioMetadata>`
|
||||
```
|
||||
|
||||
Example:
|
||||
```js
|
||||
const mm = require('music-metadata');
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const metadata = await mm.parseStream(someReadStream, {mimeType: 'audio/mpeg', size: 26838});
|
||||
console.log(metadata);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
#### parseBuffer function
|
||||
|
||||
Parse metadata from an audio file, where the audio file is held in a [Buffer](https://nodejs.org/api/buffer.html).
|
||||
|
||||
```ts
|
||||
parseBuffer(buffer: Buffer, fileInfo?: IFileInfo | string, opts?: IOptions = {}): Promise<IAudioMetadata>
|
||||
```
|
||||
|
||||
Example:
|
||||
```js
|
||||
(async () => {
|
||||
try {
|
||||
const metadata = mm.parseBuffer(someBuffer, 'audio/mpeg');
|
||||
console.log(metadata);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
#### parseFromTokenizer function
|
||||
This is a low level function, reading from a [strtok3](https://github.com/Borewit/strtok3) ITokenizer interface.
|
||||
[music-metadata-browser](https://github.com/Borewit/music-metadata-browser) is depended on this function.
|
||||
|
||||
This also enables special read modules like:
|
||||
* [streaming-http-token-reader](https://github.com/Borewit/streaming-http-token-reader) for chunked HTTP(S) reading, using [HTTP range requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests).
|
||||
|
||||
#### orderTags function
|
||||
|
||||
Utility to Converts the native tags to a dictionary index on the tag identifier
|
||||
|
||||
```ts
|
||||
orderTags(nativeTags: ITag[]): [tagId: string]: any[]
|
||||
```
|
||||
|
||||
#### ratingToStars function
|
||||
|
||||
Can be used to convert the normalized rating value to the 0..5 stars, where 0 an undefined rating, 1 the star the lowest rating and 5 the highest rating.
|
||||
|
||||
```ts
|
||||
ratingToStars(rating: number): number
|
||||
```
|
||||
#### selectCover function
|
||||
|
||||
Select cover image based on image type field, otherwise the first picture in file.
|
||||
|
||||
```ts
|
||||
export function selectCover(pictures?: IPicture[]): IPicture | null
|
||||
```
|
||||
|
||||
```js
|
||||
import * as mm from 'music-metadata';
|
||||
|
||||
(async () => {
|
||||
const {common} = await mm.parseFile(filePath);
|
||||
const cover = mm.selectCover(common.picture); // pick the cover image
|
||||
}
|
||||
)();
|
||||
```
|
||||
|
||||
### Options
|
||||
* `duration`: default: `false`, if set to `true`, it will parse the whole media file if required to determine the duration.
|
||||
* `observer: (update: MetadataEvent) => void;`: Will be called after each change to `common` (generic) tag, or `format` properties.
|
||||
* `skipCovers`: default: `false`, if set to `true`, it will not return embedded cover-art (images).
|
||||
* `skipPostHeaders? boolean` default: `false`, if set to `true`, it will not search all the entire track for additional headers. Only recommenced to use in combination with streams.
|
||||
* `includeChapters` default: `false`, if set to `true`, it will parse chapters (currently only MP4 files). _experimental functionality_
|
||||
|
||||
Although in most cases duration is included, in some cases it requires `music-metadata` parsing the entire file.
|
||||
To enforce parsing the entire file if needed you should set `duration` to `true`.
|
||||
|
||||
### Metadata result
|
||||
|
||||
If the returned promise resolves, the metadata (TypeScript `IAudioMetadata` interface) contains:
|
||||
* [`metadata.format`](#metadataformat) Audio format information
|
||||
* [`metadata.common`](#metadatacommon) Is a generic (abstract) way of reading metadata information.
|
||||
* [`metadata.trackInfo`](#metadatatrackInfo) Is a generic (abstract) way of reading metadata information.
|
||||
* `metadata.native` List of native (original) tags found in the parsed audio file.
|
||||
|
||||
#### `metadata.format`
|
||||
|
||||
The questionmark `?` indicates the property is optional.
|
||||
|
||||
Audio format information. Defined in the TypeScript `IFormat` interface:
|
||||
* `format.container?: string` Audio encoding format. e.g.: 'flac'
|
||||
* `format.codec?` Name of the codec (algorithm used for the audio compression)
|
||||
* `format.codecProfile?: string` Codec profile / settings
|
||||
* `format.tagTypes?: TagType[]` List of tagging formats found in parsed audio file
|
||||
* `format.duration?: number` Duration in seconds
|
||||
* `format.bitrate?: number` Number bits per second of encoded audio file
|
||||
* `format.sampleRate?: number` Sampling rate in Samples per second (S/s)
|
||||
* `format.bitsPerSample?: number` Audio bit depth
|
||||
* `format.lossless?: boolean` True if lossless, false for lossy encoding
|
||||
* `format.numberOfChannels?: number` Number of audio channels
|
||||
* `format.creationTime?: Date` Track creation time
|
||||
* `format.modificationTime?: Date` Track modification / tag update time
|
||||
* `format.trackGain?: number` Track gain in dB
|
||||
* `format.albumGain?: number` Album gain in dB
|
||||
|
||||
#### `metadata.trackInfo`
|
||||
|
||||
To support advanced containers like [Matroska](https://wikipedia.org/wiki/Matroska) or [MPEG-4](https://en.wikipedia.org/wiki/MPEG-4), which may contain multiple audio and video tracks, the **experimental** `metadata.trackInfo` has been added,
|
||||
|
||||
`metadata.trackInfo` is either `undefined` or has an **array** of [trackInfo](#trackinfo)
|
||||
|
||||
##### trackInfo
|
||||
|
||||
Audio format information. Defined in the TypeScript `IFormat` interface:
|
||||
* `trackInfo.type?: TrackType` Track type
|
||||
* `trackInfo.codecName?: string` Codec name
|
||||
* `trackInfo.codecSettings?: string` Codec settings
|
||||
* `trackInfo.flagEnabled?: boolean` Set if the track is usable, default: `true`
|
||||
* `trackInfo.flagDefault?: boolean` Set if that track (audio, video or subs) SHOULD be active if no language found matches the user preference.
|
||||
* `trackInfo.flagLacing?: boolean` Set if the track **may** contain blocks using lacing
|
||||
* `trackInfo.name?: string` A human-readable track name.
|
||||
* `trackInfo.language?: string` Specifies the language of the track
|
||||
* `trackInfo.audio?: IAudioTrack`, see [`trackInfo.audioTrack`](#trackinfoaudiotrack)
|
||||
* `trackInfo.video?: IVideoTrack`, see [`trackInfo.videoTrack`](#trackinfovideotrack)
|
||||
|
||||
##### `trackInfo.audioTrack`
|
||||
|
||||
* `audioTrack.samplingFrequency?: number`
|
||||
* `audioTrack.outputSamplingFrequency?: number`
|
||||
* `audioTrack.channels?: number`
|
||||
* `audioTrack.channelPositions?: Buffer`
|
||||
* `audioTrack.bitDepth?: number`
|
||||
|
||||
##### `trackInfo.videoTrack`
|
||||
|
||||
* `videoTrack.flagInterlaced?: boolean`
|
||||
* `videoTrack.stereoMode?: number`
|
||||
* `videoTrack.pixelWidth?: number`
|
||||
* `videoTrack.pixelHeight?: number`
|
||||
* `videoTrack.displayWidth?: number`
|
||||
* `videoTrack.displayHeight?: number`
|
||||
* `videoTrack.displayUnit?: number`
|
||||
* `videoTrack.aspectRatioType?: number`
|
||||
* `videoTrack.colourSpace?: Buffer`
|
||||
* `videoTrack.gammaValue?: number`
|
||||
|
||||
#### `metadata.common`
|
||||
|
||||
[Common tag documentation](doc/common_metadata.md) is automatically generated.
|
||||
|
||||
## Examples
|
||||
|
||||
In order to read the duration of a stream (with the exception of file streams), in some cases you should pass the size of the file in bytes.
|
||||
```js
|
||||
mm.parseStream(someReadStream, {mimeType: 'audio/mpeg', size: 26838}, {duration: true})
|
||||
.then( function (metadata) {
|
||||
console.log(util.inspect(metadata, {showHidden: false, depth: null}));
|
||||
someReadStream.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Access cover art
|
||||
|
||||
Via `metadata.common.picture` you can access an array of cover art if present.
|
||||
Each picture has this interface:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Attached picture, typically used for cover art
|
||||
*/
|
||||
export interface IPicture {
|
||||
/**
|
||||
* Image mime type
|
||||
*/
|
||||
format: string;
|
||||
/**
|
||||
* Image data
|
||||
*/
|
||||
data: Buffer;
|
||||
/**
|
||||
* Optional description
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* Picture type
|
||||
*/
|
||||
type?: string;
|
||||
}
|
||||
```
|
||||
|
||||
To assign `img` HTML-object you can do something like:
|
||||
```js
|
||||
img.src = `data:${picture.format};base64,${picture.data.toString('base64')}`;
|
||||
```
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
1. How can I traverse (a long) list of files?
|
||||
|
||||
What is important that file parsing should be done in a sequential manner.
|
||||
In a plain loop, due to the asynchronous character (like most JavaScript functions), it would cause all the files to run in parallel which is will cause your application to hang in no time.
|
||||
There are multiple ways of achieving this:
|
||||
|
||||
1. Using recursion
|
||||
|
||||
```js
|
||||
const mm = require('music-metadata')
|
||||
|
||||
function parseFiles(audioFiles) {
|
||||
|
||||
const audioFile = audioFiles.shift();
|
||||
|
||||
if (audioFile) {
|
||||
return mm.parseFile(audioFile).then(metadata => {
|
||||
// Do great things with the metadata
|
||||
return parseFiles(audioFiles); // process rest of the files AFTER we are finished
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
2. Use async/await
|
||||
|
||||
Use [async/await](https://javascript.info/async-await)
|
||||
|
||||
```js
|
||||
const mm = require('music-metadata');
|
||||
|
||||
// it is required to declare the function 'async' to allow the use of await
|
||||
async function parseFiles(audioFiles) {
|
||||
|
||||
for (const audioFile of audioFiles) {
|
||||
|
||||
// await will ensure the metadata parsing is completed before we move on to the next file
|
||||
const metadata = await mm.parseFile(audioFile);
|
||||
// Do great things with the metadata
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Use a specialized module to traverse files
|
||||
|
||||
There are specialized modules to traversing (walking) files and directory,
|
||||
like [walk](https://www.npmjs.com/package/walk).
|
||||
|
||||
## Licence
|
||||
|
||||
(The MIT License)
|
||||
|
||||
Copyright (c) 2017 Borewit
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { IOptions, IAudioMetadata, ParserType } from './type';
|
||||
import { ITokenizer } from 'strtok3/lib/core';
|
||||
import { INativeMetadataCollector } from './common/MetadataCollector';
|
||||
export interface ITokenParser {
|
||||
/**
|
||||
* Initialize parser with output (metadata), input (tokenizer) & parsing options (options).
|
||||
* @param metadata - Output
|
||||
* @param tokenizer - Input
|
||||
* @param options - Parsing options
|
||||
*/
|
||||
init(metadata: INativeMetadataCollector, tokenizer: ITokenizer, options: IOptions): ITokenParser;
|
||||
/**
|
||||
* Parse audio track.
|
||||
* Called after init(...).
|
||||
* @returns Promise
|
||||
*/
|
||||
parse(): Promise<void>;
|
||||
}
|
||||
export declare function parseHttpContentType(contentType: string): {
|
||||
type: string;
|
||||
subtype: string;
|
||||
suffix?: string;
|
||||
parameters: {
|
||||
[id: string]: string;
|
||||
};
|
||||
};
|
||||
export declare class ParserFactory {
|
||||
/**
|
||||
* Parse metadata from tokenizer
|
||||
* @param tokenizer - Tokenizer
|
||||
* @param opts - Options
|
||||
* @returns Native metadata
|
||||
*/
|
||||
static parseOnContentType(tokenizer: ITokenizer, opts: IOptions): Promise<IAudioMetadata>;
|
||||
static parse(tokenizer: ITokenizer, parserId: ParserType, opts: IOptions): Promise<IAudioMetadata>;
|
||||
/**
|
||||
* @param filePath - Path, filename or extension to audio file
|
||||
* @return Parser sub-module name
|
||||
*/
|
||||
static getParserIdForExtension(filePath: string): ParserType;
|
||||
static loadParser(moduleName: ParserType): Promise<ITokenParser>;
|
||||
private static _parse;
|
||||
private static getExtension;
|
||||
/**
|
||||
* @param httpContentType - HTTP Content-Type, extension, path or filename
|
||||
* @returns Parser sub-module name
|
||||
*/
|
||||
private static getParserIdForMimeType;
|
||||
}
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ParserFactory = exports.parseHttpContentType = void 0;
|
||||
const FileType = require("file-type/core");
|
||||
const ContentType = require("content-type");
|
||||
const MimeType = require("media-typer");
|
||||
const _debug = require("debug");
|
||||
const MetadataCollector_1 = require("./common/MetadataCollector");
|
||||
const AiffParser_1 = require("./aiff/AiffParser");
|
||||
const APEv2Parser_1 = require("./apev2/APEv2Parser");
|
||||
const AsfParser_1 = require("./asf/AsfParser");
|
||||
const FlacParser_1 = require("./flac/FlacParser");
|
||||
const MP4Parser_1 = require("./mp4/MP4Parser");
|
||||
const MpegParser_1 = require("./mpeg/MpegParser");
|
||||
const musepack_1 = require("./musepack");
|
||||
const OggParser_1 = require("./ogg/OggParser");
|
||||
const WaveParser_1 = require("./wav/WaveParser");
|
||||
const WavPackParser_1 = require("./wavpack/WavPackParser");
|
||||
const DsfParser_1 = require("./dsf/DsfParser");
|
||||
const DsdiffParser_1 = require("./dsdiff/DsdiffParser");
|
||||
const MatroskaParser_1 = require("./matroska/MatroskaParser");
|
||||
const debug = _debug('music-metadata:parser:factory');
|
||||
function parseHttpContentType(contentType) {
|
||||
const type = ContentType.parse(contentType);
|
||||
const mime = MimeType.parse(type.type);
|
||||
return {
|
||||
type: mime.type,
|
||||
subtype: mime.subtype,
|
||||
suffix: mime.suffix,
|
||||
parameters: type.parameters
|
||||
};
|
||||
}
|
||||
exports.parseHttpContentType = parseHttpContentType;
|
||||
class ParserFactory {
|
||||
/**
|
||||
* Parse metadata from tokenizer
|
||||
* @param tokenizer - Tokenizer
|
||||
* @param opts - Options
|
||||
* @returns Native metadata
|
||||
*/
|
||||
static async parseOnContentType(tokenizer, opts) {
|
||||
const { mimeType, path, url } = await tokenizer.fileInfo;
|
||||
// Resolve parser based on MIME-type or file extension
|
||||
const parserId = ParserFactory.getParserIdForMimeType(mimeType) || ParserFactory.getParserIdForExtension(path) || ParserFactory.getParserIdForExtension(url);
|
||||
if (!parserId) {
|
||||
debug('No parser found for MIME-type / extension: ' + mimeType);
|
||||
}
|
||||
return this.parse(tokenizer, parserId, opts);
|
||||
}
|
||||
static async parse(tokenizer, parserId, opts) {
|
||||
if (!parserId) {
|
||||
// Parser could not be determined on MIME-type or extension
|
||||
debug('Guess parser on content...');
|
||||
const buf = Buffer.alloc(4100);
|
||||
await tokenizer.peekBuffer(buf, { mayBeLess: true });
|
||||
if (tokenizer.fileInfo.path) {
|
||||
parserId = this.getParserIdForExtension(tokenizer.fileInfo.path);
|
||||
}
|
||||
if (!parserId) {
|
||||
const guessedType = await FileType.fromBuffer(buf);
|
||||
if (!guessedType) {
|
||||
throw new Error('Failed to determine audio format');
|
||||
}
|
||||
debug(`Guessed file type is mime=${guessedType.mime}, extension=${guessedType.ext}`);
|
||||
parserId = ParserFactory.getParserIdForMimeType(guessedType.mime);
|
||||
if (!parserId) {
|
||||
throw new Error('Guessed MIME-type not supported: ' + guessedType.mime);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Parser found, execute parser
|
||||
return this._parse(tokenizer, parserId, opts);
|
||||
}
|
||||
/**
|
||||
* @param filePath - Path, filename or extension to audio file
|
||||
* @return Parser sub-module name
|
||||
*/
|
||||
static getParserIdForExtension(filePath) {
|
||||
if (!filePath)
|
||||
return;
|
||||
const extension = this.getExtension(filePath).toLocaleLowerCase() || filePath;
|
||||
switch (extension) {
|
||||
case '.mp2':
|
||||
case '.mp3':
|
||||
case '.m2a':
|
||||
case '.aac': // Assume it is ADTS-container
|
||||
return 'mpeg';
|
||||
case '.ape':
|
||||
return 'apev2';
|
||||
case '.mp4':
|
||||
case '.m4a':
|
||||
case '.m4b':
|
||||
case '.m4pa':
|
||||
case '.m4v':
|
||||
case '.m4r':
|
||||
case '.3gp':
|
||||
return 'mp4';
|
||||
case '.wma':
|
||||
case '.wmv':
|
||||
case '.asf':
|
||||
return 'asf';
|
||||
case '.flac':
|
||||
return 'flac';
|
||||
case '.ogg':
|
||||
case '.ogv':
|
||||
case '.oga':
|
||||
case '.ogm':
|
||||
case '.ogx':
|
||||
case '.opus': // recommended filename extension for Ogg Opus
|
||||
case '.spx': // recommended filename extension for Ogg Speex
|
||||
return 'ogg';
|
||||
case '.aif':
|
||||
case '.aiff':
|
||||
case '.aifc':
|
||||
return 'aiff';
|
||||
case '.wav':
|
||||
return 'riff';
|
||||
case '.wv':
|
||||
case '.wvp':
|
||||
return 'wavpack';
|
||||
case '.mpc':
|
||||
return 'musepack';
|
||||
case '.dsf':
|
||||
return 'dsf';
|
||||
case '.dff':
|
||||
return 'dsdiff';
|
||||
case '.mka':
|
||||
case '.mkv':
|
||||
case '.mk3d':
|
||||
case '.mks':
|
||||
case '.webm':
|
||||
return 'matroska';
|
||||
}
|
||||
}
|
||||
static async loadParser(moduleName) {
|
||||
switch (moduleName) {
|
||||
case 'aiff': return new AiffParser_1.AIFFParser();
|
||||
case 'adts':
|
||||
case 'mpeg':
|
||||
return new MpegParser_1.MpegParser();
|
||||
case 'apev2': return new APEv2Parser_1.APEv2Parser();
|
||||
case 'asf': return new AsfParser_1.AsfParser();
|
||||
case 'dsf': return new DsfParser_1.DsfParser();
|
||||
case 'dsdiff': return new DsdiffParser_1.DsdiffParser();
|
||||
case 'flac': return new FlacParser_1.FlacParser();
|
||||
case 'mp4': return new MP4Parser_1.MP4Parser();
|
||||
case 'musepack': return new musepack_1.default();
|
||||
case 'ogg': return new OggParser_1.OggParser();
|
||||
case 'riff': return new WaveParser_1.WaveParser();
|
||||
case 'wavpack': return new WavPackParser_1.WavPackParser();
|
||||
case 'matroska': return new MatroskaParser_1.MatroskaParser();
|
||||
default:
|
||||
throw new Error(`Unknown parser type: ${moduleName}`);
|
||||
}
|
||||
}
|
||||
static async _parse(tokenizer, parserId, opts = {}) {
|
||||
// Parser found, execute parser
|
||||
const parser = await ParserFactory.loadParser(parserId);
|
||||
const metadata = new MetadataCollector_1.MetadataCollector(opts);
|
||||
await parser.init(metadata, tokenizer, opts).parse();
|
||||
return metadata.toCommonMetadata();
|
||||
}
|
||||
static getExtension(fname) {
|
||||
const i = fname.lastIndexOf('.');
|
||||
return i === -1 ? '' : fname.slice(i);
|
||||
}
|
||||
/**
|
||||
* @param httpContentType - HTTP Content-Type, extension, path or filename
|
||||
* @returns Parser sub-module name
|
||||
*/
|
||||
static getParserIdForMimeType(httpContentType) {
|
||||
let mime;
|
||||
try {
|
||||
mime = parseHttpContentType(httpContentType);
|
||||
}
|
||||
catch (err) {
|
||||
debug(`Invalid HTTP Content-Type header value: ${httpContentType}`);
|
||||
return;
|
||||
}
|
||||
const subType = mime.subtype.indexOf('x-') === 0 ? mime.subtype.substring(2) : mime.subtype;
|
||||
switch (mime.type) {
|
||||
case 'audio':
|
||||
switch (subType) {
|
||||
case 'mp3': // Incorrect MIME-type, Chrome, in Web API File object
|
||||
case 'mpeg':
|
||||
return 'mpeg';
|
||||
case 'aac':
|
||||
case 'aacp':
|
||||
return 'adts';
|
||||
case 'flac':
|
||||
return 'flac';
|
||||
case 'ape':
|
||||
case 'monkeys-audio':
|
||||
return 'apev2';
|
||||
case 'mp4':
|
||||
case 'm4a':
|
||||
return 'mp4';
|
||||
case 'ogg': // RFC 7845
|
||||
case 'opus': // RFC 6716
|
||||
case 'speex': // RFC 5574
|
||||
return 'ogg';
|
||||
case 'ms-wma':
|
||||
case 'ms-wmv':
|
||||
case 'ms-asf':
|
||||
return 'asf';
|
||||
case 'aiff':
|
||||
case 'aif':
|
||||
case 'aifc':
|
||||
return 'aiff';
|
||||
case 'vnd.wave':
|
||||
case 'wav':
|
||||
case 'wave':
|
||||
return 'riff';
|
||||
case 'wavpack':
|
||||
return 'wavpack';
|
||||
case 'musepack':
|
||||
return 'musepack';
|
||||
case 'matroska':
|
||||
case 'webm':
|
||||
return 'matroska';
|
||||
case 'dsf':
|
||||
return 'dsf';
|
||||
}
|
||||
break;
|
||||
case 'video':
|
||||
switch (subType) {
|
||||
case 'ms-asf':
|
||||
case 'ms-wmv':
|
||||
return 'asf';
|
||||
case 'm4v':
|
||||
case 'mp4':
|
||||
return 'mp4';
|
||||
case 'ogg':
|
||||
return 'ogg';
|
||||
case 'matroska':
|
||||
case 'webm':
|
||||
return 'matroska';
|
||||
}
|
||||
break;
|
||||
case 'application':
|
||||
switch (subType) {
|
||||
case 'vnd.ms-asf':
|
||||
return 'asf';
|
||||
case 'ogg':
|
||||
return 'ogg';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.ParserFactory = ParserFactory;
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { BasicParser } from '../common/BasicParser';
|
||||
import * as iff from '../iff';
|
||||
/**
|
||||
* AIFF - Audio Interchange File Format
|
||||
*
|
||||
* Ref:
|
||||
* http://www.onicos.com/staff/iz/formats/aiff.html
|
||||
* http://muratnkonar.com/aiff/index.html
|
||||
* http://www-mmsp.ece.mcgill.ca/Documents/AudioFormats/AIFF/AIFF.html
|
||||
*/
|
||||
export declare class AIFFParser extends BasicParser {
|
||||
private isCompressed;
|
||||
parse(): Promise<void>;
|
||||
readData(header: iff.IChunkHeader): Promise<number>;
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AIFFParser = void 0;
|
||||
const Token = require("token-types");
|
||||
const initDebug = require("debug");
|
||||
const strtok3 = require("strtok3/lib/core");
|
||||
const ID3v2Parser_1 = require("../id3v2/ID3v2Parser");
|
||||
const FourCC_1 = require("../common/FourCC");
|
||||
const BasicParser_1 = require("../common/BasicParser");
|
||||
const AiffToken = require("./AiffToken");
|
||||
const iff = require("../iff");
|
||||
const debug = initDebug('music-metadata:parser:aiff');
|
||||
/**
|
||||
* AIFF - Audio Interchange File Format
|
||||
*
|
||||
* Ref:
|
||||
* http://www.onicos.com/staff/iz/formats/aiff.html
|
||||
* http://muratnkonar.com/aiff/index.html
|
||||
* http://www-mmsp.ece.mcgill.ca/Documents/AudioFormats/AIFF/AIFF.html
|
||||
*/
|
||||
class AIFFParser extends BasicParser_1.BasicParser {
|
||||
async parse() {
|
||||
const header = await this.tokenizer.readToken(iff.Header);
|
||||
if (header.chunkID !== 'FORM')
|
||||
throw new Error('Invalid Chunk-ID, expected \'FORM\''); // Not AIFF format
|
||||
const type = await this.tokenizer.readToken(FourCC_1.FourCcToken);
|
||||
switch (type) {
|
||||
case 'AIFF':
|
||||
this.metadata.setFormat('container', type);
|
||||
this.isCompressed = false;
|
||||
break;
|
||||
case 'AIFC':
|
||||
this.metadata.setFormat('container', 'AIFF-C');
|
||||
this.isCompressed = true;
|
||||
break;
|
||||
default:
|
||||
throw Error('Unsupported AIFF type: ' + type);
|
||||
}
|
||||
this.metadata.setFormat('lossless', !this.isCompressed);
|
||||
try {
|
||||
while (!this.tokenizer.fileInfo.size || this.tokenizer.fileInfo.size - this.tokenizer.position >= iff.Header.len) {
|
||||
debug('Reading AIFF chunk at offset=' + this.tokenizer.position);
|
||||
const chunkHeader = await this.tokenizer.readToken(iff.Header);
|
||||
debug(`Chunk id=${chunkHeader.chunkID}`);
|
||||
const nextChunk = 2 * Math.round(chunkHeader.chunkSize / 2);
|
||||
const bytesRead = await this.readData(chunkHeader);
|
||||
await this.tokenizer.ignore(nextChunk - bytesRead);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof strtok3.EndOfStreamError) {
|
||||
debug(`End-of-stream`);
|
||||
}
|
||||
else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
async readData(header) {
|
||||
switch (header.chunkID) {
|
||||
case 'COMM': // The Common Chunk
|
||||
const common = await this.tokenizer.readToken(new AiffToken.Common(header, this.isCompressed));
|
||||
this.metadata.setFormat('bitsPerSample', common.sampleSize);
|
||||
this.metadata.setFormat('sampleRate', common.sampleRate);
|
||||
this.metadata.setFormat('numberOfChannels', common.numChannels);
|
||||
this.metadata.setFormat('numberOfSamples', common.numSampleFrames);
|
||||
this.metadata.setFormat('duration', common.numSampleFrames / common.sampleRate);
|
||||
this.metadata.setFormat('codec', common.compressionName);
|
||||
return header.chunkSize;
|
||||
case 'ID3 ': // ID3-meta-data
|
||||
const id3_data = await this.tokenizer.readToken(new Token.Uint8ArrayType(header.chunkSize));
|
||||
const rst = strtok3.fromBuffer(id3_data);
|
||||
await new ID3v2Parser_1.ID3v2Parser().parse(this.metadata, rst, this.options);
|
||||
return header.chunkSize;
|
||||
case 'SSND': // Sound Data Chunk
|
||||
if (this.metadata.format.duration) {
|
||||
this.metadata.setFormat('bitrate', 8 * header.chunkSize / this.metadata.format.duration);
|
||||
}
|
||||
return 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.AIFFParser = AIFFParser;
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/// <reference types="node" />
|
||||
import * as iff from '../iff';
|
||||
import { IGetToken } from "strtok3";
|
||||
/**
|
||||
* The Common Chunk.
|
||||
* Describes fundamental parameters of the waveform data such as sample rate, bit resolution, and how many channels of
|
||||
* digital audio are stored in the FORM AIFF.
|
||||
*/
|
||||
export interface ICommon {
|
||||
numChannels: number;
|
||||
numSampleFrames: number;
|
||||
sampleSize: number;
|
||||
sampleRate: number;
|
||||
compressionType?: string;
|
||||
compressionName?: string;
|
||||
}
|
||||
export declare class Common implements IGetToken<ICommon> {
|
||||
private isAifc;
|
||||
len: number;
|
||||
constructor(header: iff.IChunkHeader, isAifc: boolean);
|
||||
get(buf: Buffer, off: number): ICommon;
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.Common = void 0;
|
||||
const Token = require("token-types");
|
||||
const FourCC_1 = require("../common/FourCC");
|
||||
class Common {
|
||||
constructor(header, isAifc) {
|
||||
this.isAifc = isAifc;
|
||||
const minimumChunkSize = isAifc ? 22 : 18;
|
||||
if (header.chunkSize < minimumChunkSize)
|
||||
throw new Error(`COMMON CHUNK size should always be at least ${minimumChunkSize}`);
|
||||
this.len = header.chunkSize;
|
||||
}
|
||||
get(buf, off) {
|
||||
// see: https://cycling74.com/forums/aiffs-80-bit-sample-rate-value
|
||||
const shift = buf.readUInt16BE(off + 8) - 16398;
|
||||
const baseSampleRate = buf.readUInt16BE(off + 8 + 2);
|
||||
const res = {
|
||||
numChannels: buf.readUInt16BE(off),
|
||||
numSampleFrames: buf.readUInt32BE(off + 2),
|
||||
sampleSize: buf.readUInt16BE(off + 6),
|
||||
sampleRate: shift < 0 ? baseSampleRate >> Math.abs(shift) : baseSampleRate << shift
|
||||
};
|
||||
if (this.isAifc) {
|
||||
res.compressionType = FourCC_1.FourCcToken.get(buf, off + 18);
|
||||
if (this.len > 22) {
|
||||
const strLen = buf.readInt8(off + 22);
|
||||
const padding = (strLen + 1) % 2;
|
||||
if (23 + strLen + padding === this.len) {
|
||||
res.compressionName = new Token.StringType(strLen, 'binary').get(buf, off + 23);
|
||||
}
|
||||
else {
|
||||
throw new Error('Illegal pstring length');
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
res.compressionName = 'PCM';
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
exports.Common = Common;
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import * as strtok3 from 'strtok3/lib/core';
|
||||
import { IOptions, IRandomReader, IApeHeader } from '../type';
|
||||
import { INativeMetadataCollector } from '../common/MetadataCollector';
|
||||
import { BasicParser } from '../common/BasicParser';
|
||||
import { IFooter, IHeader } from './APEv2Token';
|
||||
export declare class APEv2Parser extends BasicParser {
|
||||
static tryParseApeHeader(metadata: INativeMetadataCollector, tokenizer: strtok3.ITokenizer, options: IOptions): Promise<void>;
|
||||
/**
|
||||
* Calculate the media file duration
|
||||
* @param ah ApeHeader
|
||||
* @return {number} duration in seconds
|
||||
*/
|
||||
static calculateDuration(ah: IHeader): number;
|
||||
/**
|
||||
* Calculates the APEv1 / APEv2 first field offset
|
||||
* @param reader
|
||||
* @param offset
|
||||
*/
|
||||
static findApeFooterOffset(reader: IRandomReader, offset: number): Promise<IApeHeader>;
|
||||
private static parseTagFooter;
|
||||
private ape;
|
||||
/**
|
||||
* Parse APEv1 / APEv2 header if header signature found
|
||||
*/
|
||||
tryParseApeHeader(): Promise<void>;
|
||||
parse(): Promise<void>;
|
||||
parseTags(footer: IFooter): Promise<void>;
|
||||
private parseDescriptorExpansion;
|
||||
private parseHeader;
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.APEv2Parser = void 0;
|
||||
const initDebug = require("debug");
|
||||
const strtok3 = require("strtok3/lib/core");
|
||||
const util = require("../common/Util");
|
||||
const BasicParser_1 = require("../common/BasicParser");
|
||||
const APEv2Token_1 = require("./APEv2Token");
|
||||
const token_types_1 = require("token-types");
|
||||
const debug = initDebug('music-metadata:parser:APEv2');
|
||||
const tagFormat = 'APEv2';
|
||||
const preamble = 'APETAGEX';
|
||||
class APEv2Parser extends BasicParser_1.BasicParser {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.ape = {};
|
||||
}
|
||||
static tryParseApeHeader(metadata, tokenizer, options) {
|
||||
const apeParser = new APEv2Parser();
|
||||
apeParser.init(metadata, tokenizer, options);
|
||||
return apeParser.tryParseApeHeader();
|
||||
}
|
||||
/**
|
||||
* Calculate the media file duration
|
||||
* @param ah ApeHeader
|
||||
* @return {number} duration in seconds
|
||||
*/
|
||||
static calculateDuration(ah) {
|
||||
let duration = ah.totalFrames > 1 ? ah.blocksPerFrame * (ah.totalFrames - 1) : 0;
|
||||
duration += ah.finalFrameBlocks;
|
||||
return duration / ah.sampleRate;
|
||||
}
|
||||
/**
|
||||
* Calculates the APEv1 / APEv2 first field offset
|
||||
* @param reader
|
||||
* @param offset
|
||||
*/
|
||||
static async findApeFooterOffset(reader, offset) {
|
||||
// Search for APE footer header at the end of the file
|
||||
const apeBuf = Buffer.alloc(APEv2Token_1.TagFooter.len);
|
||||
await reader.randomRead(apeBuf, 0, APEv2Token_1.TagFooter.len, offset - APEv2Token_1.TagFooter.len);
|
||||
const tagFooter = APEv2Token_1.TagFooter.get(apeBuf, 0);
|
||||
if (tagFooter.ID === 'APETAGEX') {
|
||||
debug(`APE footer header at offset=${offset}`);
|
||||
return { footer: tagFooter, offset: offset - tagFooter.size };
|
||||
}
|
||||
}
|
||||
static parseTagFooter(metadata, buffer, options) {
|
||||
const footer = APEv2Token_1.TagFooter.get(buffer, buffer.length - APEv2Token_1.TagFooter.len);
|
||||
if (footer.ID !== preamble)
|
||||
throw new Error('Unexpected APEv2 Footer ID preamble value.');
|
||||
strtok3.fromBuffer(buffer);
|
||||
const apeParser = new APEv2Parser();
|
||||
apeParser.init(metadata, strtok3.fromBuffer(buffer), options);
|
||||
return apeParser.parseTags(footer);
|
||||
}
|
||||
/**
|
||||
* Parse APEv1 / APEv2 header if header signature found
|
||||
*/
|
||||
async tryParseApeHeader() {
|
||||
if (this.tokenizer.fileInfo.size && this.tokenizer.fileInfo.size - this.tokenizer.position < APEv2Token_1.TagFooter.len) {
|
||||
debug(`No APEv2 header found, end-of-file reached`);
|
||||
return;
|
||||
}
|
||||
const footer = await this.tokenizer.peekToken(APEv2Token_1.TagFooter);
|
||||
if (footer.ID === preamble) {
|
||||
await this.tokenizer.ignore(APEv2Token_1.TagFooter.len);
|
||||
return this.parseTags(footer);
|
||||
}
|
||||
else {
|
||||
debug(`APEv2 header not found at offset=${this.tokenizer.position}`);
|
||||
if (this.tokenizer.fileInfo.size) {
|
||||
// Try to read the APEv2 header using just the footer-header
|
||||
const remaining = this.tokenizer.fileInfo.size - this.tokenizer.position; // ToDo: take ID3v1 into account
|
||||
const buffer = Buffer.alloc(remaining);
|
||||
await this.tokenizer.readBuffer(buffer);
|
||||
return APEv2Parser.parseTagFooter(this.metadata, buffer, this.options);
|
||||
}
|
||||
}
|
||||
}
|
||||
async parse() {
|
||||
const descriptor = await this.tokenizer.readToken(APEv2Token_1.DescriptorParser);
|
||||
if (descriptor.ID !== 'MAC ')
|
||||
throw new Error('Unexpected descriptor ID');
|
||||
this.ape.descriptor = descriptor;
|
||||
const lenExp = descriptor.descriptorBytes - APEv2Token_1.DescriptorParser.len;
|
||||
const header = await (lenExp > 0 ? this.parseDescriptorExpansion(lenExp) : this.parseHeader());
|
||||
await this.tokenizer.ignore(header.forwardBytes);
|
||||
return this.tryParseApeHeader();
|
||||
}
|
||||
async parseTags(footer) {
|
||||
const keyBuffer = Buffer.alloc(256); // maximum tag key length
|
||||
let bytesRemaining = footer.size - APEv2Token_1.TagFooter.len;
|
||||
debug(`Parse APE tags at offset=${this.tokenizer.position}, size=${bytesRemaining}`);
|
||||
for (let i = 0; i < footer.fields; i++) {
|
||||
if (bytesRemaining < APEv2Token_1.TagItemHeader.len) {
|
||||
this.metadata.addWarning(`APEv2 Tag-header: ${footer.fields - i} items remaining, but no more tag data to read.`);
|
||||
break;
|
||||
}
|
||||
// Only APEv2 tag has tag item headers
|
||||
const tagItemHeader = await this.tokenizer.readToken(APEv2Token_1.TagItemHeader);
|
||||
bytesRemaining -= APEv2Token_1.TagItemHeader.len + tagItemHeader.size;
|
||||
await this.tokenizer.peekBuffer(keyBuffer, { length: Math.min(keyBuffer.length, bytesRemaining) });
|
||||
let zero = util.findZero(keyBuffer, 0, keyBuffer.length);
|
||||
const key = await this.tokenizer.readToken(new token_types_1.StringType(zero, 'ascii'));
|
||||
await this.tokenizer.ignore(1);
|
||||
bytesRemaining -= key.length + 1;
|
||||
switch (tagItemHeader.flags.dataType) {
|
||||
case APEv2Token_1.DataType.text_utf8: { // utf-8 textstring
|
||||
const value = await this.tokenizer.readToken(new token_types_1.StringType(tagItemHeader.size, 'utf8'));
|
||||
const values = value.split(/\x00/g);
|
||||
/*jshint loopfunc:true */
|
||||
for (const val of values) {
|
||||
this.metadata.addTag(tagFormat, key, val);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case APEv2Token_1.DataType.binary: // binary (probably artwork)
|
||||
if (this.options.skipCovers) {
|
||||
await this.tokenizer.ignore(tagItemHeader.size);
|
||||
}
|
||||
else {
|
||||
const picData = Buffer.alloc(tagItemHeader.size);
|
||||
await this.tokenizer.readBuffer(picData);
|
||||
zero = util.findZero(picData, 0, picData.length);
|
||||
const description = picData.toString('utf8', 0, zero);
|
||||
const data = Buffer.from(picData.slice(zero + 1));
|
||||
this.metadata.addTag(tagFormat, key, {
|
||||
description,
|
||||
data
|
||||
});
|
||||
}
|
||||
break;
|
||||
case APEv2Token_1.DataType.external_info:
|
||||
debug(`Ignore external info ${key}`);
|
||||
await this.tokenizer.ignore(tagItemHeader.size);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unexpected data-type: ${tagItemHeader.flags.dataType}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
async parseDescriptorExpansion(lenExp) {
|
||||
await this.tokenizer.ignore(lenExp);
|
||||
return this.parseHeader();
|
||||
}
|
||||
async parseHeader() {
|
||||
const header = await this.tokenizer.readToken(APEv2Token_1.Header);
|
||||
// ToDo before
|
||||
this.metadata.setFormat('lossless', true);
|
||||
this.metadata.setFormat('container', 'Monkey\'s Audio');
|
||||
this.metadata.setFormat('bitsPerSample', header.bitsPerSample);
|
||||
this.metadata.setFormat('sampleRate', header.sampleRate);
|
||||
this.metadata.setFormat('numberOfChannels', header.channel);
|
||||
this.metadata.setFormat('duration', APEv2Parser.calculateDuration(header));
|
||||
return {
|
||||
forwardBytes: this.ape.descriptor.seekTableBytes + this.ape.descriptor.headerDataBytes +
|
||||
this.ape.descriptor.apeFrameDataBytes + this.ape.descriptor.terminatingDataBytes
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.APEv2Parser = APEv2Parser;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { CaseInsensitiveTagMap } from '../common/CaseInsensitiveTagMap';
|
||||
export declare class APEv2TagMapper extends CaseInsensitiveTagMap {
|
||||
constructor();
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.APEv2TagMapper = void 0;
|
||||
const CaseInsensitiveTagMap_1 = require("../common/CaseInsensitiveTagMap");
|
||||
/**
|
||||
* ID3v2.2 tag mappings
|
||||
*/
|
||||
const apev2TagMap = {
|
||||
Title: 'title',
|
||||
Artist: 'artist',
|
||||
Artists: 'artists',
|
||||
'Album Artist': 'albumartist',
|
||||
Album: 'album',
|
||||
Year: 'date',
|
||||
Originalyear: 'originalyear',
|
||||
Originaldate: 'originaldate',
|
||||
Comment: 'comment',
|
||||
Track: 'track',
|
||||
Disc: 'disk',
|
||||
DISCNUMBER: 'disk',
|
||||
Genre: 'genre',
|
||||
'Cover Art (Front)': 'picture',
|
||||
'Cover Art (Back)': 'picture',
|
||||
Composer: 'composer',
|
||||
Lyrics: 'lyrics',
|
||||
ALBUMSORT: 'albumsort',
|
||||
TITLESORT: 'titlesort',
|
||||
WORK: 'work',
|
||||
ARTISTSORT: 'artistsort',
|
||||
ALBUMARTISTSORT: 'albumartistsort',
|
||||
COMPOSERSORT: 'composersort',
|
||||
Lyricist: 'lyricist',
|
||||
Writer: 'writer',
|
||||
Conductor: 'conductor',
|
||||
// 'Performer=artist (instrument)': 'performer:instrument',
|
||||
MixArtist: 'remixer',
|
||||
Arranger: 'arranger',
|
||||
Engineer: 'engineer',
|
||||
Producer: 'producer',
|
||||
DJMixer: 'djmixer',
|
||||
Mixer: 'mixer',
|
||||
Label: 'label',
|
||||
Grouping: 'grouping',
|
||||
Subtitle: 'subtitle',
|
||||
DiscSubtitle: 'discsubtitle',
|
||||
Compilation: 'compilation',
|
||||
BPM: 'bpm',
|
||||
Mood: 'mood',
|
||||
Media: 'media',
|
||||
CatalogNumber: 'catalognumber',
|
||||
MUSICBRAINZ_ALBUMSTATUS: 'releasestatus',
|
||||
MUSICBRAINZ_ALBUMTYPE: 'releasetype',
|
||||
RELEASECOUNTRY: 'releasecountry',
|
||||
Script: 'script',
|
||||
Language: 'language',
|
||||
Copyright: 'copyright',
|
||||
LICENSE: 'license',
|
||||
EncodedBy: 'encodedby',
|
||||
EncoderSettings: 'encodersettings',
|
||||
Barcode: 'barcode',
|
||||
ISRC: 'isrc',
|
||||
ASIN: 'asin',
|
||||
musicbrainz_trackid: 'musicbrainz_recordingid',
|
||||
musicbrainz_releasetrackid: 'musicbrainz_trackid',
|
||||
MUSICBRAINZ_ALBUMID: 'musicbrainz_albumid',
|
||||
MUSICBRAINZ_ARTISTID: 'musicbrainz_artistid',
|
||||
MUSICBRAINZ_ALBUMARTISTID: 'musicbrainz_albumartistid',
|
||||
MUSICBRAINZ_RELEASEGROUPID: 'musicbrainz_releasegroupid',
|
||||
MUSICBRAINZ_WORKID: 'musicbrainz_workid',
|
||||
MUSICBRAINZ_TRMID: 'musicbrainz_trmid',
|
||||
MUSICBRAINZ_DISCID: 'musicbrainz_discid',
|
||||
Acoustid_Id: 'acoustid_id',
|
||||
ACOUSTID_FINGERPRINT: 'acoustid_fingerprint',
|
||||
MUSICIP_PUID: 'musicip_puid',
|
||||
Weblink: 'website',
|
||||
REPLAYGAIN_TRACK_GAIN: 'replaygain_track_gain',
|
||||
REPLAYGAIN_TRACK_PEAK: 'replaygain_track_peak',
|
||||
MP3GAIN_MINMAX: 'replaygain_track_minmax',
|
||||
MP3GAIN_UNDO: 'replaygain_undo'
|
||||
};
|
||||
class APEv2TagMapper extends CaseInsensitiveTagMap_1.CaseInsensitiveTagMap {
|
||||
constructor() {
|
||||
super(['APEv2'], apev2TagMap);
|
||||
}
|
||||
}
|
||||
exports.APEv2TagMapper = APEv2TagMapper;
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import * as Token from 'token-types';
|
||||
import { IGetToken } from 'strtok3/lib/core';
|
||||
/**
|
||||
* APETag versionIndex history / supported formats
|
||||
*
|
||||
* 1.0 (1000) - Original APE tag spec. Fully supported by this code.
|
||||
* 2.0 (2000) - Refined APE tag spec (better streaming support, UTF StringEncoding). Fully supported by this code.
|
||||
*
|
||||
* Notes:
|
||||
* - also supports reading of ID3v1.1 tags
|
||||
* - all saving done in the APE Tag format using CURRENT_APE_TAG_VERSION
|
||||
*
|
||||
* APE File Format Overview: (pieces in order -- only valid for the latest versionIndex APE files)
|
||||
*
|
||||
* JUNK - any amount of "junk" before the APE_DESCRIPTOR (so people that put ID3v2 tags on the files aren't hosed)
|
||||
* APE_DESCRIPTOR - defines the sizes (and offsets) of all the pieces, as well as the MD5 checksum
|
||||
* APE_HEADER - describes all of the necessary information about the APE file
|
||||
* SEEK TABLE - the table that represents seek offsets [optional]
|
||||
* HEADER DATA - the pre-audio data from the original file [optional]
|
||||
* APE FRAMES - the actual compressed audio (broken into frames for seekability)
|
||||
* TERMINATING DATA - the post-audio data from the original file [optional]
|
||||
* TAG - describes all the properties of the file [optional]
|
||||
*/
|
||||
export interface IDescriptor {
|
||||
ID: string;
|
||||
version: number;
|
||||
descriptorBytes: number;
|
||||
headerBytes: number;
|
||||
seekTableBytes: number;
|
||||
headerDataBytes: number;
|
||||
apeFrameDataBytes: number;
|
||||
apeFrameDataBytesHigh: number;
|
||||
terminatingDataBytes: number;
|
||||
fileMD5: Uint8Array;
|
||||
}
|
||||
/**
|
||||
* APE_HEADER: describes all of the necessary information about the APE file
|
||||
*/
|
||||
export interface IHeader {
|
||||
compressionLevel: number;
|
||||
formatFlags: number;
|
||||
blocksPerFrame: number;
|
||||
finalFrameBlocks: number;
|
||||
totalFrames: number;
|
||||
bitsPerSample: number;
|
||||
channel: number;
|
||||
sampleRate: number;
|
||||
}
|
||||
export interface IFooter {
|
||||
ID: string;
|
||||
version: number;
|
||||
size: number;
|
||||
fields: number;
|
||||
flags: ITagFlags;
|
||||
}
|
||||
export declare enum DataType {
|
||||
text_utf8 = 0,
|
||||
binary = 1,
|
||||
external_info = 2,
|
||||
reserved = 3
|
||||
}
|
||||
/**
|
||||
* APE_DESCRIPTOR: defines the sizes (and offsets) of all the pieces, as well as the MD5 checksum
|
||||
*/
|
||||
export declare const DescriptorParser: IGetToken<IDescriptor>;
|
||||
/**
|
||||
* APE_HEADER: describes all of the necessary information about the APE file
|
||||
*/
|
||||
export declare const Header: IGetToken<IHeader>;
|
||||
/**
|
||||
* APE Tag Header/Footer Version 2.0
|
||||
* TAG: describes all the properties of the file [optional]
|
||||
*/
|
||||
export declare const TagFooter: IGetToken<IFooter>;
|
||||
/**
|
||||
* APE Tag v2.0 Item Header
|
||||
*/
|
||||
export interface ITagItemHeader {
|
||||
size: number;
|
||||
flags: ITagFlags;
|
||||
}
|
||||
/**
|
||||
* APE Tag v2.0 Item Header
|
||||
*/
|
||||
export declare const TagItemHeader: IGetToken<ITagItemHeader>;
|
||||
export declare const TagField: (footer: any) => Token.Uint8ArrayType;
|
||||
export interface ITagFlags {
|
||||
containsHeader: boolean;
|
||||
containsFooter: boolean;
|
||||
isHeader: boolean;
|
||||
readOnly: boolean;
|
||||
dataType: DataType;
|
||||
}
|
||||
export declare function parseTagFlags(flags: any): ITagFlags;
|
||||
/**
|
||||
* @param num {number}
|
||||
* @param bit 0 is least significant bit (LSB)
|
||||
* @return {boolean} true if bit is 1; otherwise false
|
||||
*/
|
||||
export declare function isBitSet(num: any, bit: any): boolean;
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.isBitSet = exports.parseTagFlags = exports.TagField = exports.TagItemHeader = exports.TagFooter = exports.Header = exports.DescriptorParser = exports.DataType = void 0;
|
||||
const Token = require("token-types");
|
||||
const FourCC_1 = require("../common/FourCC");
|
||||
var DataType;
|
||||
(function (DataType) {
|
||||
DataType[DataType["text_utf8"] = 0] = "text_utf8";
|
||||
DataType[DataType["binary"] = 1] = "binary";
|
||||
DataType[DataType["external_info"] = 2] = "external_info";
|
||||
DataType[DataType["reserved"] = 3] = "reserved";
|
||||
})(DataType = exports.DataType || (exports.DataType = {}));
|
||||
/**
|
||||
* APE_DESCRIPTOR: defines the sizes (and offsets) of all the pieces, as well as the MD5 checksum
|
||||
*/
|
||||
exports.DescriptorParser = {
|
||||
len: 52,
|
||||
get: (buf, off) => {
|
||||
return {
|
||||
// should equal 'MAC '
|
||||
ID: FourCC_1.FourCcToken.get(buf, off),
|
||||
// versionIndex number * 1000 (3.81 = 3810) (remember that 4-byte alignment causes this to take 4-bytes)
|
||||
version: Token.UINT32_LE.get(buf, off + 4) / 1000,
|
||||
// the number of descriptor bytes (allows later expansion of this header)
|
||||
descriptorBytes: Token.UINT32_LE.get(buf, off + 8),
|
||||
// the number of header APE_HEADER bytes
|
||||
headerBytes: Token.UINT32_LE.get(buf, off + 12),
|
||||
// the number of header APE_HEADER bytes
|
||||
seekTableBytes: Token.UINT32_LE.get(buf, off + 16),
|
||||
// the number of header data bytes (from original file)
|
||||
headerDataBytes: Token.UINT32_LE.get(buf, off + 20),
|
||||
// the number of bytes of APE frame data
|
||||
apeFrameDataBytes: Token.UINT32_LE.get(buf, off + 24),
|
||||
// the high order number of APE frame data bytes
|
||||
apeFrameDataBytesHigh: Token.UINT32_LE.get(buf, off + 28),
|
||||
// the terminating data of the file (not including tag data)
|
||||
terminatingDataBytes: Token.UINT32_LE.get(buf, off + 32),
|
||||
// the MD5 hash of the file (see notes for usage... it's a little tricky)
|
||||
fileMD5: new Token.Uint8ArrayType(16).get(buf, off + 36)
|
||||
};
|
||||
}
|
||||
};
|
||||
/**
|
||||
* APE_HEADER: describes all of the necessary information about the APE file
|
||||
*/
|
||||
exports.Header = {
|
||||
len: 24,
|
||||
get: (buf, off) => {
|
||||
return {
|
||||
// the compression level (see defines I.E. COMPRESSION_LEVEL_FAST)
|
||||
compressionLevel: Token.UINT16_LE.get(buf, off),
|
||||
// any format flags (for future use)
|
||||
formatFlags: Token.UINT16_LE.get(buf, off + 2),
|
||||
// the number of audio blocks in one frame
|
||||
blocksPerFrame: Token.UINT32_LE.get(buf, off + 4),
|
||||
// the number of audio blocks in the final frame
|
||||
finalFrameBlocks: Token.UINT32_LE.get(buf, off + 8),
|
||||
// the total number of frames
|
||||
totalFrames: Token.UINT32_LE.get(buf, off + 12),
|
||||
// the bits per sample (typically 16)
|
||||
bitsPerSample: Token.UINT16_LE.get(buf, off + 16),
|
||||
// the number of channels (1 or 2)
|
||||
channel: Token.UINT16_LE.get(buf, off + 18),
|
||||
// the sample rate (typically 44100)
|
||||
sampleRate: Token.UINT32_LE.get(buf, off + 20)
|
||||
};
|
||||
}
|
||||
};
|
||||
/**
|
||||
* APE Tag Header/Footer Version 2.0
|
||||
* TAG: describes all the properties of the file [optional]
|
||||
*/
|
||||
exports.TagFooter = {
|
||||
len: 32,
|
||||
get: (buf, off) => {
|
||||
return {
|
||||
// should equal 'APETAGEX'
|
||||
ID: new Token.StringType(8, 'ascii').get(buf, off),
|
||||
// equals CURRENT_APE_TAG_VERSION
|
||||
version: Token.UINT32_LE.get(buf, off + 8),
|
||||
// the complete size of the tag, including this footer (excludes header)
|
||||
size: Token.UINT32_LE.get(buf, off + 12),
|
||||
// the number of fields in the tag
|
||||
fields: Token.UINT32_LE.get(buf, off + 16),
|
||||
// reserved for later use (must be zero),
|
||||
flags: parseTagFlags(Token.UINT32_LE.get(buf, off + 20))
|
||||
};
|
||||
}
|
||||
};
|
||||
/**
|
||||
* APE Tag v2.0 Item Header
|
||||
*/
|
||||
exports.TagItemHeader = {
|
||||
len: 8,
|
||||
get: (buf, off) => {
|
||||
return {
|
||||
// Length of assigned value in bytes
|
||||
size: Token.UINT32_LE.get(buf, off),
|
||||
// reserved for later use (must be zero),
|
||||
flags: parseTagFlags(Token.UINT32_LE.get(buf, off + 4))
|
||||
};
|
||||
}
|
||||
};
|
||||
const TagField = footer => {
|
||||
return new Token.Uint8ArrayType(footer.size - exports.TagFooter.len);
|
||||
};
|
||||
exports.TagField = TagField;
|
||||
function parseTagFlags(flags) {
|
||||
return {
|
||||
containsHeader: isBitSet(flags, 31),
|
||||
containsFooter: isBitSet(flags, 30),
|
||||
isHeader: isBitSet(flags, 31),
|
||||
readOnly: isBitSet(flags, 0),
|
||||
dataType: (flags & 6) >> 1
|
||||
};
|
||||
}
|
||||
exports.parseTagFlags = parseTagFlags;
|
||||
/**
|
||||
* @param num {number}
|
||||
* @param bit 0 is least significant bit (LSB)
|
||||
* @return {boolean} true if bit is 1; otherwise false
|
||||
*/
|
||||
function isBitSet(num, bit) {
|
||||
return (num & 1 << bit) !== 0;
|
||||
}
|
||||
exports.isBitSet = isBitSet;
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
/// <reference types="node" />
|
||||
import { IPicture, ITag } from '../type';
|
||||
import GUID from './GUID';
|
||||
import { IGetToken, ITokenizer } from 'strtok3/lib/core';
|
||||
/**
|
||||
* Data Type: Specifies the type of information being stored. The following values are recognized.
|
||||
*/
|
||||
export declare enum DataType {
|
||||
/**
|
||||
* Unicode string. The data consists of a sequence of Unicode characters.
|
||||
*/
|
||||
UnicodeString = 0,
|
||||
/**
|
||||
* BYTE array. The type of data is implementation-specific.
|
||||
*/
|
||||
ByteArray = 1,
|
||||
/**
|
||||
* BOOL. The data is 2 bytes long and should be interpreted as a 16-bit unsigned integer. Only 0x0000 or 0x0001 are permitted values.
|
||||
*/
|
||||
Bool = 2,
|
||||
/**
|
||||
* DWORD. The data is 4 bytes long and should be interpreted as a 32-bit unsigned integer.
|
||||
*/
|
||||
DWord = 3,
|
||||
/**
|
||||
* QWORD. The data is 8 bytes long and should be interpreted as a 64-bit unsigned integer.
|
||||
*/
|
||||
QWord = 4,
|
||||
/**
|
||||
* WORD. The data is 2 bytes long and should be interpreted as a 16-bit unsigned integer.
|
||||
*/
|
||||
Word = 5
|
||||
}
|
||||
/**
|
||||
* Ref: https://msdn.microsoft.com/en-us/library/windows/desktop/ee663575
|
||||
*/
|
||||
export interface IAsfObjectHeader {
|
||||
/**
|
||||
* A GUID that identifies the object. 128 bits
|
||||
*/
|
||||
objectId: GUID;
|
||||
/**
|
||||
* The size of the object (64-bits)
|
||||
*/
|
||||
objectSize: number;
|
||||
}
|
||||
/**
|
||||
* Interface for: 3. ASF top-level Header Object
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/03_asf_top_level_header_object.html#3
|
||||
*/
|
||||
export interface IAsfTopLevelObjectHeader extends IAsfObjectHeader {
|
||||
numberOfHeaderObjects: number;
|
||||
}
|
||||
/**
|
||||
* Token for: 3. ASF top-level Header Object
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/03_asf_top_level_header_object.html#3
|
||||
*/
|
||||
export declare const TopLevelHeaderObjectToken: IGetToken<IAsfTopLevelObjectHeader, Buffer>;
|
||||
/**
|
||||
* Token for: 3.1 Header Object (mandatory, one only)
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/03_asf_top_level_header_object.html#3_1
|
||||
*/
|
||||
export declare const HeaderObjectToken: IGetToken<IAsfObjectHeader, Buffer>;
|
||||
export declare abstract class State<T> implements IGetToken<T> {
|
||||
len: number;
|
||||
constructor(header: IAsfObjectHeader);
|
||||
abstract get(buf: Buffer, off: number): T;
|
||||
protected postProcessTag(tags: ITag[], name: string, valueType: number, data: any): void;
|
||||
}
|
||||
export declare class IgnoreObjectState extends State<any> {
|
||||
constructor(header: IAsfObjectHeader);
|
||||
get(buf: Buffer, off: number): null;
|
||||
}
|
||||
/**
|
||||
* Interface for: 3.2: File Properties Object (mandatory, one only)
|
||||
*
|
||||
* The File Properties Object defines the global characteristics of the combined digital media streams found within the Data Object.
|
||||
*/
|
||||
export interface IFilePropertiesObject {
|
||||
/**
|
||||
* Specifies the unique identifier for this file.
|
||||
* The value of this field shall be regenerated every time the file is modified in any way.
|
||||
* The value of this field shall be identical to the value of the File ID field of the Data Object.
|
||||
*/
|
||||
fileId: GUID;
|
||||
/**
|
||||
* Specifies the size, in bytes, of the entire file.
|
||||
* The value of this field is invalid if the Broadcast Flag bit in the Flags field is set to 1.
|
||||
*/
|
||||
fileSize: bigint;
|
||||
/**
|
||||
* Specifies the date and time of the initial creation of the file. The value is given as the number of 100-nanosecond
|
||||
* intervals since January 1, 1601, according to Coordinated Universal Time (Greenwich Mean Time). The value of this
|
||||
* field may be invalid if the Broadcast Flag bit in the Flags field is set to 1.
|
||||
*/
|
||||
creationDate: bigint;
|
||||
/**
|
||||
* Specifies the number of Data Packet entries that exist within the Data Object. The value of this field is invalid
|
||||
* if the Broadcast Flag bit in the Flags field is set to 1.
|
||||
*/
|
||||
dataPacketsCount: bigint;
|
||||
/**
|
||||
* Specifies the time needed to play the file in 100-nanosecond units.
|
||||
* This value should include the duration (estimated, if an exact value is unavailable) of the the last media object
|
||||
* in the presentation. The value of this field is invalid if the Broadcast Flag bit in the Flags field is set to 1.
|
||||
*/
|
||||
playDuration: bigint;
|
||||
/**
|
||||
* Specifies the time needed to send the file in 100-nanosecond units.
|
||||
* This value should include the duration of the last packet in the content.
|
||||
* The value of this field is invalid if the Broadcast Flag bit in the Flags field is set to 1.
|
||||
* Players can ignore this value.
|
||||
*/
|
||||
sendDuration: bigint;
|
||||
/**
|
||||
* Specifies the amount of time to buffer data before starting to play the file, in millisecond units.
|
||||
* If this value is nonzero, the Play Duration field and all of the payload Presentation Time fields have been offset
|
||||
* by this amount. Therefore, player software must subtract the value in the preroll field from the play duration and
|
||||
* presentation times to calculate their actual values. It follows that all payload Presentation Time fields need to
|
||||
* be at least this value.
|
||||
*/
|
||||
preroll: bigint;
|
||||
/**
|
||||
* The flags
|
||||
*/
|
||||
flags: {
|
||||
/**
|
||||
* Specifies, if set, that a file is in the process of being created (for example, for recording applications),
|
||||
* and thus that various values stored in the header objects are invalid. It is highly recommended that
|
||||
* post-processing be performed to remove this condition at the earliest opportunity.
|
||||
*/
|
||||
broadcast: boolean;
|
||||
/**
|
||||
* Specifies, if set, that a file is seekable.
|
||||
* Note that for files containing a single audio stream and a Minimum Data Packet Size field equal to the Maximum
|
||||
* Data Packet Size field, this flag shall always be set to 1.
|
||||
* For files containing a single audio stream and a video stream or mutually exclusive video streams,
|
||||
* this flag is only set to 1 if the file contains a matching Simple Index Object for each regular video stream
|
||||
* (that is, video streams that are not hidden according to the method described in section 8.2.2).
|
||||
*/
|
||||
seekable: boolean;
|
||||
};
|
||||
/**
|
||||
* Specifies the minimum Data Packet size in bytes. In general, the value of this field is invalid if the Broadcast
|
||||
* Flag bit in the Flags field is set to 1.
|
||||
* However, for the purposes of this specification, the values for the Minimum Data Packet Size and Maximum Data
|
||||
* Packet Size fields shall be set to the same value, and this value should be set to the packet size, even when the
|
||||
* Broadcast Flag in the Flags field is set to 1.
|
||||
*/
|
||||
minimumDataPacketSize: number;
|
||||
/**
|
||||
* Specifies the maximum Data Packet size in bytes.
|
||||
* In general, the value of this field is invalid if the Broadcast Flag bit in the Flags field is set to 1.
|
||||
* However, for the purposes of this specification, the values of the Minimum Data Packet Size and Maximum Data Packet
|
||||
* Size fields shall be set to the same value,
|
||||
* and this value should be set to the packet size, even when the Broadcast Flag field is set to 1.
|
||||
*/
|
||||
maximumDataPacketSize: number;
|
||||
/**
|
||||
* Specifies the maximum instantaneous bit rate in bits per second for the entire file.
|
||||
* This shall equal the sum of the bit rates of the individual digital media streams.
|
||||
* It shall be noted that the digital media stream includes ASF data packetization overhead as well as digital media
|
||||
* data in payloads.
|
||||
* Only those streams that have a free-standing Stream Properties Object in the header shall have their bit rates
|
||||
* included in the sum;
|
||||
* streams whose Stream Properties Object exists as part of an Extended Stream Properties Object in the Header
|
||||
* Extension Object shall not have their bit rates included in this sum, except when this value would otherwise be 0.
|
||||
*/
|
||||
maximumBitrate: number;
|
||||
}
|
||||
/**
|
||||
* Token for: 3.2: File Properties Object (mandatory, one only)
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/03_asf_top_level_header_object.html#3_2
|
||||
*/
|
||||
export declare class FilePropertiesObject extends State<IFilePropertiesObject> {
|
||||
static guid: GUID;
|
||||
constructor(header: IAsfObjectHeader);
|
||||
get(buf: Buffer, off: number): IFilePropertiesObject;
|
||||
}
|
||||
/**
|
||||
* Interface for: 3.3 Stream Properties Object (mandatory, one per stream)
|
||||
*/
|
||||
export interface IStreamPropertiesObject {
|
||||
/**
|
||||
* Stream Type
|
||||
*/
|
||||
streamType: string;
|
||||
/**
|
||||
* Error Correction Type
|
||||
*/
|
||||
errorCorrectionType: GUID;
|
||||
}
|
||||
/**
|
||||
* Token for: 3.3 Stream Properties Object (mandatory, one per stream)
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/03_asf_top_level_header_object.html#3_3
|
||||
*/
|
||||
export declare class StreamPropertiesObject extends State<IStreamPropertiesObject> {
|
||||
static guid: GUID;
|
||||
constructor(header: IAsfObjectHeader);
|
||||
get(buf: Buffer, off: number): IStreamPropertiesObject;
|
||||
}
|
||||
export interface IHeaderExtensionObject {
|
||||
reserved1: GUID;
|
||||
reserved2: number;
|
||||
extensionDataSize: number;
|
||||
}
|
||||
/**
|
||||
* 3.4: Header Extension Object (mandatory, one only)
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/03_asf_top_level_header_object.html#3_4
|
||||
*/
|
||||
export declare class HeaderExtensionObject implements IGetToken<IHeaderExtensionObject> {
|
||||
static guid: GUID;
|
||||
len: number;
|
||||
constructor();
|
||||
get(buf: Buffer, off: number): IHeaderExtensionObject;
|
||||
}
|
||||
export interface ICodecEntry {
|
||||
type: {
|
||||
videoCodec: boolean;
|
||||
audioCodec: boolean;
|
||||
};
|
||||
codecName: string;
|
||||
description: string;
|
||||
information: Buffer;
|
||||
}
|
||||
/**
|
||||
* 3.5: Read the Codec-List-Object, which provides user-friendly information about the codecs and formats used to encode the content found in the ASF file.
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/03_asf_top_level_header_object.html#3_5
|
||||
*/
|
||||
export declare function readCodecEntries(tokenizer: ITokenizer): Promise<ICodecEntry[]>;
|
||||
/**
|
||||
* 3.10 Content Description Object (optional, one only)
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/03_asf_top_level_header_object.html#3_10
|
||||
*/
|
||||
export declare class ContentDescriptionObjectState extends State<ITag[]> {
|
||||
static guid: GUID;
|
||||
private static contentDescTags;
|
||||
constructor(header: IAsfObjectHeader);
|
||||
get(buf: Buffer, off: number): ITag[];
|
||||
}
|
||||
/**
|
||||
* 3.11 Extended Content Description Object (optional, one only)
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/03_asf_top_level_header_object.html#3_11
|
||||
*/
|
||||
export declare class ExtendedContentDescriptionObjectState extends State<ITag[]> {
|
||||
static guid: GUID;
|
||||
constructor(header: IAsfObjectHeader);
|
||||
get(buf: Buffer, off: number): ITag[];
|
||||
}
|
||||
export interface IStreamName {
|
||||
streamLanguageId: number;
|
||||
streamName: string;
|
||||
}
|
||||
/**
|
||||
* 4.1 Extended Stream Properties Object (optional, 1 per media stream)
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/04_objects_in_the_asf_header_extension_object.html#4_1
|
||||
*/
|
||||
export interface IExtendedStreamPropertiesObject {
|
||||
startTime: bigint;
|
||||
endTime: bigint;
|
||||
dataBitrate: number;
|
||||
bufferSize: number;
|
||||
initialBufferFullness: number;
|
||||
alternateDataBitrate: number;
|
||||
alternateBufferSize: number;
|
||||
alternateInitialBufferFullness: number;
|
||||
maximumObjectSize: number;
|
||||
flags: {
|
||||
reliableFlag: boolean;
|
||||
seekableFlag: boolean;
|
||||
resendLiveCleanpointsFlag: boolean;
|
||||
};
|
||||
streamNumber: number;
|
||||
streamLanguageId: number;
|
||||
averageTimePerFrame: number;
|
||||
streamNameCount: number;
|
||||
payloadExtensionSystems: number;
|
||||
streamNames: IStreamName[];
|
||||
streamPropertiesObject: number;
|
||||
}
|
||||
/**
|
||||
* 4.1 Extended Stream Properties Object (optional, 1 per media stream)
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/04_objects_in_the_asf_header_extension_object.html#4_1
|
||||
*/
|
||||
export declare class ExtendedStreamPropertiesObjectState extends State<IExtendedStreamPropertiesObject> {
|
||||
static guid: GUID;
|
||||
constructor(header: IAsfObjectHeader);
|
||||
get(buf: Buffer, off: number): IExtendedStreamPropertiesObject;
|
||||
}
|
||||
/**
|
||||
* 4.7 Metadata Object (optional, 0 or 1)
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/04_objects_in_the_asf_header_extension_object.html#4_7
|
||||
*/
|
||||
export declare class MetadataObjectState extends State<ITag[]> {
|
||||
static guid: GUID;
|
||||
constructor(header: IAsfObjectHeader);
|
||||
get(buf: Buffer, off: number): ITag[];
|
||||
}
|
||||
export declare class MetadataLibraryObjectState extends MetadataObjectState {
|
||||
static guid: GUID;
|
||||
constructor(header: IAsfObjectHeader);
|
||||
}
|
||||
export interface IWmPicture extends IPicture {
|
||||
type: string;
|
||||
format: string;
|
||||
description: string;
|
||||
size: number;
|
||||
data: Buffer;
|
||||
}
|
||||
/**
|
||||
* Ref: https://msdn.microsoft.com/en-us/library/windows/desktop/dd757977(v=vs.85).aspx
|
||||
*/
|
||||
export declare class WmPictureToken implements IGetToken<IWmPicture> {
|
||||
len: any;
|
||||
static fromBase64(base64str: string): IPicture;
|
||||
static fromBuffer(buffer: Buffer): IWmPicture;
|
||||
constructor(len: any);
|
||||
get(buffer: Buffer, offset: number): IWmPicture;
|
||||
}
|
||||
+384
@@ -0,0 +1,384 @@
|
||||
"use strict";
|
||||
// ASF Objects
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.WmPictureToken = exports.MetadataLibraryObjectState = exports.MetadataObjectState = exports.ExtendedStreamPropertiesObjectState = exports.ExtendedContentDescriptionObjectState = exports.ContentDescriptionObjectState = exports.readCodecEntries = exports.HeaderExtensionObject = exports.StreamPropertiesObject = exports.FilePropertiesObject = exports.IgnoreObjectState = exports.State = exports.HeaderObjectToken = exports.TopLevelHeaderObjectToken = exports.DataType = void 0;
|
||||
const util = require("../common/Util");
|
||||
const Token = require("token-types");
|
||||
const GUID_1 = require("./GUID");
|
||||
const AsfUtil_1 = require("./AsfUtil");
|
||||
const ID3v2Token_1 = require("../id3v2/ID3v2Token");
|
||||
/**
|
||||
* Data Type: Specifies the type of information being stored. The following values are recognized.
|
||||
*/
|
||||
var DataType;
|
||||
(function (DataType) {
|
||||
/**
|
||||
* Unicode string. The data consists of a sequence of Unicode characters.
|
||||
*/
|
||||
DataType[DataType["UnicodeString"] = 0] = "UnicodeString";
|
||||
/**
|
||||
* BYTE array. The type of data is implementation-specific.
|
||||
*/
|
||||
DataType[DataType["ByteArray"] = 1] = "ByteArray";
|
||||
/**
|
||||
* BOOL. The data is 2 bytes long and should be interpreted as a 16-bit unsigned integer. Only 0x0000 or 0x0001 are permitted values.
|
||||
*/
|
||||
DataType[DataType["Bool"] = 2] = "Bool";
|
||||
/**
|
||||
* DWORD. The data is 4 bytes long and should be interpreted as a 32-bit unsigned integer.
|
||||
*/
|
||||
DataType[DataType["DWord"] = 3] = "DWord";
|
||||
/**
|
||||
* QWORD. The data is 8 bytes long and should be interpreted as a 64-bit unsigned integer.
|
||||
*/
|
||||
DataType[DataType["QWord"] = 4] = "QWord";
|
||||
/**
|
||||
* WORD. The data is 2 bytes long and should be interpreted as a 16-bit unsigned integer.
|
||||
*/
|
||||
DataType[DataType["Word"] = 5] = "Word";
|
||||
})(DataType = exports.DataType || (exports.DataType = {}));
|
||||
/**
|
||||
* Token for: 3. ASF top-level Header Object
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/03_asf_top_level_header_object.html#3
|
||||
*/
|
||||
exports.TopLevelHeaderObjectToken = {
|
||||
len: 30,
|
||||
get: (buf, off) => {
|
||||
return {
|
||||
objectId: GUID_1.default.fromBin(new Token.BufferType(16).get(buf, off)),
|
||||
objectSize: Number(Token.UINT64_LE.get(buf, off + 16)),
|
||||
numberOfHeaderObjects: Token.UINT32_LE.get(buf, off + 24)
|
||||
// Reserved: 2 bytes
|
||||
};
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Token for: 3.1 Header Object (mandatory, one only)
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/03_asf_top_level_header_object.html#3_1
|
||||
*/
|
||||
exports.HeaderObjectToken = {
|
||||
len: 24,
|
||||
get: (buf, off) => {
|
||||
return {
|
||||
objectId: GUID_1.default.fromBin(new Token.BufferType(16).get(buf, off)),
|
||||
objectSize: Number(Token.UINT64_LE.get(buf, off + 16))
|
||||
};
|
||||
}
|
||||
};
|
||||
class State {
|
||||
constructor(header) {
|
||||
this.len = Number(header.objectSize) - exports.HeaderObjectToken.len;
|
||||
}
|
||||
postProcessTag(tags, name, valueType, data) {
|
||||
if (name === 'WM/Picture') {
|
||||
tags.push({ id: name, value: WmPictureToken.fromBuffer(data) });
|
||||
}
|
||||
else {
|
||||
const parseAttr = AsfUtil_1.AsfUtil.getParserForAttr(valueType);
|
||||
if (!parseAttr) {
|
||||
throw new Error('unexpected value headerType: ' + valueType);
|
||||
}
|
||||
tags.push({ id: name, value: parseAttr(data) });
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.State = State;
|
||||
// ToDo: use ignore type
|
||||
class IgnoreObjectState extends State {
|
||||
constructor(header) {
|
||||
super(header);
|
||||
}
|
||||
get(buf, off) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
exports.IgnoreObjectState = IgnoreObjectState;
|
||||
/**
|
||||
* Token for: 3.2: File Properties Object (mandatory, one only)
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/03_asf_top_level_header_object.html#3_2
|
||||
*/
|
||||
class FilePropertiesObject extends State {
|
||||
constructor(header) {
|
||||
super(header);
|
||||
}
|
||||
get(buf, off) {
|
||||
return {
|
||||
fileId: GUID_1.default.fromBin(buf, off),
|
||||
fileSize: Token.UINT64_LE.get(buf, off + 16),
|
||||
creationDate: Token.UINT64_LE.get(buf, off + 24),
|
||||
dataPacketsCount: Token.UINT64_LE.get(buf, off + 32),
|
||||
playDuration: Token.UINT64_LE.get(buf, off + 40),
|
||||
sendDuration: Token.UINT64_LE.get(buf, off + 48),
|
||||
preroll: Token.UINT64_LE.get(buf, off + 56),
|
||||
flags: {
|
||||
broadcast: util.getBit(buf, off + 64, 24),
|
||||
seekable: util.getBit(buf, off + 64, 25)
|
||||
},
|
||||
// flagsNumeric: Token.UINT32_LE.get(buf, off + 64),
|
||||
minimumDataPacketSize: Token.UINT32_LE.get(buf, off + 68),
|
||||
maximumDataPacketSize: Token.UINT32_LE.get(buf, off + 72),
|
||||
maximumBitrate: Token.UINT32_LE.get(buf, off + 76)
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.FilePropertiesObject = FilePropertiesObject;
|
||||
FilePropertiesObject.guid = GUID_1.default.FilePropertiesObject;
|
||||
/**
|
||||
* Token for: 3.3 Stream Properties Object (mandatory, one per stream)
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/03_asf_top_level_header_object.html#3_3
|
||||
*/
|
||||
class StreamPropertiesObject extends State {
|
||||
constructor(header) {
|
||||
super(header);
|
||||
}
|
||||
get(buf, off) {
|
||||
return {
|
||||
streamType: GUID_1.default.decodeMediaType(GUID_1.default.fromBin(buf, off)),
|
||||
errorCorrectionType: GUID_1.default.fromBin(buf, off + 8)
|
||||
// ToDo
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.StreamPropertiesObject = StreamPropertiesObject;
|
||||
StreamPropertiesObject.guid = GUID_1.default.StreamPropertiesObject;
|
||||
/**
|
||||
* 3.4: Header Extension Object (mandatory, one only)
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/03_asf_top_level_header_object.html#3_4
|
||||
*/
|
||||
class HeaderExtensionObject {
|
||||
constructor() {
|
||||
this.len = 22;
|
||||
}
|
||||
get(buf, off) {
|
||||
return {
|
||||
reserved1: GUID_1.default.fromBin(buf, off),
|
||||
reserved2: buf.readUInt16LE(off + 16),
|
||||
extensionDataSize: buf.readUInt32LE(off + 18)
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.HeaderExtensionObject = HeaderExtensionObject;
|
||||
HeaderExtensionObject.guid = GUID_1.default.HeaderExtensionObject;
|
||||
/**
|
||||
* 3.5: The Codec List Object provides user-friendly information about the codecs and formats used to encode the content found in the ASF file.
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/03_asf_top_level_header_object.html#3_5
|
||||
*/
|
||||
const CodecListObjectHeader = {
|
||||
len: 20,
|
||||
get: (buf, off) => {
|
||||
return {
|
||||
entryCount: buf.readUInt16LE(off + 16)
|
||||
};
|
||||
}
|
||||
};
|
||||
async function readString(tokenizer) {
|
||||
const length = await tokenizer.readNumber(Token.UINT16_LE);
|
||||
return (await tokenizer.readToken(new Token.StringType(length * 2, 'utf16le'))).replace('\0', '');
|
||||
}
|
||||
/**
|
||||
* 3.5: Read the Codec-List-Object, which provides user-friendly information about the codecs and formats used to encode the content found in the ASF file.
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/03_asf_top_level_header_object.html#3_5
|
||||
*/
|
||||
async function readCodecEntries(tokenizer) {
|
||||
const codecHeader = await tokenizer.readToken(CodecListObjectHeader);
|
||||
const entries = [];
|
||||
for (let i = 0; i < codecHeader.entryCount; ++i) {
|
||||
entries.push(await readCodecEntry(tokenizer));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
exports.readCodecEntries = readCodecEntries;
|
||||
async function readInformation(tokenizer) {
|
||||
const length = await tokenizer.readNumber(Token.UINT16_LE);
|
||||
const buf = Buffer.alloc(length);
|
||||
await tokenizer.readBuffer(buf);
|
||||
return buf;
|
||||
}
|
||||
/**
|
||||
* Read Codec-Entries
|
||||
* @param tokenizer
|
||||
*/
|
||||
async function readCodecEntry(tokenizer) {
|
||||
const type = await tokenizer.readNumber(Token.UINT16_LE);
|
||||
return {
|
||||
type: {
|
||||
videoCodec: (type & 0x0001) === 0x0001,
|
||||
audioCodec: (type & 0x0002) === 0x0002
|
||||
},
|
||||
codecName: await readString(tokenizer),
|
||||
description: await readString(tokenizer),
|
||||
information: await readInformation(tokenizer)
|
||||
};
|
||||
}
|
||||
/**
|
||||
* 3.10 Content Description Object (optional, one only)
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/03_asf_top_level_header_object.html#3_10
|
||||
*/
|
||||
class ContentDescriptionObjectState extends State {
|
||||
constructor(header) {
|
||||
super(header);
|
||||
}
|
||||
get(buf, off) {
|
||||
const tags = [];
|
||||
let pos = off + 10;
|
||||
for (let i = 0; i < ContentDescriptionObjectState.contentDescTags.length; ++i) {
|
||||
const length = buf.readUInt16LE(off + i * 2);
|
||||
if (length > 0) {
|
||||
const tagName = ContentDescriptionObjectState.contentDescTags[i];
|
||||
const end = pos + length;
|
||||
tags.push({ id: tagName, value: AsfUtil_1.AsfUtil.parseUnicodeAttr(buf.slice(pos, end)) });
|
||||
pos = end;
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
exports.ContentDescriptionObjectState = ContentDescriptionObjectState;
|
||||
ContentDescriptionObjectState.guid = GUID_1.default.ContentDescriptionObject;
|
||||
ContentDescriptionObjectState.contentDescTags = ['Title', 'Author', 'Copyright', 'Description', 'Rating'];
|
||||
/**
|
||||
* 3.11 Extended Content Description Object (optional, one only)
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/03_asf_top_level_header_object.html#3_11
|
||||
*/
|
||||
class ExtendedContentDescriptionObjectState extends State {
|
||||
constructor(header) {
|
||||
super(header);
|
||||
}
|
||||
get(buf, off) {
|
||||
const tags = [];
|
||||
const attrCount = buf.readUInt16LE(off);
|
||||
let pos = off + 2;
|
||||
for (let i = 0; i < attrCount; i += 1) {
|
||||
const nameLen = buf.readUInt16LE(pos);
|
||||
pos += 2;
|
||||
const name = AsfUtil_1.AsfUtil.parseUnicodeAttr(buf.slice(pos, pos + nameLen));
|
||||
pos += nameLen;
|
||||
const valueType = buf.readUInt16LE(pos);
|
||||
pos += 2;
|
||||
const valueLen = buf.readUInt16LE(pos);
|
||||
pos += 2;
|
||||
const value = buf.slice(pos, pos + valueLen);
|
||||
pos += valueLen;
|
||||
this.postProcessTag(tags, name, valueType, value);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
exports.ExtendedContentDescriptionObjectState = ExtendedContentDescriptionObjectState;
|
||||
ExtendedContentDescriptionObjectState.guid = GUID_1.default.ExtendedContentDescriptionObject;
|
||||
/**
|
||||
* 4.1 Extended Stream Properties Object (optional, 1 per media stream)
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/04_objects_in_the_asf_header_extension_object.html#4_1
|
||||
*/
|
||||
class ExtendedStreamPropertiesObjectState extends State {
|
||||
constructor(header) {
|
||||
super(header);
|
||||
}
|
||||
get(buf, off) {
|
||||
return {
|
||||
startTime: Token.UINT64_LE.get(buf, off),
|
||||
endTime: Token.UINT64_LE.get(buf, off + 8),
|
||||
dataBitrate: buf.readInt32LE(off + 12),
|
||||
bufferSize: buf.readInt32LE(off + 16),
|
||||
initialBufferFullness: buf.readInt32LE(off + 20),
|
||||
alternateDataBitrate: buf.readInt32LE(off + 24),
|
||||
alternateBufferSize: buf.readInt32LE(off + 28),
|
||||
alternateInitialBufferFullness: buf.readInt32LE(off + 32),
|
||||
maximumObjectSize: buf.readInt32LE(off + 36),
|
||||
flags: {
|
||||
reliableFlag: util.getBit(buf, off + 40, 0),
|
||||
seekableFlag: util.getBit(buf, off + 40, 1),
|
||||
resendLiveCleanpointsFlag: util.getBit(buf, off + 40, 2)
|
||||
},
|
||||
// flagsNumeric: Token.UINT32_LE.get(buf, off + 64),
|
||||
streamNumber: buf.readInt16LE(off + 42),
|
||||
streamLanguageId: buf.readInt16LE(off + 44),
|
||||
averageTimePerFrame: buf.readInt32LE(off + 52),
|
||||
streamNameCount: buf.readInt32LE(off + 54),
|
||||
payloadExtensionSystems: buf.readInt32LE(off + 56),
|
||||
streamNames: [],
|
||||
streamPropertiesObject: null
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.ExtendedStreamPropertiesObjectState = ExtendedStreamPropertiesObjectState;
|
||||
ExtendedStreamPropertiesObjectState.guid = GUID_1.default.ExtendedStreamPropertiesObject;
|
||||
/**
|
||||
* 4.7 Metadata Object (optional, 0 or 1)
|
||||
* Ref: http://drang.s4.xrea.com/program/tips/id3tag/wmp/04_objects_in_the_asf_header_extension_object.html#4_7
|
||||
*/
|
||||
class MetadataObjectState extends State {
|
||||
constructor(header) {
|
||||
super(header);
|
||||
}
|
||||
get(buf, off) {
|
||||
const tags = [];
|
||||
const descriptionRecordsCount = buf.readUInt16LE(off);
|
||||
let pos = off + 2;
|
||||
for (let i = 0; i < descriptionRecordsCount; i += 1) {
|
||||
pos += 4;
|
||||
const nameLen = buf.readUInt16LE(pos);
|
||||
pos += 2;
|
||||
const dataType = buf.readUInt16LE(pos);
|
||||
pos += 2;
|
||||
const dataLen = buf.readUInt32LE(pos);
|
||||
pos += 4;
|
||||
const name = AsfUtil_1.AsfUtil.parseUnicodeAttr(buf.slice(pos, pos + nameLen));
|
||||
pos += nameLen;
|
||||
const data = buf.slice(pos, pos + dataLen);
|
||||
pos += dataLen;
|
||||
const parseAttr = AsfUtil_1.AsfUtil.getParserForAttr(dataType);
|
||||
if (!parseAttr) {
|
||||
throw new Error('unexpected value headerType: ' + dataType);
|
||||
}
|
||||
this.postProcessTag(tags, name, dataType, data);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
exports.MetadataObjectState = MetadataObjectState;
|
||||
MetadataObjectState.guid = GUID_1.default.MetadataObject;
|
||||
// 4.8 Metadata Library Object (optional, 0 or 1)
|
||||
class MetadataLibraryObjectState extends MetadataObjectState {
|
||||
constructor(header) {
|
||||
super(header);
|
||||
}
|
||||
}
|
||||
exports.MetadataLibraryObjectState = MetadataLibraryObjectState;
|
||||
MetadataLibraryObjectState.guid = GUID_1.default.MetadataLibraryObject;
|
||||
/**
|
||||
* Ref: https://msdn.microsoft.com/en-us/library/windows/desktop/dd757977(v=vs.85).aspx
|
||||
*/
|
||||
class WmPictureToken {
|
||||
constructor(len) {
|
||||
this.len = len;
|
||||
}
|
||||
static fromBase64(base64str) {
|
||||
return this.fromBuffer(Buffer.from(base64str, 'base64'));
|
||||
}
|
||||
static fromBuffer(buffer) {
|
||||
const pic = new WmPictureToken(buffer.length);
|
||||
return pic.get(buffer, 0);
|
||||
}
|
||||
get(buffer, offset) {
|
||||
const typeId = buffer.readUInt8(offset++);
|
||||
const size = buffer.readInt32LE(offset);
|
||||
let index = 5;
|
||||
while (buffer.readUInt16BE(index) !== 0) {
|
||||
index += 2;
|
||||
}
|
||||
const format = buffer.slice(5, index).toString('utf16le');
|
||||
while (buffer.readUInt16BE(index) !== 0) {
|
||||
index += 2;
|
||||
}
|
||||
const description = buffer.slice(5, index).toString('utf16le');
|
||||
return {
|
||||
type: ID3v2Token_1.AttachedPictureType[typeId],
|
||||
format,
|
||||
description,
|
||||
size,
|
||||
data: buffer.slice(index + 4)
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.WmPictureToken = WmPictureToken;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { BasicParser } from '../common/BasicParser';
|
||||
/**
|
||||
* Windows Media Metadata Usage Guidelines
|
||||
* Ref: https://msdn.microsoft.com/en-us/library/ms867702.aspx
|
||||
*
|
||||
* Ref:
|
||||
* https://tools.ietf.org/html/draft-fleischman-asf-01
|
||||
* https://hwiegman.home.xs4all.nl/fileformats/asf/ASF_Specification.pdf
|
||||
* http://drang.s4.xrea.com/program/tips/id3tag/wmp/index.html
|
||||
* https://msdn.microsoft.com/en-us/library/windows/desktop/ee663575(v=vs.85).aspx
|
||||
*/
|
||||
export declare class AsfParser extends BasicParser {
|
||||
parse(): Promise<void>;
|
||||
private parseObjectHeader;
|
||||
private addTags;
|
||||
private parseExtensionObject;
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AsfParser = void 0;
|
||||
const type_1 = require("../type");
|
||||
const GUID_1 = require("./GUID");
|
||||
const AsfObject = require("./AsfObject");
|
||||
const _debug = require("debug");
|
||||
const BasicParser_1 = require("../common/BasicParser");
|
||||
const debug = _debug('music-metadata:parser:ASF');
|
||||
const headerType = 'asf';
|
||||
/**
|
||||
* Windows Media Metadata Usage Guidelines
|
||||
* Ref: https://msdn.microsoft.com/en-us/library/ms867702.aspx
|
||||
*
|
||||
* Ref:
|
||||
* https://tools.ietf.org/html/draft-fleischman-asf-01
|
||||
* https://hwiegman.home.xs4all.nl/fileformats/asf/ASF_Specification.pdf
|
||||
* http://drang.s4.xrea.com/program/tips/id3tag/wmp/index.html
|
||||
* https://msdn.microsoft.com/en-us/library/windows/desktop/ee663575(v=vs.85).aspx
|
||||
*/
|
||||
class AsfParser extends BasicParser_1.BasicParser {
|
||||
async parse() {
|
||||
const header = await this.tokenizer.readToken(AsfObject.TopLevelHeaderObjectToken);
|
||||
if (!header.objectId.equals(GUID_1.default.HeaderObject)) {
|
||||
throw new Error('expected asf header; but was not found; got: ' + header.objectId.str);
|
||||
}
|
||||
try {
|
||||
await this.parseObjectHeader(header.numberOfHeaderObjects);
|
||||
}
|
||||
catch (err) {
|
||||
debug('Error while parsing ASF: %s', err);
|
||||
}
|
||||
}
|
||||
async parseObjectHeader(numberOfObjectHeaders) {
|
||||
let tags;
|
||||
do {
|
||||
// Parse common header of the ASF Object (3.1)
|
||||
const header = await this.tokenizer.readToken(AsfObject.HeaderObjectToken);
|
||||
// Parse data part of the ASF Object
|
||||
debug('header GUID=%s', header.objectId.str);
|
||||
switch (header.objectId.str) {
|
||||
case AsfObject.FilePropertiesObject.guid.str: // 3.2
|
||||
const fpo = await this.tokenizer.readToken(new AsfObject.FilePropertiesObject(header));
|
||||
this.metadata.setFormat('duration', Number(fpo.playDuration / BigInt(1000)) / 10000 - Number(fpo.preroll) / 1000);
|
||||
this.metadata.setFormat('bitrate', fpo.maximumBitrate);
|
||||
break;
|
||||
case AsfObject.StreamPropertiesObject.guid.str: // 3.3
|
||||
const spo = await this.tokenizer.readToken(new AsfObject.StreamPropertiesObject(header));
|
||||
this.metadata.setFormat('container', 'ASF/' + spo.streamType);
|
||||
break;
|
||||
case AsfObject.HeaderExtensionObject.guid.str: // 3.4
|
||||
const extHeader = await this.tokenizer.readToken(new AsfObject.HeaderExtensionObject());
|
||||
await this.parseExtensionObject(extHeader.extensionDataSize);
|
||||
break;
|
||||
case AsfObject.ContentDescriptionObjectState.guid.str: // 3.10
|
||||
tags = await this.tokenizer.readToken(new AsfObject.ContentDescriptionObjectState(header));
|
||||
this.addTags(tags);
|
||||
break;
|
||||
case AsfObject.ExtendedContentDescriptionObjectState.guid.str: // 3.11
|
||||
tags = await this.tokenizer.readToken(new AsfObject.ExtendedContentDescriptionObjectState(header));
|
||||
this.addTags(tags);
|
||||
break;
|
||||
case GUID_1.default.CodecListObject.str:
|
||||
const codecs = await AsfObject.readCodecEntries(this.tokenizer);
|
||||
codecs.forEach(codec => {
|
||||
this.metadata.addStreamInfo({
|
||||
type: codec.type.videoCodec ? type_1.TrackType.video : type_1.TrackType.audio,
|
||||
codecName: codec.codecName
|
||||
});
|
||||
});
|
||||
const audioCodecs = codecs.filter(codec => codec.type.audioCodec).map(codec => codec.codecName).join('/');
|
||||
this.metadata.setFormat('codec', audioCodecs);
|
||||
break;
|
||||
case GUID_1.default.StreamBitratePropertiesObject.str:
|
||||
// ToDo?
|
||||
await this.tokenizer.ignore(header.objectSize - AsfObject.HeaderObjectToken.len);
|
||||
break;
|
||||
case GUID_1.default.PaddingObject.str:
|
||||
// ToDo: register bytes pad
|
||||
debug('Padding: %s bytes', header.objectSize - AsfObject.HeaderObjectToken.len);
|
||||
await this.tokenizer.ignore(header.objectSize - AsfObject.HeaderObjectToken.len);
|
||||
break;
|
||||
default:
|
||||
this.metadata.addWarning('Ignore ASF-Object-GUID: ' + header.objectId.str);
|
||||
debug('Ignore ASF-Object-GUID: %s', header.objectId.str);
|
||||
await this.tokenizer.readToken(new AsfObject.IgnoreObjectState(header));
|
||||
}
|
||||
} while (--numberOfObjectHeaders);
|
||||
// done
|
||||
}
|
||||
addTags(tags) {
|
||||
tags.forEach(tag => {
|
||||
this.metadata.addTag(headerType, tag.id, tag.value);
|
||||
});
|
||||
}
|
||||
async parseExtensionObject(extensionSize) {
|
||||
do {
|
||||
// Parse common header of the ASF Object (3.1)
|
||||
const header = await this.tokenizer.readToken(AsfObject.HeaderObjectToken);
|
||||
const remaining = header.objectSize - AsfObject.HeaderObjectToken.len;
|
||||
// Parse data part of the ASF Object
|
||||
switch (header.objectId.str) {
|
||||
case AsfObject.ExtendedStreamPropertiesObjectState.guid.str: // 4.1
|
||||
// ToDo: extended stream header properties are ignored
|
||||
await this.tokenizer.readToken(new AsfObject.ExtendedStreamPropertiesObjectState(header));
|
||||
break;
|
||||
case AsfObject.MetadataObjectState.guid.str: // 4.7
|
||||
const moTags = await this.tokenizer.readToken(new AsfObject.MetadataObjectState(header));
|
||||
this.addTags(moTags);
|
||||
break;
|
||||
case AsfObject.MetadataLibraryObjectState.guid.str: // 4.8
|
||||
const mlTags = await this.tokenizer.readToken(new AsfObject.MetadataLibraryObjectState(header));
|
||||
this.addTags(mlTags);
|
||||
break;
|
||||
case GUID_1.default.PaddingObject.str:
|
||||
// ToDo: register bytes pad
|
||||
await this.tokenizer.ignore(remaining);
|
||||
break;
|
||||
case GUID_1.default.CompatibilityObject.str:
|
||||
this.tokenizer.ignore(remaining);
|
||||
break;
|
||||
case GUID_1.default.ASF_Index_Placeholder_Object.str:
|
||||
await this.tokenizer.ignore(remaining);
|
||||
break;
|
||||
default:
|
||||
this.metadata.addWarning('Ignore ASF-Object-GUID: ' + header.objectId.str);
|
||||
// console.log("Ignore ASF-Object-GUID: %s", header.objectId.str);
|
||||
await this.tokenizer.readToken(new AsfObject.IgnoreObjectState(header));
|
||||
break;
|
||||
}
|
||||
extensionSize -= header.objectSize;
|
||||
} while (extensionSize > 0);
|
||||
}
|
||||
}
|
||||
exports.AsfParser = AsfParser;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { CommonTagMapper } from '../common/GenericTagMapper';
|
||||
import { IRating, ITag } from '../type';
|
||||
export declare class AsfTagMapper extends CommonTagMapper {
|
||||
static toRating(rating: string): IRating;
|
||||
constructor();
|
||||
protected postMap(tag: ITag): void;
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AsfTagMapper = void 0;
|
||||
const GenericTagMapper_1 = require("../common/GenericTagMapper");
|
||||
/**
|
||||
* ASF Metadata tag mappings.
|
||||
* See http://msdn.microsoft.com/en-us/library/ms867702.aspx
|
||||
*/
|
||||
const asfTagMap = {
|
||||
Title: 'title',
|
||||
Author: 'artist',
|
||||
'WM/AlbumArtist': 'albumartist',
|
||||
'WM/AlbumTitle': 'album',
|
||||
'WM/Year': 'date',
|
||||
'WM/OriginalReleaseTime': 'originaldate',
|
||||
'WM/OriginalReleaseYear': 'originalyear',
|
||||
Description: 'comment',
|
||||
'WM/TrackNumber': 'track',
|
||||
'WM/PartOfSet': 'disk',
|
||||
'WM/Genre': 'genre',
|
||||
'WM/Composer': 'composer',
|
||||
'WM/Lyrics': 'lyrics',
|
||||
'WM/AlbumSortOrder': 'albumsort',
|
||||
'WM/TitleSortOrder': 'titlesort',
|
||||
'WM/ArtistSortOrder': 'artistsort',
|
||||
'WM/AlbumArtistSortOrder': 'albumartistsort',
|
||||
'WM/ComposerSortOrder': 'composersort',
|
||||
'WM/Writer': 'lyricist',
|
||||
'WM/Conductor': 'conductor',
|
||||
'WM/ModifiedBy': 'remixer',
|
||||
'WM/Engineer': 'engineer',
|
||||
'WM/Producer': 'producer',
|
||||
'WM/DJMixer': 'djmixer',
|
||||
'WM/Mixer': 'mixer',
|
||||
'WM/Publisher': 'label',
|
||||
'WM/ContentGroupDescription': 'grouping',
|
||||
'WM/SubTitle': 'subtitle',
|
||||
'WM/SetSubTitle': 'discsubtitle',
|
||||
// 'WM/PartOfSet': 'totaldiscs',
|
||||
'WM/IsCompilation': 'compilation',
|
||||
'WM/SharedUserRating': 'rating',
|
||||
'WM/BeatsPerMinute': 'bpm',
|
||||
'WM/Mood': 'mood',
|
||||
'WM/Media': 'media',
|
||||
'WM/CatalogNo': 'catalognumber',
|
||||
'MusicBrainz/Album Status': 'releasestatus',
|
||||
'MusicBrainz/Album Type': 'releasetype',
|
||||
'MusicBrainz/Album Release Country': 'releasecountry',
|
||||
'WM/Script': 'script',
|
||||
'WM/Language': 'language',
|
||||
Copyright: 'copyright',
|
||||
LICENSE: 'license',
|
||||
'WM/EncodedBy': 'encodedby',
|
||||
'WM/EncodingSettings': 'encodersettings',
|
||||
'WM/Barcode': 'barcode',
|
||||
'WM/ISRC': 'isrc',
|
||||
'MusicBrainz/Track Id': 'musicbrainz_recordingid',
|
||||
'MusicBrainz/Release Track Id': 'musicbrainz_trackid',
|
||||
'MusicBrainz/Album Id': 'musicbrainz_albumid',
|
||||
'MusicBrainz/Artist Id': 'musicbrainz_artistid',
|
||||
'MusicBrainz/Album Artist Id': 'musicbrainz_albumartistid',
|
||||
'MusicBrainz/Release Group Id': 'musicbrainz_releasegroupid',
|
||||
'MusicBrainz/Work Id': 'musicbrainz_workid',
|
||||
'MusicBrainz/TRM Id': 'musicbrainz_trmid',
|
||||
'MusicBrainz/Disc Id': 'musicbrainz_discid',
|
||||
'Acoustid/Id': 'acoustid_id',
|
||||
'Acoustid/Fingerprint': 'acoustid_fingerprint',
|
||||
'MusicIP/PUID': 'musicip_puid',
|
||||
'WM/ARTISTS': 'artists',
|
||||
'WM/InitialKey': 'key',
|
||||
ASIN: 'asin',
|
||||
'WM/Work': 'work',
|
||||
'WM/AuthorURL': 'website',
|
||||
'WM/Picture': 'picture'
|
||||
};
|
||||
class AsfTagMapper extends GenericTagMapper_1.CommonTagMapper {
|
||||
static toRating(rating) {
|
||||
return {
|
||||
rating: parseFloat(rating + 1) / 5
|
||||
};
|
||||
}
|
||||
constructor() {
|
||||
super(['asf'], asfTagMap);
|
||||
}
|
||||
postMap(tag) {
|
||||
switch (tag.id) {
|
||||
case 'WM/SharedUserRating':
|
||||
const keys = tag.id.split(':');
|
||||
tag.value = AsfTagMapper.toRating(tag.value);
|
||||
tag.id = keys[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.AsfTagMapper = AsfTagMapper;
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/// <reference types="node" />
|
||||
import { DataType } from "./AsfObject";
|
||||
export declare type AttributeParser = (buf: Buffer) => boolean | string | number | bigint | Buffer;
|
||||
export declare class AsfUtil {
|
||||
static getParserForAttr(i: DataType): AttributeParser;
|
||||
static parseUnicodeAttr(buf: any): string;
|
||||
private static attributeParsers;
|
||||
private static parseByteArrayAttr;
|
||||
private static parseBoolAttr;
|
||||
private static parseDWordAttr;
|
||||
private static parseQWordAttr;
|
||||
private static parseWordAttr;
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AsfUtil = void 0;
|
||||
const util = require("../common/Util");
|
||||
const Token = require("token-types");
|
||||
class AsfUtil {
|
||||
static getParserForAttr(i) {
|
||||
return AsfUtil.attributeParsers[i];
|
||||
}
|
||||
static parseUnicodeAttr(buf) {
|
||||
return util.stripNulls(util.decodeString(buf, 'utf16le'));
|
||||
}
|
||||
static parseByteArrayAttr(buf) {
|
||||
const newBuf = Buffer.alloc(buf.length);
|
||||
buf.copy(newBuf);
|
||||
return newBuf;
|
||||
}
|
||||
static parseBoolAttr(buf, offset = 0) {
|
||||
return AsfUtil.parseWordAttr(buf, offset) === 1;
|
||||
}
|
||||
static parseDWordAttr(buf, offset = 0) {
|
||||
return buf.readUInt32LE(offset);
|
||||
}
|
||||
static parseQWordAttr(buf, offset = 0) {
|
||||
return Token.UINT64_LE.get(buf, offset);
|
||||
}
|
||||
static parseWordAttr(buf, offset = 0) {
|
||||
return buf.readUInt16LE(offset);
|
||||
}
|
||||
}
|
||||
exports.AsfUtil = AsfUtil;
|
||||
AsfUtil.attributeParsers = [
|
||||
AsfUtil.parseUnicodeAttr,
|
||||
AsfUtil.parseByteArrayAttr,
|
||||
AsfUtil.parseBoolAttr,
|
||||
AsfUtil.parseDWordAttr,
|
||||
AsfUtil.parseQWordAttr,
|
||||
AsfUtil.parseWordAttr,
|
||||
AsfUtil.parseByteArrayAttr
|
||||
];
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/// <reference types="node" />
|
||||
/**
|
||||
* Ref:
|
||||
* https://tools.ietf.org/html/draft-fleischman-asf-01, Appendix A: ASF GUIDs
|
||||
* http://drang.s4.xrea.com/program/tips/id3tag/wmp/10_asf_guids.html
|
||||
* http://drang.s4.xrea.com/program/tips/id3tag/wmp/index.html
|
||||
*
|
||||
* http://drang.s4.xrea.com/program/tips/id3tag/wmp/10_asf_guids.html
|
||||
*
|
||||
* ASF File Structure:
|
||||
* https://msdn.microsoft.com/en-us/library/windows/desktop/ee663575(v=vs.85).aspx
|
||||
*
|
||||
* ASF GUIDs:
|
||||
* http://drang.s4.xrea.com/program/tips/id3tag/wmp/10_asf_guids.html
|
||||
*
|
||||
* https://github.com/dji-sdk/FFmpeg/blob/master/libavformat/asf.c
|
||||
*/
|
||||
export default class GUID {
|
||||
str: string;
|
||||
static HeaderObject: GUID;
|
||||
static DataObject: GUID;
|
||||
static SimpleIndexObject: GUID;
|
||||
static IndexObject: GUID;
|
||||
static MediaObjectIndexObject: GUID;
|
||||
static TimecodeIndexObject: GUID;
|
||||
static FilePropertiesObject: GUID;
|
||||
static StreamPropertiesObject: GUID;
|
||||
static HeaderExtensionObject: GUID;
|
||||
static CodecListObject: GUID;
|
||||
static ScriptCommandObject: GUID;
|
||||
static MarkerObject: GUID;
|
||||
static BitrateMutualExclusionObject: GUID;
|
||||
static ErrorCorrectionObject: GUID;
|
||||
static ContentDescriptionObject: GUID;
|
||||
static ExtendedContentDescriptionObject: GUID;
|
||||
static ContentBrandingObject: GUID;
|
||||
static StreamBitratePropertiesObject: GUID;
|
||||
static ContentEncryptionObject: GUID;
|
||||
static ExtendedContentEncryptionObject: GUID;
|
||||
static DigitalSignatureObject: GUID;
|
||||
static PaddingObject: GUID;
|
||||
static ExtendedStreamPropertiesObject: GUID;
|
||||
static AdvancedMutualExclusionObject: GUID;
|
||||
static GroupMutualExclusionObject: GUID;
|
||||
static StreamPrioritizationObject: GUID;
|
||||
static BandwidthSharingObject: GUID;
|
||||
static LanguageListObject: GUID;
|
||||
static MetadataObject: GUID;
|
||||
static MetadataLibraryObject: GUID;
|
||||
static IndexParametersObject: GUID;
|
||||
static MediaObjectIndexParametersObject: GUID;
|
||||
static TimecodeIndexParametersObject: GUID;
|
||||
static CompatibilityObject: GUID;
|
||||
static AdvancedContentEncryptionObject: GUID;
|
||||
static AudioMedia: GUID;
|
||||
static VideoMedia: GUID;
|
||||
static CommandMedia: GUID;
|
||||
static JFIF_Media: GUID;
|
||||
static Degradable_JPEG_Media: GUID;
|
||||
static FileTransferMedia: GUID;
|
||||
static BinaryMedia: GUID;
|
||||
static ASF_Index_Placeholder_Object: GUID;
|
||||
static fromBin(bin: Buffer, offset?: number): GUID;
|
||||
/**
|
||||
* Decode GUID in format like "B503BF5F-2EA9-CF11-8EE3-00C00C205365"
|
||||
* @param objectId Binary GUID
|
||||
* @param offset Read offset in bytes, default 0
|
||||
* @returns {string} GUID as dashed hexadecimal representation
|
||||
*/
|
||||
static decode(objectId: Buffer, offset?: number): string;
|
||||
/**
|
||||
* Decode stream type
|
||||
* @param {string} mediaType
|
||||
* @returns {string}
|
||||
*/
|
||||
static decodeMediaType(mediaType: GUID): string;
|
||||
/**
|
||||
* Encode GUID
|
||||
* @param guid GUID like: "B503BF5F-2EA9-CF11-8EE3-00C00C205365"
|
||||
* @returns {Buffer} Encoded Bnary GUID
|
||||
*/
|
||||
static encode(str: string): Buffer;
|
||||
constructor(str: string);
|
||||
equals(guid: GUID): boolean;
|
||||
toBin(): Buffer;
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
/**
|
||||
* Ref:
|
||||
* https://tools.ietf.org/html/draft-fleischman-asf-01, Appendix A: ASF GUIDs
|
||||
* http://drang.s4.xrea.com/program/tips/id3tag/wmp/10_asf_guids.html
|
||||
* http://drang.s4.xrea.com/program/tips/id3tag/wmp/index.html
|
||||
*
|
||||
* http://drang.s4.xrea.com/program/tips/id3tag/wmp/10_asf_guids.html
|
||||
*
|
||||
* ASF File Structure:
|
||||
* https://msdn.microsoft.com/en-us/library/windows/desktop/ee663575(v=vs.85).aspx
|
||||
*
|
||||
* ASF GUIDs:
|
||||
* http://drang.s4.xrea.com/program/tips/id3tag/wmp/10_asf_guids.html
|
||||
*
|
||||
* https://github.com/dji-sdk/FFmpeg/blob/master/libavformat/asf.c
|
||||
*/
|
||||
class GUID {
|
||||
constructor(str) {
|
||||
this.str = str;
|
||||
}
|
||||
static fromBin(bin, offset = 0) {
|
||||
return new GUID(this.decode(bin, offset));
|
||||
}
|
||||
/**
|
||||
* Decode GUID in format like "B503BF5F-2EA9-CF11-8EE3-00C00C205365"
|
||||
* @param objectId Binary GUID
|
||||
* @param offset Read offset in bytes, default 0
|
||||
* @returns {string} GUID as dashed hexadecimal representation
|
||||
*/
|
||||
static decode(objectId, offset = 0) {
|
||||
const guid = objectId.readUInt32LE(offset).toString(16) + "-" +
|
||||
objectId.readUInt16LE(offset + 4).toString(16) + "-" +
|
||||
objectId.readUInt16LE(offset + 6).toString(16) + "-" +
|
||||
objectId.readUInt16BE(offset + 8).toString(16) + "-" +
|
||||
objectId.slice(offset + 10, offset + 16).toString('hex');
|
||||
return guid.toUpperCase();
|
||||
}
|
||||
/**
|
||||
* Decode stream type
|
||||
* @param {string} mediaType
|
||||
* @returns {string}
|
||||
*/
|
||||
static decodeMediaType(mediaType) {
|
||||
switch (mediaType.str) {
|
||||
case GUID.AudioMedia.str: return 'audio';
|
||||
case GUID.VideoMedia.str: return 'video';
|
||||
case GUID.CommandMedia.str: return 'command';
|
||||
case GUID.Degradable_JPEG_Media.str: return 'degradable-jpeg';
|
||||
case GUID.FileTransferMedia.str: return 'file-transfer';
|
||||
case GUID.BinaryMedia.str: return 'binary';
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Encode GUID
|
||||
* @param guid GUID like: "B503BF5F-2EA9-CF11-8EE3-00C00C205365"
|
||||
* @returns {Buffer} Encoded Bnary GUID
|
||||
*/
|
||||
static encode(str) {
|
||||
const bin = Buffer.alloc(16);
|
||||
bin.writeUInt32LE(parseInt(str.slice(0, 8), 16), 0);
|
||||
bin.writeUInt16LE(parseInt(str.slice(9, 13), 16), 4);
|
||||
bin.writeUInt16LE(parseInt(str.slice(14, 18), 16), 6);
|
||||
Buffer.from(str.slice(19, 23), "hex").copy(bin, 8);
|
||||
Buffer.from(str.slice(24), "hex").copy(bin, 10);
|
||||
return bin;
|
||||
}
|
||||
equals(guid) {
|
||||
return this.str === guid.str;
|
||||
}
|
||||
toBin() {
|
||||
return GUID.encode(this.str);
|
||||
}
|
||||
}
|
||||
exports.default = GUID;
|
||||
// 10.1 Top-level ASF object GUIDs
|
||||
GUID.HeaderObject = new GUID("75B22630-668E-11CF-A6D9-00AA0062CE6C");
|
||||
GUID.DataObject = new GUID("75B22636-668E-11CF-A6D9-00AA0062CE6C");
|
||||
GUID.SimpleIndexObject = new GUID("33000890-E5B1-11CF-89F4-00A0C90349CB");
|
||||
GUID.IndexObject = new GUID("D6E229D3-35DA-11D1-9034-00A0C90349BE");
|
||||
GUID.MediaObjectIndexObject = new GUID("FEB103F8-12AD-4C64-840F-2A1D2F7AD48C");
|
||||
GUID.TimecodeIndexObject = new GUID("3CB73FD0-0C4A-4803-953D-EDF7B6228F0C");
|
||||
// 10.2 Header Object GUIDs
|
||||
GUID.FilePropertiesObject = new GUID("8CABDCA1-A947-11CF-8EE4-00C00C205365");
|
||||
GUID.StreamPropertiesObject = new GUID("B7DC0791-A9B7-11CF-8EE6-00C00C205365");
|
||||
GUID.HeaderExtensionObject = new GUID("5FBF03B5-A92E-11CF-8EE3-00C00C205365");
|
||||
GUID.CodecListObject = new GUID("86D15240-311D-11D0-A3A4-00A0C90348F6");
|
||||
GUID.ScriptCommandObject = new GUID("1EFB1A30-0B62-11D0-A39B-00A0C90348F6");
|
||||
GUID.MarkerObject = new GUID("F487CD01-A951-11CF-8EE6-00C00C205365");
|
||||
GUID.BitrateMutualExclusionObject = new GUID("D6E229DC-35DA-11D1-9034-00A0C90349BE");
|
||||
GUID.ErrorCorrectionObject = new GUID("75B22635-668E-11CF-A6D9-00AA0062CE6C");
|
||||
GUID.ContentDescriptionObject = new GUID("75B22633-668E-11CF-A6D9-00AA0062CE6C");
|
||||
GUID.ExtendedContentDescriptionObject = new GUID("D2D0A440-E307-11D2-97F0-00A0C95EA850");
|
||||
GUID.ContentBrandingObject = new GUID("2211B3FA-BD23-11D2-B4B7-00A0C955FC6E");
|
||||
GUID.StreamBitratePropertiesObject = new GUID("7BF875CE-468D-11D1-8D82-006097C9A2B2");
|
||||
GUID.ContentEncryptionObject = new GUID("2211B3FB-BD23-11D2-B4B7-00A0C955FC6E");
|
||||
GUID.ExtendedContentEncryptionObject = new GUID("298AE614-2622-4C17-B935-DAE07EE9289C");
|
||||
GUID.DigitalSignatureObject = new GUID("2211B3FC-BD23-11D2-B4B7-00A0C955FC6E");
|
||||
GUID.PaddingObject = new GUID("1806D474-CADF-4509-A4BA-9AABCB96AAE8");
|
||||
// 10.3 Header Extension Object GUIDs
|
||||
GUID.ExtendedStreamPropertiesObject = new GUID("14E6A5CB-C672-4332-8399-A96952065B5A");
|
||||
GUID.AdvancedMutualExclusionObject = new GUID("A08649CF-4775-4670-8A16-6E35357566CD");
|
||||
GUID.GroupMutualExclusionObject = new GUID("D1465A40-5A79-4338-B71B-E36B8FD6C249");
|
||||
GUID.StreamPrioritizationObject = new GUID("D4FED15B-88D3-454F-81F0-ED5C45999E24");
|
||||
GUID.BandwidthSharingObject = new GUID("A69609E6-517B-11D2-B6AF-00C04FD908E9");
|
||||
GUID.LanguageListObject = new GUID("7C4346A9-EFE0-4BFC-B229-393EDE415C85");
|
||||
GUID.MetadataObject = new GUID("C5F8CBEA-5BAF-4877-8467-AA8C44FA4CCA");
|
||||
GUID.MetadataLibraryObject = new GUID("44231C94-9498-49D1-A141-1D134E457054");
|
||||
GUID.IndexParametersObject = new GUID("D6E229DF-35DA-11D1-9034-00A0C90349BE");
|
||||
GUID.MediaObjectIndexParametersObject = new GUID("6B203BAD-3F11-48E4-ACA8-D7613DE2CFA7");
|
||||
GUID.TimecodeIndexParametersObject = new GUID("F55E496D-9797-4B5D-8C8B-604DFE9BFB24");
|
||||
GUID.CompatibilityObject = new GUID("26F18B5D-4584-47EC-9F5F-0E651F0452C9");
|
||||
GUID.AdvancedContentEncryptionObject = new GUID("43058533-6981-49E6-9B74-AD12CB86D58C");
|
||||
// 10.4 Stream Properties Object Stream Type GUIDs
|
||||
GUID.AudioMedia = new GUID("F8699E40-5B4D-11CF-A8FD-00805F5C442B");
|
||||
GUID.VideoMedia = new GUID("BC19EFC0-5B4D-11CF-A8FD-00805F5C442B");
|
||||
GUID.CommandMedia = new GUID("59DACFC0-59E6-11D0-A3AC-00A0C90348F6");
|
||||
GUID.JFIF_Media = new GUID("B61BE100-5B4E-11CF-A8FD-00805F5C442B");
|
||||
GUID.Degradable_JPEG_Media = new GUID("35907DE0-E415-11CF-A917-00805F5C442B");
|
||||
GUID.FileTransferMedia = new GUID("91BD222C-F21C-497A-8B6D-5AA86BFC0185");
|
||||
GUID.BinaryMedia = new GUID("3AFB65E2-47EF-40F2-AC2C-70A90D71D343");
|
||||
GUID.ASF_Index_Placeholder_Object = new GUID("D9AADE20-7C17-4F9C-BC28-8555DD98E2A2");
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { ITokenizer } from 'strtok3/lib/core';
|
||||
import { ITokenParser } from '../ParserFactory';
|
||||
import { IOptions, IPrivateOptions } from '../type';
|
||||
import { INativeMetadataCollector } from './MetadataCollector';
|
||||
export declare abstract class BasicParser implements ITokenParser {
|
||||
protected metadata: INativeMetadataCollector;
|
||||
protected tokenizer: ITokenizer;
|
||||
protected options: IPrivateOptions;
|
||||
/**
|
||||
* Initialize parser with output (metadata), input (tokenizer) & parsing options (options).
|
||||
* @param {INativeMetadataCollector} metadata Output
|
||||
* @param {ITokenizer} tokenizer Input
|
||||
* @param {IOptions} options Parsing options
|
||||
*/
|
||||
init(metadata: INativeMetadataCollector, tokenizer: ITokenizer, options: IOptions): ITokenParser;
|
||||
abstract parse(): any;
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.BasicParser = void 0;
|
||||
class BasicParser {
|
||||
/**
|
||||
* Initialize parser with output (metadata), input (tokenizer) & parsing options (options).
|
||||
* @param {INativeMetadataCollector} metadata Output
|
||||
* @param {ITokenizer} tokenizer Input
|
||||
* @param {IOptions} options Parsing options
|
||||
*/
|
||||
init(metadata, tokenizer, options) {
|
||||
this.metadata = metadata;
|
||||
this.tokenizer = tokenizer;
|
||||
this.options = options;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
exports.BasicParser = BasicParser;
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { INativeTagMap, TagType } from './GenericTagTypes';
|
||||
import { CommonTagMapper } from './GenericTagMapper';
|
||||
export declare class CaseInsensitiveTagMap extends CommonTagMapper {
|
||||
constructor(tagTypes: TagType[], tagMap: INativeTagMap);
|
||||
/**
|
||||
* @tag Native header tag
|
||||
* @return common tag name (alias)
|
||||
*/
|
||||
protected getCommonName(tag: string): import("./GenericTagTypes").GenericTagId;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.CaseInsensitiveTagMap = void 0;
|
||||
const GenericTagMapper_1 = require("./GenericTagMapper");
|
||||
class CaseInsensitiveTagMap extends GenericTagMapper_1.CommonTagMapper {
|
||||
constructor(tagTypes, tagMap) {
|
||||
const upperCaseMap = {};
|
||||
for (const tag of Object.keys(tagMap)) {
|
||||
upperCaseMap[tag.toUpperCase()] = tagMap[tag];
|
||||
}
|
||||
super(tagTypes, upperCaseMap);
|
||||
}
|
||||
/**
|
||||
* @tag Native header tag
|
||||
* @return common tag name (alias)
|
||||
*/
|
||||
getCommonName(tag) {
|
||||
return this.tagMap[tag.toUpperCase()];
|
||||
}
|
||||
}
|
||||
exports.CaseInsensitiveTagMap = CaseInsensitiveTagMap;
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { IGenericTag, TagType } from "./GenericTagTypes";
|
||||
import { IGenericTagMapper } from "./GenericTagMapper";
|
||||
import { ITag } from "../type";
|
||||
import { INativeMetadataCollector } from './MetadataCollector';
|
||||
export declare class CombinedTagMapper {
|
||||
tagMappers: {
|
||||
[index: string]: IGenericTagMapper;
|
||||
};
|
||||
constructor();
|
||||
/**
|
||||
* Convert native to generic (common) tags
|
||||
* @param tagType Originating tag format
|
||||
* @param tag Native tag to map to a generic tag id
|
||||
* @param warnings
|
||||
* @return Generic tag result (output of this function)
|
||||
*/
|
||||
mapTag(tagType: TagType, tag: ITag, warnings: INativeMetadataCollector): IGenericTag;
|
||||
private registerTagMapper;
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.CombinedTagMapper = void 0;
|
||||
const ID3v1TagMap_1 = require("../id3v1/ID3v1TagMap");
|
||||
const ID3v24TagMapper_1 = require("../id3v2/ID3v24TagMapper");
|
||||
const AsfTagMapper_1 = require("../asf/AsfTagMapper");
|
||||
const ID3v22TagMapper_1 = require("../id3v2/ID3v22TagMapper");
|
||||
const APEv2TagMapper_1 = require("../apev2/APEv2TagMapper");
|
||||
const MP4TagMapper_1 = require("../mp4/MP4TagMapper");
|
||||
const VorbisTagMapper_1 = require("../ogg/vorbis/VorbisTagMapper");
|
||||
const RiffInfoTagMap_1 = require("../riff/RiffInfoTagMap");
|
||||
const MatroskaTagMapper_1 = require("../matroska/MatroskaTagMapper");
|
||||
class CombinedTagMapper {
|
||||
constructor() {
|
||||
this.tagMappers = {};
|
||||
[
|
||||
new ID3v1TagMap_1.ID3v1TagMapper(),
|
||||
new ID3v22TagMapper_1.ID3v22TagMapper(),
|
||||
new ID3v24TagMapper_1.ID3v24TagMapper(),
|
||||
new MP4TagMapper_1.MP4TagMapper(),
|
||||
new MP4TagMapper_1.MP4TagMapper(),
|
||||
new VorbisTagMapper_1.VorbisTagMapper(),
|
||||
new APEv2TagMapper_1.APEv2TagMapper(),
|
||||
new AsfTagMapper_1.AsfTagMapper(),
|
||||
new RiffInfoTagMap_1.RiffInfoTagMapper(),
|
||||
new MatroskaTagMapper_1.MatroskaTagMapper()
|
||||
].forEach(mapper => {
|
||||
this.registerTagMapper(mapper);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Convert native to generic (common) tags
|
||||
* @param tagType Originating tag format
|
||||
* @param tag Native tag to map to a generic tag id
|
||||
* @param warnings
|
||||
* @return Generic tag result (output of this function)
|
||||
*/
|
||||
mapTag(tagType, tag, warnings) {
|
||||
const tagMapper = this.tagMappers[tagType];
|
||||
if (tagMapper) {
|
||||
return this.tagMappers[tagType].mapGenericTag(tag, warnings);
|
||||
}
|
||||
throw new Error("No generic tag mapper defined for tag-format: " + tagType);
|
||||
}
|
||||
registerTagMapper(genericTagMapper) {
|
||||
for (const tagType of genericTagMapper.tagTypes) {
|
||||
this.tagMappers[tagType] = genericTagMapper;
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.CombinedTagMapper = CombinedTagMapper;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { IToken } from "strtok3/lib/core";
|
||||
/**
|
||||
* Token for read FourCC
|
||||
* Ref: https://en.wikipedia.org/wiki/FourCC
|
||||
*/
|
||||
export declare const FourCcToken: IToken<string>;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.FourCcToken = void 0;
|
||||
const util = require("./Util");
|
||||
const validFourCC = /^[\x21-\x7e©][\x20-\x7e\x00()]{3}/;
|
||||
/**
|
||||
* Token for read FourCC
|
||||
* Ref: https://en.wikipedia.org/wiki/FourCC
|
||||
*/
|
||||
exports.FourCcToken = {
|
||||
len: 4,
|
||||
get: (buf, off) => {
|
||||
const id = buf.toString('binary', off, off + exports.FourCcToken.len);
|
||||
switch (id) {
|
||||
default:
|
||||
if (!id.match(validFourCC)) {
|
||||
throw new Error(`FourCC contains invalid characters: ${util.a2hex(id)} "${id}"`);
|
||||
}
|
||||
}
|
||||
return id;
|
||||
},
|
||||
put: (buffer, offset, id) => {
|
||||
const str = Buffer.from(id, 'binary');
|
||||
if (str.length !== 4)
|
||||
throw new Error("Invalid length");
|
||||
return str.copy(buffer, offset);
|
||||
}
|
||||
};
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import * as generic from './GenericTagTypes';
|
||||
import { ITag } from '../type';
|
||||
import { INativeMetadataCollector, IWarningCollector } from './MetadataCollector';
|
||||
export interface IGenericTagMapper {
|
||||
/**
|
||||
* Which tagType it able to map to the generic mapping format
|
||||
*/
|
||||
tagTypes: generic.TagType[];
|
||||
/**
|
||||
* Basic tag map
|
||||
*/
|
||||
tagMap: generic.INativeTagMap;
|
||||
/**
|
||||
* Map native tag to generic tag
|
||||
* @param tag Native tag
|
||||
* @param warnings Register warnings
|
||||
* @return Generic tag, if native tag could be mapped
|
||||
*/
|
||||
mapGenericTag(tag: ITag, warnings: INativeMetadataCollector): generic.IGenericTag;
|
||||
}
|
||||
export declare class CommonTagMapper implements IGenericTagMapper {
|
||||
tagTypes: generic.TagType[];
|
||||
tagMap: generic.INativeTagMap;
|
||||
static maxRatingScore: number;
|
||||
static toIntOrNull(str: string): number;
|
||||
static normalizeTrack(origVal: number | string): {
|
||||
no: number;
|
||||
of: number;
|
||||
};
|
||||
constructor(tagTypes: generic.TagType[], tagMap: generic.INativeTagMap);
|
||||
/**
|
||||
* Process and set common tags
|
||||
* write common tags to
|
||||
* @param tag Native tag
|
||||
* @param warnings Register warnings
|
||||
* @return common name
|
||||
*/
|
||||
mapGenericTag(tag: ITag, warnings: IWarningCollector): generic.IGenericTag;
|
||||
/**
|
||||
* Convert native tag key to common tag key
|
||||
* @tag Native header tag
|
||||
* @return common tag name (alias)
|
||||
*/
|
||||
protected getCommonName(tag: string): generic.GenericTagId;
|
||||
/**
|
||||
* Handle post mapping exceptions / correction
|
||||
* @param {string} tag Tag e.g. {"©alb", "Buena Vista Social Club")
|
||||
* @param {warnings} Used to register warnings
|
||||
*/
|
||||
protected postMap(tag: ITag, warnings: IWarningCollector): void;
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.CommonTagMapper = void 0;
|
||||
class CommonTagMapper {
|
||||
constructor(tagTypes, tagMap) {
|
||||
this.tagTypes = tagTypes;
|
||||
this.tagMap = tagMap;
|
||||
}
|
||||
static toIntOrNull(str) {
|
||||
const cleaned = parseInt(str, 10);
|
||||
return isNaN(cleaned) ? null : cleaned;
|
||||
}
|
||||
// TODO: a string of 1of1 would fail to be converted
|
||||
// converts 1/10 to no : 1, of : 10
|
||||
// or 1 to no : 1, of : 0
|
||||
static normalizeTrack(origVal) {
|
||||
const split = origVal.toString().split('/');
|
||||
return {
|
||||
no: parseInt(split[0], 10) || null,
|
||||
of: parseInt(split[1], 10) || null
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Process and set common tags
|
||||
* write common tags to
|
||||
* @param tag Native tag
|
||||
* @param warnings Register warnings
|
||||
* @return common name
|
||||
*/
|
||||
mapGenericTag(tag, warnings) {
|
||||
tag = { id: tag.id, value: tag.value }; // clone object
|
||||
this.postMap(tag, warnings);
|
||||
// Convert native tag event to generic 'alias' tag
|
||||
const id = this.getCommonName(tag.id);
|
||||
return id ? { id, value: tag.value } : null;
|
||||
}
|
||||
/**
|
||||
* Convert native tag key to common tag key
|
||||
* @tag Native header tag
|
||||
* @return common tag name (alias)
|
||||
*/
|
||||
getCommonName(tag) {
|
||||
return this.tagMap[tag];
|
||||
}
|
||||
/**
|
||||
* Handle post mapping exceptions / correction
|
||||
* @param {string} tag Tag e.g. {"©alb", "Buena Vista Social Club")
|
||||
* @param {warnings} Used to register warnings
|
||||
*/
|
||||
postMap(tag, warnings) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
exports.CommonTagMapper = CommonTagMapper;
|
||||
CommonTagMapper.maxRatingScore = 1;
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
export declare type TagType = 'vorbis' | 'ID3v1' | 'ID3v2.2' | 'ID3v2.3' | 'ID3v2.4' | 'APEv2' | 'asf' | 'iTunes' | 'exif' | 'matroska';
|
||||
export interface IGenericTag {
|
||||
id: GenericTagId;
|
||||
value: any;
|
||||
}
|
||||
export declare type GenericTagId = 'track' | 'disk' | 'year' | 'title' | 'artist' | 'artists' | 'albumartist' | 'album' | 'date' | 'originaldate' | 'originalyear' | 'comment' | 'genre' | 'picture' | 'composer' | 'lyrics' | 'albumsort' | 'titlesort' | 'work' | 'artistsort' | 'albumartistsort' | 'composersort' | 'lyricist' | 'writer' | 'conductor' | 'remixer' | 'arranger' | 'engineer' | 'technician' | 'producer' | 'djmixer' | 'mixer' | 'publisher' | 'label' | 'grouping' | 'subtitle' | 'discsubtitle' | 'totaltracks' | 'totaldiscs' | 'compilation' | 'rating' | 'bpm' | 'mood' | 'media' | 'catalognumber' | 'tvShow' | 'tvShowSort' | 'tvEpisode' | 'tvEpisodeId' | 'tvNetwork' | 'tvSeason' | 'podcast' | 'podcasturl' | 'releasestatus' | 'releasetype' | 'releasecountry' | 'script' | 'language' | 'copyright' | 'license' | 'encodedby' | 'encodersettings' | 'gapless' | 'barcode' | 'isrc' | 'asin' | 'musicbrainz_recordingid' | 'musicbrainz_trackid' | 'musicbrainz_albumid' | 'musicbrainz_artistid' | 'musicbrainz_albumartistid' | 'musicbrainz_releasegroupid' | 'musicbrainz_workid' | 'musicbrainz_trmid' | 'musicbrainz_discid' | 'acoustid_id' | 'acoustid_fingerprint' | 'musicip_puid' | 'musicip_fingerprint' | 'website' | 'performer:instrument' | 'peakLevel' | 'averageLevel' | 'notes' | 'key' | 'originalalbum' | 'originalartist' | 'discogs_artist_id' | 'discogs_label_id' | 'discogs_master_release_id' | 'discogs_rating' | 'discogs_release_id' | 'discogs_votes' | 'replaygain_track_gain' | 'replaygain_track_peak' | 'replaygain_album_gain' | 'replaygain_album_peak' | 'replaygain_track_minmax' | 'replaygain_album_minmax' | 'replaygain_undo' | 'description' | 'longDescription' | 'category' | 'hdVideo' | 'keywords' | 'movement' | 'movementIndex' | 'movementTotal' | 'podcastId' | 'showMovement' | 'stik';
|
||||
export interface INativeTagMap {
|
||||
[index: string]: GenericTagId;
|
||||
}
|
||||
export interface ITagInfo {
|
||||
/**
|
||||
* True if result is an array
|
||||
*/
|
||||
multiple: boolean;
|
||||
/**
|
||||
* True if the result is an array and each value in the array should be unique
|
||||
*/
|
||||
unique?: boolean;
|
||||
}
|
||||
export interface ITagInfoMap {
|
||||
[index: string]: ITagInfo;
|
||||
}
|
||||
export declare const commonTags: ITagInfoMap;
|
||||
/**
|
||||
* @param alias Name of common tag
|
||||
* @returns {boolean|*} true if given alias is mapped as a singleton', otherwise false
|
||||
*/
|
||||
export declare function isSingleton(alias: GenericTagId): boolean;
|
||||
/**
|
||||
* @param alias Common (generic) tag
|
||||
* @returns {boolean|*} true if given alias is a singleton or explicitly marked as unique
|
||||
*/
|
||||
export declare function isUnique(alias: GenericTagId): boolean;
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.isUnique = exports.isSingleton = exports.commonTags = void 0;
|
||||
exports.commonTags = {
|
||||
year: { multiple: false },
|
||||
track: { multiple: false },
|
||||
disk: { multiple: false },
|
||||
title: { multiple: false },
|
||||
artist: { multiple: false },
|
||||
artists: { multiple: true, unique: true },
|
||||
albumartist: { multiple: false },
|
||||
album: { multiple: false },
|
||||
date: { multiple: false },
|
||||
originaldate: { multiple: false },
|
||||
originalyear: { multiple: false },
|
||||
comment: { multiple: true, unique: false },
|
||||
genre: { multiple: true, unique: true },
|
||||
picture: { multiple: true, unique: true },
|
||||
composer: { multiple: true, unique: true },
|
||||
lyrics: { multiple: true, unique: false },
|
||||
albumsort: { multiple: false, unique: true },
|
||||
titlesort: { multiple: false, unique: true },
|
||||
work: { multiple: false, unique: true },
|
||||
artistsort: { multiple: false, unique: true },
|
||||
albumartistsort: { multiple: false, unique: true },
|
||||
composersort: { multiple: false, unique: true },
|
||||
lyricist: { multiple: true, unique: true },
|
||||
writer: { multiple: true, unique: true },
|
||||
conductor: { multiple: true, unique: true },
|
||||
remixer: { multiple: true, unique: true },
|
||||
arranger: { multiple: true, unique: true },
|
||||
engineer: { multiple: true, unique: true },
|
||||
producer: { multiple: true, unique: true },
|
||||
technician: { multiple: true, unique: true },
|
||||
djmixer: { multiple: true, unique: true },
|
||||
mixer: { multiple: true, unique: true },
|
||||
label: { multiple: true, unique: true },
|
||||
grouping: { multiple: false },
|
||||
subtitle: { multiple: true },
|
||||
discsubtitle: { multiple: false },
|
||||
totaltracks: { multiple: false },
|
||||
totaldiscs: { multiple: false },
|
||||
compilation: { multiple: false },
|
||||
rating: { multiple: true },
|
||||
bpm: { multiple: false },
|
||||
mood: { multiple: false },
|
||||
media: { multiple: false },
|
||||
catalognumber: { multiple: true, unique: true },
|
||||
tvShow: { multiple: false },
|
||||
tvShowSort: { multiple: false },
|
||||
tvSeason: { multiple: false },
|
||||
tvEpisode: { multiple: false },
|
||||
tvEpisodeId: { multiple: false },
|
||||
tvNetwork: { multiple: false },
|
||||
podcast: { multiple: false },
|
||||
podcasturl: { multiple: false },
|
||||
releasestatus: { multiple: false },
|
||||
releasetype: { multiple: true },
|
||||
releasecountry: { multiple: false },
|
||||
script: { multiple: false },
|
||||
language: { multiple: false },
|
||||
copyright: { multiple: false },
|
||||
license: { multiple: false },
|
||||
encodedby: { multiple: false },
|
||||
encodersettings: { multiple: false },
|
||||
gapless: { multiple: false },
|
||||
barcode: { multiple: false },
|
||||
isrc: { multiple: true },
|
||||
asin: { multiple: false },
|
||||
musicbrainz_recordingid: { multiple: false },
|
||||
musicbrainz_trackid: { multiple: false },
|
||||
musicbrainz_albumid: { multiple: false },
|
||||
musicbrainz_artistid: { multiple: true },
|
||||
musicbrainz_albumartistid: { multiple: true },
|
||||
musicbrainz_releasegroupid: { multiple: false },
|
||||
musicbrainz_workid: { multiple: false },
|
||||
musicbrainz_trmid: { multiple: false },
|
||||
musicbrainz_discid: { multiple: false },
|
||||
acoustid_id: { multiple: false },
|
||||
acoustid_fingerprint: { multiple: false },
|
||||
musicip_puid: { multiple: false },
|
||||
musicip_fingerprint: { multiple: false },
|
||||
website: { multiple: false },
|
||||
'performer:instrument': { multiple: true, unique: true },
|
||||
averageLevel: { multiple: false },
|
||||
peakLevel: { multiple: false },
|
||||
notes: { multiple: true, unique: false },
|
||||
key: { multiple: false },
|
||||
originalalbum: { multiple: false },
|
||||
originalartist: { multiple: false },
|
||||
discogs_artist_id: { multiple: true, unique: true },
|
||||
discogs_release_id: { multiple: false },
|
||||
discogs_label_id: { multiple: false },
|
||||
discogs_master_release_id: { multiple: false },
|
||||
discogs_votes: { multiple: false },
|
||||
discogs_rating: { multiple: false },
|
||||
replaygain_track_peak: { multiple: false },
|
||||
replaygain_track_gain: { multiple: false },
|
||||
replaygain_album_peak: { multiple: false },
|
||||
replaygain_album_gain: { multiple: false },
|
||||
replaygain_track_minmax: { multiple: false },
|
||||
replaygain_album_minmax: { multiple: false },
|
||||
replaygain_undo: { multiple: false },
|
||||
description: { multiple: true },
|
||||
longDescription: { multiple: false },
|
||||
category: { multiple: true },
|
||||
hdVideo: { multiple: false },
|
||||
keywords: { multiple: true },
|
||||
movement: { multiple: false },
|
||||
movementIndex: { multiple: false },
|
||||
movementTotal: { multiple: false },
|
||||
podcastId: { multiple: false },
|
||||
showMovement: { multiple: false },
|
||||
stik: { multiple: false }
|
||||
};
|
||||
/**
|
||||
* @param alias Name of common tag
|
||||
* @returns {boolean|*} true if given alias is mapped as a singleton', otherwise false
|
||||
*/
|
||||
function isSingleton(alias) {
|
||||
return exports.commonTags.hasOwnProperty(alias) && !exports.commonTags[alias].multiple;
|
||||
}
|
||||
exports.isSingleton = isSingleton;
|
||||
/**
|
||||
* @param alias Common (generic) tag
|
||||
* @returns {boolean|*} true if given alias is a singleton or explicitly marked as unique
|
||||
*/
|
||||
function isUnique(alias) {
|
||||
return !exports.commonTags[alias].multiple || exports.commonTags[alias].unique;
|
||||
}
|
||||
exports.isUnique = isUnique;
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { FormatId, IAudioMetadata, ICommonTagsResult, IFormat, INativeTags, IOptions, IQualityInformation, ITrackInfo } from '../type';
|
||||
import { IGenericTag, TagType } from './GenericTagTypes';
|
||||
/**
|
||||
* Combines all generic-tag-mappers for each tag type
|
||||
*/
|
||||
export interface IWarningCollector {
|
||||
/**
|
||||
* Register parser warning
|
||||
* @param warning
|
||||
*/
|
||||
addWarning(warning: string): any;
|
||||
}
|
||||
export interface INativeMetadataCollector extends IWarningCollector {
|
||||
/**
|
||||
* Only use this for reading
|
||||
*/
|
||||
readonly format: IFormat;
|
||||
readonly native: INativeTags;
|
||||
readonly quality: IQualityInformation;
|
||||
/**
|
||||
* @returns {boolean} true if one or more tags have been found
|
||||
*/
|
||||
hasAny(): boolean;
|
||||
setFormat(key: FormatId, value: any): any;
|
||||
addTag(tagType: TagType, tagId: string, value: any): any;
|
||||
addStreamInfo(streamInfo: ITrackInfo): any;
|
||||
}
|
||||
/**
|
||||
* Provided to the parser to uodate the metadata result.
|
||||
* Responsible for triggering async updates
|
||||
*/
|
||||
export declare class MetadataCollector implements INativeMetadataCollector {
|
||||
private opts;
|
||||
readonly format: IFormat;
|
||||
readonly native: INativeTags;
|
||||
readonly common: ICommonTagsResult;
|
||||
readonly quality: IQualityInformation;
|
||||
/**
|
||||
* Keeps track of origin priority for each mapped id
|
||||
*/
|
||||
private readonly commonOrigin;
|
||||
/**
|
||||
* Maps a tag type to a priority
|
||||
*/
|
||||
private readonly originPriority;
|
||||
private tagMapper;
|
||||
constructor(opts: IOptions);
|
||||
/**
|
||||
* @returns {boolean} true if one or more tags have been found
|
||||
*/
|
||||
hasAny(): boolean;
|
||||
addStreamInfo(streamInfo: ITrackInfo): void;
|
||||
setFormat(key: FormatId, value: any): void;
|
||||
addTag(tagType: TagType, tagId: string, value: any): void;
|
||||
addWarning(warning: string): void;
|
||||
postMap(tagType: TagType | 'artificial', tag: IGenericTag): any;
|
||||
/**
|
||||
* Convert native tags to common tags
|
||||
* @returns {IAudioMetadata} Native + common tags
|
||||
*/
|
||||
toCommonMetadata(): IAudioMetadata;
|
||||
/**
|
||||
* Fix some common issues with picture object
|
||||
* @param pictureType
|
||||
*/
|
||||
private postFixPicture;
|
||||
/**
|
||||
* Convert native tag to common tags
|
||||
*/
|
||||
private toCommon;
|
||||
/**
|
||||
* Set generic tag
|
||||
*/
|
||||
private setGenericTag;
|
||||
}
|
||||
export declare function joinArtists(artists: string[]): string;
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.joinArtists = exports.MetadataCollector = void 0;
|
||||
const type_1 = require("../type");
|
||||
const _debug = require("debug");
|
||||
const GenericTagTypes_1 = require("./GenericTagTypes");
|
||||
const CombinedTagMapper_1 = require("./CombinedTagMapper");
|
||||
const GenericTagMapper_1 = require("./GenericTagMapper");
|
||||
const Util_1 = require("./Util");
|
||||
const FileType = require("file-type/core");
|
||||
const debug = _debug('music-metadata:collector');
|
||||
const TagPriority = ['matroska', 'APEv2', 'vorbis', 'ID3v2.4', 'ID3v2.3', 'ID3v2.2', 'exif', 'asf', 'iTunes', 'ID3v1'];
|
||||
/**
|
||||
* Provided to the parser to uodate the metadata result.
|
||||
* Responsible for triggering async updates
|
||||
*/
|
||||
class MetadataCollector {
|
||||
constructor(opts) {
|
||||
this.opts = opts;
|
||||
this.format = {
|
||||
tagTypes: [],
|
||||
trackInfo: []
|
||||
};
|
||||
this.native = {};
|
||||
this.common = {
|
||||
track: { no: null, of: null },
|
||||
disk: { no: null, of: null },
|
||||
movementIndex: {}
|
||||
};
|
||||
this.quality = {
|
||||
warnings: []
|
||||
};
|
||||
/**
|
||||
* Keeps track of origin priority for each mapped id
|
||||
*/
|
||||
this.commonOrigin = {};
|
||||
/**
|
||||
* Maps a tag type to a priority
|
||||
*/
|
||||
this.originPriority = {};
|
||||
this.tagMapper = new CombinedTagMapper_1.CombinedTagMapper();
|
||||
let priority = 1;
|
||||
for (const tagType of TagPriority) {
|
||||
this.originPriority[tagType] = priority++;
|
||||
}
|
||||
this.originPriority.artificial = 500; // Filled using alternative tags
|
||||
this.originPriority.id3v1 = 600; // Consider worst due to field length limit
|
||||
}
|
||||
/**
|
||||
* @returns {boolean} true if one or more tags have been found
|
||||
*/
|
||||
hasAny() {
|
||||
return Object.keys(this.native).length > 0;
|
||||
}
|
||||
addStreamInfo(streamInfo) {
|
||||
debug(`streamInfo: type=${type_1.TrackType[streamInfo.type]}, codec=${streamInfo.codecName}`);
|
||||
this.format.trackInfo.push(streamInfo);
|
||||
}
|
||||
setFormat(key, value) {
|
||||
debug(`format: ${key} = ${value}`);
|
||||
this.format[key] = value; // as any to override readonly
|
||||
if (this.opts.observer) {
|
||||
this.opts.observer({ metadata: this, tag: { type: 'format', id: key, value } });
|
||||
}
|
||||
}
|
||||
addTag(tagType, tagId, value) {
|
||||
debug(`tag ${tagType}.${tagId} = ${value}`);
|
||||
if (!this.native[tagType]) {
|
||||
this.format.tagTypes.push(tagType);
|
||||
this.native[tagType] = [];
|
||||
}
|
||||
this.native[tagType].push({ id: tagId, value });
|
||||
this.toCommon(tagType, tagId, value);
|
||||
}
|
||||
addWarning(warning) {
|
||||
this.quality.warnings.push({ message: warning });
|
||||
}
|
||||
postMap(tagType, tag) {
|
||||
// Common tag (alias) found
|
||||
// check if we need to do something special with common tag
|
||||
// if the event has been aliased then we need to clean it before
|
||||
// it is emitted to the user. e.g. genre (20) -> Electronic
|
||||
switch (tag.id) {
|
||||
case 'artist':
|
||||
if (this.commonOrigin.artist === this.originPriority[tagType]) {
|
||||
// Assume the artist field is used as artists
|
||||
return this.postMap('artificial', { id: 'artists', value: tag.value });
|
||||
}
|
||||
if (!this.common.artists) {
|
||||
// Fill artists using artist source
|
||||
this.setGenericTag('artificial', { id: 'artists', value: tag.value });
|
||||
}
|
||||
break;
|
||||
case 'artists':
|
||||
if (!this.common.artist || this.commonOrigin.artist === this.originPriority.artificial) {
|
||||
if (!this.common.artists || this.common.artists.indexOf(tag.value) === -1) {
|
||||
// Fill artist using artists source
|
||||
const artists = (this.common.artists || []).concat([tag.value]);
|
||||
const value = joinArtists(artists);
|
||||
const artistTag = { id: 'artist', value };
|
||||
this.setGenericTag('artificial', artistTag);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'picture':
|
||||
this.postFixPicture(tag.value).then(picture => {
|
||||
if (picture !== null) {
|
||||
tag.value = picture;
|
||||
this.setGenericTag(tagType, tag);
|
||||
}
|
||||
});
|
||||
return;
|
||||
case 'totaltracks':
|
||||
this.common.track.of = GenericTagMapper_1.CommonTagMapper.toIntOrNull(tag.value);
|
||||
return;
|
||||
case 'totaldiscs':
|
||||
this.common.disk.of = GenericTagMapper_1.CommonTagMapper.toIntOrNull(tag.value);
|
||||
return;
|
||||
case 'movementTotal':
|
||||
this.common.movementIndex.of = GenericTagMapper_1.CommonTagMapper.toIntOrNull(tag.value);
|
||||
return;
|
||||
case 'track':
|
||||
case 'disk':
|
||||
case 'movementIndex':
|
||||
const of = this.common[tag.id].of; // store of value, maybe maybe overwritten
|
||||
this.common[tag.id] = GenericTagMapper_1.CommonTagMapper.normalizeTrack(tag.value);
|
||||
this.common[tag.id].of = of != null ? of : this.common[tag.id].of;
|
||||
return;
|
||||
case 'year':
|
||||
case 'originalyear':
|
||||
tag.value = parseInt(tag.value, 10);
|
||||
break;
|
||||
case 'date':
|
||||
// ToDo: be more strict on 'YYYY...'
|
||||
const year = parseInt(tag.value.substr(0, 4), 10);
|
||||
if (!isNaN(year)) {
|
||||
this.common.year = year;
|
||||
}
|
||||
break;
|
||||
case 'discogs_label_id':
|
||||
case 'discogs_release_id':
|
||||
case 'discogs_master_release_id':
|
||||
case 'discogs_artist_id':
|
||||
case 'discogs_votes':
|
||||
tag.value = typeof tag.value === 'string' ? parseInt(tag.value, 10) : tag.value;
|
||||
break;
|
||||
case 'replaygain_track_gain':
|
||||
case 'replaygain_track_peak':
|
||||
case 'replaygain_album_gain':
|
||||
case 'replaygain_album_peak':
|
||||
tag.value = (0, Util_1.toRatio)(tag.value);
|
||||
break;
|
||||
case 'replaygain_track_minmax':
|
||||
tag.value = tag.value.split(',').map(v => parseInt(v, 10));
|
||||
break;
|
||||
case 'replaygain_undo':
|
||||
const minMix = tag.value.split(',').map(v => parseInt(v, 10));
|
||||
tag.value = {
|
||||
leftChannel: minMix[0],
|
||||
rightChannel: minMix[1]
|
||||
};
|
||||
break;
|
||||
case 'gapless': // iTunes gap-less flag
|
||||
case 'compilation':
|
||||
case 'podcast':
|
||||
case 'showMovement':
|
||||
tag.value = tag.value === '1' || tag.value === 1; // boolean
|
||||
break;
|
||||
case 'isrc': // Only keep unique values
|
||||
if (this.common[tag.id] && this.common[tag.id].indexOf(tag.value) !== -1)
|
||||
return;
|
||||
break;
|
||||
default:
|
||||
// nothing to do
|
||||
}
|
||||
if (tag.value !== null) {
|
||||
this.setGenericTag(tagType, tag);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Convert native tags to common tags
|
||||
* @returns {IAudioMetadata} Native + common tags
|
||||
*/
|
||||
toCommonMetadata() {
|
||||
return {
|
||||
format: this.format,
|
||||
native: this.native,
|
||||
quality: this.quality,
|
||||
common: this.common
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Fix some common issues with picture object
|
||||
* @param pictureType
|
||||
*/
|
||||
async postFixPicture(picture) {
|
||||
if (picture.data && picture.data.length > 0) {
|
||||
if (!picture.format) {
|
||||
const fileType = await FileType.fromBuffer(picture.data);
|
||||
if (fileType) {
|
||||
picture.format = fileType.mime;
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
picture.format = picture.format.toLocaleLowerCase();
|
||||
switch (picture.format) {
|
||||
case 'image/jpg':
|
||||
picture.format = 'image/jpeg'; // ToDo: register warning
|
||||
}
|
||||
return picture;
|
||||
}
|
||||
this.addWarning(`Empty picture tag found`);
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Convert native tag to common tags
|
||||
*/
|
||||
toCommon(tagType, tagId, value) {
|
||||
const tag = { id: tagId, value };
|
||||
const genericTag = this.tagMapper.mapTag(tagType, tag, this);
|
||||
if (genericTag) {
|
||||
this.postMap(tagType, genericTag);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Set generic tag
|
||||
*/
|
||||
setGenericTag(tagType, tag) {
|
||||
debug(`common.${tag.id} = ${tag.value}`);
|
||||
const prio0 = this.commonOrigin[tag.id] || 1000;
|
||||
const prio1 = this.originPriority[tagType];
|
||||
if ((0, GenericTagTypes_1.isSingleton)(tag.id)) {
|
||||
if (prio1 <= prio0) {
|
||||
this.common[tag.id] = tag.value;
|
||||
this.commonOrigin[tag.id] = prio1;
|
||||
}
|
||||
else {
|
||||
return debug(`Ignore native tag (singleton): ${tagType}.${tag.id} = ${tag.value}`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (prio1 === prio0) {
|
||||
if (!(0, GenericTagTypes_1.isUnique)(tag.id) || this.common[tag.id].indexOf(tag.value) === -1) {
|
||||
this.common[tag.id].push(tag.value);
|
||||
}
|
||||
else {
|
||||
debug(`Ignore duplicate value: ${tagType}.${tag.id} = ${tag.value}`);
|
||||
}
|
||||
// no effect? this.commonOrigin[tag.id] = prio1;
|
||||
}
|
||||
else if (prio1 < prio0) {
|
||||
this.common[tag.id] = [tag.value];
|
||||
this.commonOrigin[tag.id] = prio1;
|
||||
}
|
||||
else {
|
||||
return debug(`Ignore native tag (list): ${tagType}.${tag.id} = ${tag.value}`);
|
||||
}
|
||||
}
|
||||
if (this.opts.observer) {
|
||||
this.opts.observer({ metadata: this, tag: { type: 'common', id: tag.id, value: tag.value } });
|
||||
}
|
||||
// ToDo: trigger metadata event
|
||||
}
|
||||
}
|
||||
exports.MetadataCollector = MetadataCollector;
|
||||
function joinArtists(artists) {
|
||||
if (artists.length > 2) {
|
||||
return artists.slice(0, artists.length - 1).join(', ') + ' & ' + artists[artists.length - 1];
|
||||
}
|
||||
return artists.join(' & ');
|
||||
}
|
||||
exports.joinArtists = joinArtists;
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/// <reference types="node" />
|
||||
import { IRandomReader } from '../type';
|
||||
/**
|
||||
* Provides abstract file access via the IRandomRead interface
|
||||
*/
|
||||
export declare class RandomFileReader implements IRandomReader {
|
||||
fileSize: number;
|
||||
private readonly fd;
|
||||
constructor(filePath: string, fileSize: number);
|
||||
/**
|
||||
* Read from a given position of an abstracted file or buffer.
|
||||
* @param buffer {Buffer} is the buffer that the data will be written to.
|
||||
* @param offset {number} is the offset in the buffer to start writing at.
|
||||
* @param length {number}is an integer specifying the number of bytes to read.
|
||||
* @param position {number} is an argument specifying where to begin reading from in the file.
|
||||
* @return {Promise<number>} bytes read
|
||||
*/
|
||||
randomRead(buffer: Buffer, offset: number, length: number, position: number): Promise<number>;
|
||||
close(): void;
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.RandomFileReader = void 0;
|
||||
const fs = require("fs");
|
||||
/**
|
||||
* Provides abstract file access via the IRandomRead interface
|
||||
*/
|
||||
class RandomFileReader {
|
||||
constructor(filePath, fileSize) {
|
||||
this.fileSize = fileSize;
|
||||
this.fd = fs.openSync(filePath, 'r');
|
||||
}
|
||||
/**
|
||||
* Read from a given position of an abstracted file or buffer.
|
||||
* @param buffer {Buffer} is the buffer that the data will be written to.
|
||||
* @param offset {number} is the offset in the buffer to start writing at.
|
||||
* @param length {number}is an integer specifying the number of bytes to read.
|
||||
* @param position {number} is an argument specifying where to begin reading from in the file.
|
||||
* @return {Promise<number>} bytes read
|
||||
*/
|
||||
randomRead(buffer, offset, length, position) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.read(this.fd, buffer, offset, length, position, (err, bytesRead) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
else {
|
||||
resolve(bytesRead);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
close() {
|
||||
fs.closeSync(this.fd);
|
||||
}
|
||||
}
|
||||
exports.RandomFileReader = RandomFileReader;
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { IRandomReader } from '../type';
|
||||
/**
|
||||
* Provides abstract Uint8Array access via the IRandomRead interface
|
||||
*/
|
||||
export declare class RandomUint8ArrayReader implements IRandomReader {
|
||||
private readonly uint8Array;
|
||||
readonly fileSize: number;
|
||||
constructor(uint8Array: Uint8Array);
|
||||
/**
|
||||
* Read from a given position of an abstracted file or buffer.
|
||||
* @param uint8Array - Uint8Array that the data will be written to.
|
||||
* @param offset - Offset in the buffer to start writing at.
|
||||
* @param length - Integer specifying the number of bytes to read.
|
||||
* @param position - Specifies where to begin reading from in the file.
|
||||
* @return Promise providing bytes read
|
||||
*/
|
||||
randomRead(uint8Array: Uint8Array, offset: number, length: number, position: number): Promise<number>;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.RandomUint8ArrayReader = void 0;
|
||||
/**
|
||||
* Provides abstract Uint8Array access via the IRandomRead interface
|
||||
*/
|
||||
class RandomUint8ArrayReader {
|
||||
constructor(uint8Array) {
|
||||
this.uint8Array = uint8Array;
|
||||
this.fileSize = uint8Array.length;
|
||||
}
|
||||
/**
|
||||
* Read from a given position of an abstracted file or buffer.
|
||||
* @param uint8Array - Uint8Array that the data will be written to.
|
||||
* @param offset - Offset in the buffer to start writing at.
|
||||
* @param length - Integer specifying the number of bytes to read.
|
||||
* @param position - Specifies where to begin reading from in the file.
|
||||
* @return Promise providing bytes read
|
||||
*/
|
||||
async randomRead(uint8Array, offset, length, position) {
|
||||
uint8Array.set(this.uint8Array.subarray(position, position + length), offset);
|
||||
return length;
|
||||
}
|
||||
}
|
||||
exports.RandomUint8ArrayReader = RandomUint8ArrayReader;
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/// <reference types="node" />
|
||||
import { IRatio } from '../type';
|
||||
export declare type StringEncoding = 'ascii' | 'utf8' | 'utf16le' | 'ucs2' | 'base64url' | 'latin1' | 'hex';
|
||||
export declare function getBit(buf: Uint8Array, off: number, bit: number): boolean;
|
||||
/**
|
||||
* Found delimiting zero in uint8Array
|
||||
* @param uint8Array Uint8Array to find the zero delimiter in
|
||||
* @param start Offset in uint8Array
|
||||
* @param end Last position to parse in uint8Array
|
||||
* @param encoding The string encoding used
|
||||
* @return Absolute position on uint8Array where zero found
|
||||
*/
|
||||
export declare function findZero(uint8Array: Uint8Array, start: number, end: number, encoding?: StringEncoding): number;
|
||||
export declare function trimRightNull(x: string): string;
|
||||
/**
|
||||
*
|
||||
* @param buffer Decoder input data
|
||||
* @param encoding 'utf16le' | 'utf16' | 'utf8' | 'iso-8859-1'
|
||||
* @return {string}
|
||||
*/
|
||||
export declare function decodeString(buffer: Buffer, encoding: StringEncoding): string;
|
||||
export declare function stripNulls(str: string): string;
|
||||
/**
|
||||
* Read bit-aligned number start from buffer
|
||||
* Total offset in bits = byteOffset * 8 + bitOffset
|
||||
* @param buf Byte buffer
|
||||
* @param byteOffset Starting offset in bytes
|
||||
* @param bitOffset Starting offset in bits: 0 = lsb
|
||||
* @param len Length of number in bits
|
||||
* @return {number} decoded bit aligned number
|
||||
*/
|
||||
export declare function getBitAllignedNumber(buf: Uint8Array, byteOffset: number, bitOffset: number, len: number): number;
|
||||
/**
|
||||
* Read bit-aligned number start from buffer
|
||||
* Total offset in bits = byteOffset * 8 + bitOffset
|
||||
* @param buf Byte buffer
|
||||
* @param byteOffset Starting offset in bytes
|
||||
* @param bitOffset Starting offset in bits: 0 = most significant bit, 7 is least significant bit
|
||||
* @return {number} decoded bit aligned number
|
||||
*/
|
||||
export declare function isBitSet(buf: Uint8Array, byteOffset: number, bitOffset: number): boolean;
|
||||
export declare function a2hex(str: string): string;
|
||||
/**
|
||||
* Convert power ratio to DB
|
||||
* ratio: [0..1]
|
||||
*/
|
||||
export declare function ratioToDb(ratio: number): number;
|
||||
/**
|
||||
* Convert dB to ratio
|
||||
* db Decibels
|
||||
*/
|
||||
export declare function dbToRatio(dB: number): number;
|
||||
/**
|
||||
* Convert replay gain to ratio and Decibel
|
||||
* @param value string holding a ratio like '0.034' or '-7.54 dB'
|
||||
*/
|
||||
export declare function toRatio(value: string): IRatio;
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.toRatio = exports.dbToRatio = exports.ratioToDb = exports.a2hex = exports.isBitSet = exports.getBitAllignedNumber = exports.stripNulls = exports.decodeString = exports.trimRightNull = exports.findZero = exports.getBit = void 0;
|
||||
function getBit(buf, off, bit) {
|
||||
return (buf[off] & (1 << bit)) !== 0;
|
||||
}
|
||||
exports.getBit = getBit;
|
||||
/**
|
||||
* Found delimiting zero in uint8Array
|
||||
* @param uint8Array Uint8Array to find the zero delimiter in
|
||||
* @param start Offset in uint8Array
|
||||
* @param end Last position to parse in uint8Array
|
||||
* @param encoding The string encoding used
|
||||
* @return Absolute position on uint8Array where zero found
|
||||
*/
|
||||
function findZero(uint8Array, start, end, encoding) {
|
||||
let i = start;
|
||||
if (encoding === 'utf16le') {
|
||||
while (uint8Array[i] !== 0 || uint8Array[i + 1] !== 0) {
|
||||
if (i >= end)
|
||||
return end;
|
||||
i += 2;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
else {
|
||||
while (uint8Array[i] !== 0) {
|
||||
if (i >= end)
|
||||
return end;
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
}
|
||||
exports.findZero = findZero;
|
||||
function trimRightNull(x) {
|
||||
const pos0 = x.indexOf('\0');
|
||||
return pos0 === -1 ? x : x.substr(0, pos0);
|
||||
}
|
||||
exports.trimRightNull = trimRightNull;
|
||||
function swapBytes(uint8Array) {
|
||||
const l = uint8Array.length;
|
||||
if ((l & 1) !== 0)
|
||||
throw new Error('Buffer length must be even');
|
||||
for (let i = 0; i < l; i += 2) {
|
||||
const a = uint8Array[i];
|
||||
uint8Array[i] = uint8Array[i + 1];
|
||||
uint8Array[i + 1] = a;
|
||||
}
|
||||
return uint8Array;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param buffer Decoder input data
|
||||
* @param encoding 'utf16le' | 'utf16' | 'utf8' | 'iso-8859-1'
|
||||
* @return {string}
|
||||
*/
|
||||
function decodeString(buffer, encoding) {
|
||||
// annoying workaround for a double BOM issue
|
||||
// https://github.com/leetreveil/musicmetadata/issues/84
|
||||
let offset = 0;
|
||||
if (buffer[0] === 0xFF && buffer[1] === 0xFE) { // little endian
|
||||
if (encoding === 'utf16le') {
|
||||
offset = 2;
|
||||
}
|
||||
else if (buffer[2] === 0xFE && buffer[3] === 0xFF) {
|
||||
offset = 2; // Clear double BOM
|
||||
}
|
||||
}
|
||||
else if (encoding === 'utf16le' && buffer[0] === 0xFE && buffer[1] === 0xFF) {
|
||||
// BOM, indicating big endian decoding
|
||||
return decodeString(swapBytes(buffer), encoding);
|
||||
}
|
||||
return buffer.toString(encoding, offset);
|
||||
}
|
||||
exports.decodeString = decodeString;
|
||||
function stripNulls(str) {
|
||||
str = str.replace(/^\x00+/g, '');
|
||||
str = str.replace(/\x00+$/g, '');
|
||||
return str;
|
||||
}
|
||||
exports.stripNulls = stripNulls;
|
||||
/**
|
||||
* Read bit-aligned number start from buffer
|
||||
* Total offset in bits = byteOffset * 8 + bitOffset
|
||||
* @param buf Byte buffer
|
||||
* @param byteOffset Starting offset in bytes
|
||||
* @param bitOffset Starting offset in bits: 0 = lsb
|
||||
* @param len Length of number in bits
|
||||
* @return {number} decoded bit aligned number
|
||||
*/
|
||||
function getBitAllignedNumber(buf, byteOffset, bitOffset, len) {
|
||||
const byteOff = byteOffset + ~~(bitOffset / 8);
|
||||
const bitOff = bitOffset % 8;
|
||||
let value = buf[byteOff];
|
||||
value &= 0xff >> bitOff;
|
||||
const bitsRead = 8 - bitOff;
|
||||
const bitsLeft = len - bitsRead;
|
||||
if (bitsLeft < 0) {
|
||||
value >>= (8 - bitOff - len);
|
||||
}
|
||||
else if (bitsLeft > 0) {
|
||||
value <<= bitsLeft;
|
||||
value |= getBitAllignedNumber(buf, byteOffset, bitOffset + bitsRead, bitsLeft);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
exports.getBitAllignedNumber = getBitAllignedNumber;
|
||||
/**
|
||||
* Read bit-aligned number start from buffer
|
||||
* Total offset in bits = byteOffset * 8 + bitOffset
|
||||
* @param buf Byte buffer
|
||||
* @param byteOffset Starting offset in bytes
|
||||
* @param bitOffset Starting offset in bits: 0 = most significant bit, 7 is least significant bit
|
||||
* @return {number} decoded bit aligned number
|
||||
*/
|
||||
function isBitSet(buf, byteOffset, bitOffset) {
|
||||
return getBitAllignedNumber(buf, byteOffset, bitOffset, 1) === 1;
|
||||
}
|
||||
exports.isBitSet = isBitSet;
|
||||
function a2hex(str) {
|
||||
const arr = [];
|
||||
for (let i = 0, l = str.length; i < l; i++) {
|
||||
const hex = Number(str.charCodeAt(i)).toString(16);
|
||||
arr.push(hex.length === 1 ? '0' + hex : hex);
|
||||
}
|
||||
return arr.join(' ');
|
||||
}
|
||||
exports.a2hex = a2hex;
|
||||
/**
|
||||
* Convert power ratio to DB
|
||||
* ratio: [0..1]
|
||||
*/
|
||||
function ratioToDb(ratio) {
|
||||
return 10 * Math.log10(ratio);
|
||||
}
|
||||
exports.ratioToDb = ratioToDb;
|
||||
/**
|
||||
* Convert dB to ratio
|
||||
* db Decibels
|
||||
*/
|
||||
function dbToRatio(dB) {
|
||||
return Math.pow(10, dB / 10);
|
||||
}
|
||||
exports.dbToRatio = dbToRatio;
|
||||
/**
|
||||
* Convert replay gain to ratio and Decibel
|
||||
* @param value string holding a ratio like '0.034' or '-7.54 dB'
|
||||
*/
|
||||
function toRatio(value) {
|
||||
const ps = value.split(' ').map(p => p.trim().toLowerCase());
|
||||
// @ts-ignore
|
||||
if (ps.length >= 1) {
|
||||
const v = parseFloat(ps[0]);
|
||||
if (ps.length === 2 && ps[1] === 'db') {
|
||||
return {
|
||||
dB: v,
|
||||
ratio: dbToRatio(v)
|
||||
};
|
||||
}
|
||||
else {
|
||||
return {
|
||||
dB: ratioToDb(v),
|
||||
ratio: v
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.toRatio = toRatio;
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/// <reference types="node" />
|
||||
import { Readable } from 'stream';
|
||||
import * as strtok3 from 'strtok3/lib/core';
|
||||
import { IAudioMetadata, INativeTagDict, IOptions, IPicture, IPrivateOptions, IRandomReader, ITag } from './type';
|
||||
export { IFileInfo } from 'strtok3/lib/core';
|
||||
/**
|
||||
* Parse audio from Node Stream.Readable
|
||||
* @param stream - Stream to read the audio track from
|
||||
* @param options - Parsing options
|
||||
* @param fileInfo - File information object or MIME-type string
|
||||
* @returns Metadata
|
||||
*/
|
||||
export declare function parseStream(stream: Readable, fileInfo?: strtok3.IFileInfo | string, options?: IOptions): Promise<IAudioMetadata>;
|
||||
/**
|
||||
* Parse audio from Node Buffer
|
||||
* @param uint8Array - Uint8Array holding audio data
|
||||
* @param fileInfo - File information object or MIME-type string
|
||||
* @param options - Parsing options
|
||||
* @returns Metadata
|
||||
* Ref: https://github.com/Borewit/strtok3/blob/e6938c81ff685074d5eb3064a11c0b03ca934c1d/src/index.ts#L15
|
||||
*/
|
||||
export declare function parseBuffer(uint8Array: Uint8Array, fileInfo?: strtok3.IFileInfo | string, options?: IOptions): Promise<IAudioMetadata>;
|
||||
/**
|
||||
* Parse audio from ITokenizer source
|
||||
* @param tokenizer - Audio source implementing the tokenizer interface
|
||||
* @param options - Parsing options
|
||||
* @returns Metadata
|
||||
*/
|
||||
export declare function parseFromTokenizer(tokenizer: strtok3.ITokenizer, options?: IOptions): Promise<IAudioMetadata>;
|
||||
/**
|
||||
* Create a dictionary ordered by their tag id (key)
|
||||
* @param nativeTags list of tags
|
||||
* @returns tags indexed by id
|
||||
*/
|
||||
export declare function orderTags(nativeTags: ITag[]): INativeTagDict;
|
||||
/**
|
||||
* Convert rating to 1-5 star rating
|
||||
* @param rating: Normalized rating [0..1] (common.rating[n].rating)
|
||||
* @returns Number of stars: 1, 2, 3, 4 or 5 stars
|
||||
*/
|
||||
export declare function ratingToStars(rating: number): number;
|
||||
/**
|
||||
* Select most likely cover image.
|
||||
* @param pictures Usually metadata.common.picture
|
||||
* @return Cover image, if any, otherwise null
|
||||
*/
|
||||
export declare function selectCover(pictures?: IPicture[]): IPicture | null;
|
||||
export declare function scanAppendingHeaders(randomReader: IRandomReader, options?: IPrivateOptions): Promise<void>;
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.scanAppendingHeaders = exports.selectCover = exports.ratingToStars = exports.orderTags = exports.parseFromTokenizer = exports.parseBuffer = exports.parseStream = void 0;
|
||||
const strtok3 = require("strtok3/lib/core");
|
||||
const ParserFactory_1 = require("./ParserFactory");
|
||||
const RandomUint8ArrayReader_1 = require("./common/RandomUint8ArrayReader");
|
||||
const APEv2Parser_1 = require("./apev2/APEv2Parser");
|
||||
const ID3v1Parser_1 = require("./id3v1/ID3v1Parser");
|
||||
const Lyrics3_1 = require("./lyrics3/Lyrics3");
|
||||
/**
|
||||
* Parse audio from Node Stream.Readable
|
||||
* @param stream - Stream to read the audio track from
|
||||
* @param options - Parsing options
|
||||
* @param fileInfo - File information object or MIME-type string
|
||||
* @returns Metadata
|
||||
*/
|
||||
function parseStream(stream, fileInfo, options = {}) {
|
||||
return parseFromTokenizer(strtok3.fromStream(stream, typeof fileInfo === 'string' ? { mimeType: fileInfo } : fileInfo), options);
|
||||
}
|
||||
exports.parseStream = parseStream;
|
||||
/**
|
||||
* Parse audio from Node Buffer
|
||||
* @param uint8Array - Uint8Array holding audio data
|
||||
* @param fileInfo - File information object or MIME-type string
|
||||
* @param options - Parsing options
|
||||
* @returns Metadata
|
||||
* Ref: https://github.com/Borewit/strtok3/blob/e6938c81ff685074d5eb3064a11c0b03ca934c1d/src/index.ts#L15
|
||||
*/
|
||||
async function parseBuffer(uint8Array, fileInfo, options = {}) {
|
||||
const bufferReader = new RandomUint8ArrayReader_1.RandomUint8ArrayReader(uint8Array);
|
||||
await scanAppendingHeaders(bufferReader, options);
|
||||
const tokenizer = strtok3.fromBuffer(uint8Array, typeof fileInfo === 'string' ? { mimeType: fileInfo } : fileInfo);
|
||||
return parseFromTokenizer(tokenizer, options);
|
||||
}
|
||||
exports.parseBuffer = parseBuffer;
|
||||
/**
|
||||
* Parse audio from ITokenizer source
|
||||
* @param tokenizer - Audio source implementing the tokenizer interface
|
||||
* @param options - Parsing options
|
||||
* @returns Metadata
|
||||
*/
|
||||
function parseFromTokenizer(tokenizer, options) {
|
||||
return ParserFactory_1.ParserFactory.parseOnContentType(tokenizer, options);
|
||||
}
|
||||
exports.parseFromTokenizer = parseFromTokenizer;
|
||||
/**
|
||||
* Create a dictionary ordered by their tag id (key)
|
||||
* @param nativeTags list of tags
|
||||
* @returns tags indexed by id
|
||||
*/
|
||||
function orderTags(nativeTags) {
|
||||
const tags = {};
|
||||
for (const tag of nativeTags) {
|
||||
(tags[tag.id] = (tags[tag.id] || [])).push(tag.value);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
exports.orderTags = orderTags;
|
||||
/**
|
||||
* Convert rating to 1-5 star rating
|
||||
* @param rating: Normalized rating [0..1] (common.rating[n].rating)
|
||||
* @returns Number of stars: 1, 2, 3, 4 or 5 stars
|
||||
*/
|
||||
function ratingToStars(rating) {
|
||||
return rating === undefined ? 0 : 1 + Math.round(rating * 4);
|
||||
}
|
||||
exports.ratingToStars = ratingToStars;
|
||||
/**
|
||||
* Select most likely cover image.
|
||||
* @param pictures Usually metadata.common.picture
|
||||
* @return Cover image, if any, otherwise null
|
||||
*/
|
||||
function selectCover(pictures) {
|
||||
return pictures ? pictures.reduce((acc, cur) => {
|
||||
if (cur.name && cur.name.toLowerCase() in ['front', 'cover', 'cover (front)'])
|
||||
return cur;
|
||||
return acc;
|
||||
}) : null;
|
||||
}
|
||||
exports.selectCover = selectCover;
|
||||
async function scanAppendingHeaders(randomReader, options = {}) {
|
||||
let apeOffset = randomReader.fileSize;
|
||||
if (await (0, ID3v1Parser_1.hasID3v1Header)(randomReader)) {
|
||||
apeOffset -= 128;
|
||||
const lyricsLen = await (0, Lyrics3_1.getLyricsHeaderLength)(randomReader);
|
||||
apeOffset -= lyricsLen;
|
||||
}
|
||||
options.apeHeader = await APEv2Parser_1.APEv2Parser.findApeFooterOffset(randomReader, apeOffset);
|
||||
}
|
||||
exports.scanAppendingHeaders = scanAppendingHeaders;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { BasicParser } from '../common/BasicParser';
|
||||
/**
|
||||
* DSDIFF - Direct Stream Digital Interchange File Format (Phillips)
|
||||
*
|
||||
* Ref:
|
||||
* http://www.sonicstudio.com/pdf/dsd/DSDIFF_1.5_Spec.pdf
|
||||
*/
|
||||
export declare class DsdiffParser extends BasicParser {
|
||||
parse(): Promise<void>;
|
||||
private readFmt8Chunks;
|
||||
private readData;
|
||||
private handleSoundPropertyChunks;
|
||||
private handleChannelChunks;
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.DsdiffParser = void 0;
|
||||
const Token = require("token-types");
|
||||
const initDebug = require("debug");
|
||||
const FourCC_1 = require("../common/FourCC");
|
||||
const BasicParser_1 = require("../common/BasicParser");
|
||||
const DsdiffToken_1 = require("./DsdiffToken");
|
||||
const strtok3 = require("strtok3/lib/core");
|
||||
const ID3v2Parser_1 = require("../id3v2/ID3v2Parser");
|
||||
const debug = initDebug('music-metadata:parser:aiff');
|
||||
/**
|
||||
* DSDIFF - Direct Stream Digital Interchange File Format (Phillips)
|
||||
*
|
||||
* Ref:
|
||||
* http://www.sonicstudio.com/pdf/dsd/DSDIFF_1.5_Spec.pdf
|
||||
*/
|
||||
class DsdiffParser extends BasicParser_1.BasicParser {
|
||||
async parse() {
|
||||
const header = await this.tokenizer.readToken(DsdiffToken_1.ChunkHeader64);
|
||||
if (header.chunkID !== 'FRM8')
|
||||
throw new Error('Unexpected chunk-ID');
|
||||
const type = (await this.tokenizer.readToken(FourCC_1.FourCcToken)).trim();
|
||||
switch (type) {
|
||||
case 'DSD':
|
||||
this.metadata.setFormat('container', `DSDIFF/${type}`);
|
||||
this.metadata.setFormat('lossless', true);
|
||||
return this.readFmt8Chunks(header.chunkSize - BigInt(FourCC_1.FourCcToken.len));
|
||||
default:
|
||||
throw Error(`Unsupported DSDIFF type: ${type}`);
|
||||
}
|
||||
}
|
||||
async readFmt8Chunks(remainingSize) {
|
||||
while (remainingSize >= DsdiffToken_1.ChunkHeader64.len) {
|
||||
const chunkHeader = await this.tokenizer.readToken(DsdiffToken_1.ChunkHeader64);
|
||||
// If the data is an odd number of bytes in length, a pad byte must be added at the end
|
||||
debug(`Chunk id=${chunkHeader.chunkID}`);
|
||||
await this.readData(chunkHeader);
|
||||
remainingSize -= (BigInt(DsdiffToken_1.ChunkHeader64.len) + chunkHeader.chunkSize);
|
||||
}
|
||||
}
|
||||
async readData(header) {
|
||||
debug(`Reading data of chunk[ID=${header.chunkID}, size=${header.chunkSize}]`);
|
||||
const p0 = this.tokenizer.position;
|
||||
switch (header.chunkID.trim()) {
|
||||
case 'FVER': // 3.1 FORMAT VERSION CHUNK
|
||||
const version = await this.tokenizer.readToken(Token.UINT32_LE);
|
||||
debug(`DSDIFF version=${version}`);
|
||||
break;
|
||||
case 'PROP': // 3.2 PROPERTY CHUNK
|
||||
const propType = await this.tokenizer.readToken(FourCC_1.FourCcToken);
|
||||
if (propType !== 'SND ')
|
||||
throw new Error('Unexpected PROP-chunk ID');
|
||||
await this.handleSoundPropertyChunks(header.chunkSize - BigInt(FourCC_1.FourCcToken.len));
|
||||
break;
|
||||
case 'ID3': // Unofficial ID3 tag support
|
||||
const id3_data = await this.tokenizer.readToken(new Token.Uint8ArrayType(Number(header.chunkSize)));
|
||||
const rst = strtok3.fromBuffer(id3_data);
|
||||
await new ID3v2Parser_1.ID3v2Parser().parse(this.metadata, rst, this.options);
|
||||
break;
|
||||
default:
|
||||
debug(`Ignore chunk[ID=${header.chunkID}, size=${header.chunkSize}]`);
|
||||
break;
|
||||
case 'DSD':
|
||||
this.metadata.setFormat('numberOfSamples', Number(header.chunkSize * BigInt(8) / BigInt(this.metadata.format.numberOfChannels)));
|
||||
this.metadata.setFormat('duration', this.metadata.format.numberOfSamples / this.metadata.format.sampleRate);
|
||||
break;
|
||||
}
|
||||
const remaining = header.chunkSize - BigInt(this.tokenizer.position - p0);
|
||||
if (remaining > 0) {
|
||||
debug(`After Parsing chunk, remaining ${remaining} bytes`);
|
||||
await this.tokenizer.ignore(Number(remaining));
|
||||
}
|
||||
}
|
||||
async handleSoundPropertyChunks(remainingSize) {
|
||||
debug(`Parsing sound-property-chunks, remainingSize=${remainingSize}`);
|
||||
while (remainingSize > 0) {
|
||||
const sndPropHeader = await this.tokenizer.readToken(DsdiffToken_1.ChunkHeader64);
|
||||
debug(`Sound-property-chunk[ID=${sndPropHeader.chunkID}, size=${sndPropHeader.chunkSize}]`);
|
||||
const p0 = this.tokenizer.position;
|
||||
switch (sndPropHeader.chunkID.trim()) {
|
||||
case 'FS': // 3.2.1 Sample Rate Chunk
|
||||
const sampleRate = await this.tokenizer.readToken(Token.UINT32_BE);
|
||||
this.metadata.setFormat('sampleRate', sampleRate);
|
||||
break;
|
||||
case 'CHNL': // 3.2.2 Channels Chunk
|
||||
const numChannels = await this.tokenizer.readToken(Token.UINT16_BE);
|
||||
this.metadata.setFormat('numberOfChannels', numChannels);
|
||||
await this.handleChannelChunks(sndPropHeader.chunkSize - BigInt(Token.UINT16_BE.len));
|
||||
break;
|
||||
case 'CMPR': // 3.2.3 Compression Type Chunk
|
||||
const compressionIdCode = (await this.tokenizer.readToken(FourCC_1.FourCcToken)).trim();
|
||||
const count = await this.tokenizer.readToken(Token.UINT8);
|
||||
const compressionName = await this.tokenizer.readToken(new Token.StringType(count, 'ascii'));
|
||||
if (compressionIdCode === 'DSD') {
|
||||
this.metadata.setFormat('lossless', true);
|
||||
this.metadata.setFormat('bitsPerSample', 1);
|
||||
}
|
||||
this.metadata.setFormat('codec', `${compressionIdCode} (${compressionName})`);
|
||||
break;
|
||||
case 'ABSS': // 3.2.4 Absolute Start Time Chunk
|
||||
const hours = await this.tokenizer.readToken(Token.UINT16_BE);
|
||||
const minutes = await this.tokenizer.readToken(Token.UINT8);
|
||||
const seconds = await this.tokenizer.readToken(Token.UINT8);
|
||||
const samples = await this.tokenizer.readToken(Token.UINT32_BE);
|
||||
debug(`ABSS ${hours}:${minutes}:${seconds}.${samples}`);
|
||||
break;
|
||||
case 'LSCO': // 3.2.5 Loudspeaker Configuration Chunk
|
||||
const lsConfig = await this.tokenizer.readToken(Token.UINT16_BE);
|
||||
debug(`LSCO lsConfig=${lsConfig}`);
|
||||
break;
|
||||
case 'COMT':
|
||||
default:
|
||||
debug(`Unknown sound-property-chunk[ID=${sndPropHeader.chunkID}, size=${sndPropHeader.chunkSize}]`);
|
||||
await this.tokenizer.ignore(Number(sndPropHeader.chunkSize));
|
||||
}
|
||||
const remaining = sndPropHeader.chunkSize - BigInt(this.tokenizer.position - p0);
|
||||
if (remaining > 0) {
|
||||
debug(`After Parsing sound-property-chunk ${sndPropHeader.chunkSize}, remaining ${remaining} bytes`);
|
||||
await this.tokenizer.ignore(Number(remaining));
|
||||
}
|
||||
remainingSize -= BigInt(DsdiffToken_1.ChunkHeader64.len) + sndPropHeader.chunkSize;
|
||||
debug(`Parsing sound-property-chunks, remainingSize=${remainingSize}`);
|
||||
}
|
||||
if (this.metadata.format.lossless && this.metadata.format.sampleRate && this.metadata.format.numberOfChannels && this.metadata.format.bitsPerSample) {
|
||||
const bitrate = this.metadata.format.sampleRate * this.metadata.format.numberOfChannels * this.metadata.format.bitsPerSample;
|
||||
this.metadata.setFormat('bitrate', bitrate);
|
||||
}
|
||||
}
|
||||
async handleChannelChunks(remainingSize) {
|
||||
debug(`Parsing channel-chunks, remainingSize=${remainingSize}`);
|
||||
const channels = [];
|
||||
while (remainingSize >= FourCC_1.FourCcToken.len) {
|
||||
const channelId = await this.tokenizer.readToken(FourCC_1.FourCcToken);
|
||||
debug(`Channel[ID=${channelId}]`);
|
||||
channels.push(channelId);
|
||||
remainingSize -= BigInt(FourCC_1.FourCcToken.len);
|
||||
}
|
||||
debug(`Channels: ${channels.join(', ')}`);
|
||||
return channels;
|
||||
}
|
||||
}
|
||||
exports.DsdiffParser = DsdiffParser;
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { IChunkHeader64 } from '../iff';
|
||||
import { IGetToken } from 'strtok3/lib/core';
|
||||
export { IChunkHeader64 } from '../iff';
|
||||
/**
|
||||
* DSDIFF chunk header
|
||||
* The data-size encoding is deviating from EA-IFF 85
|
||||
* Ref: http://www.sonicstudio.com/pdf/dsd/DSDIFF_1.5_Spec.pdf
|
||||
*/
|
||||
export declare const ChunkHeader64: IGetToken<IChunkHeader64>;
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ChunkHeader64 = void 0;
|
||||
const Token = require("token-types");
|
||||
const FourCC_1 = require("../common/FourCC");
|
||||
/**
|
||||
* DSDIFF chunk header
|
||||
* The data-size encoding is deviating from EA-IFF 85
|
||||
* Ref: http://www.sonicstudio.com/pdf/dsd/DSDIFF_1.5_Spec.pdf
|
||||
*/
|
||||
exports.ChunkHeader64 = {
|
||||
len: 12,
|
||||
get: (buf, off) => {
|
||||
return {
|
||||
// Group-ID
|
||||
chunkID: FourCC_1.FourCcToken.get(buf, off),
|
||||
// Size
|
||||
chunkSize: Token.INT64_BE.get(buf, off + 4)
|
||||
};
|
||||
}
|
||||
};
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { IGetToken } from 'strtok3/lib/core';
|
||||
/**
|
||||
* Common interface for the common chunk DSD header
|
||||
*/
|
||||
export interface IChunkHeader {
|
||||
/**
|
||||
* Chunk ID
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Chunk size
|
||||
*/
|
||||
size: bigint;
|
||||
}
|
||||
/**
|
||||
* Common chunk DSD header: the 'chunk name (Four-CC)' & chunk size
|
||||
*/
|
||||
export declare const ChunkHeader: IGetToken<IChunkHeader>;
|
||||
/**
|
||||
* Interface to DSD payload chunk
|
||||
*/
|
||||
export interface IDsdChunk {
|
||||
/**
|
||||
* Total file size
|
||||
*/
|
||||
fileSize: bigint;
|
||||
/**
|
||||
* If Metadata doesn’t exist, set 0. If the file has ID3v2 tag, then set the pointer to it.
|
||||
* ID3v2 tag should be located in the end of the file.
|
||||
*/
|
||||
metadataPointer: bigint;
|
||||
}
|
||||
/**
|
||||
* Common chunk DSD header: the 'chunk name (Four-CC)' & chunk size
|
||||
*/
|
||||
export declare const DsdChunk: IGetToken<IDsdChunk>;
|
||||
export declare enum ChannelType {
|
||||
mono = 1,
|
||||
stereo = 2,
|
||||
channels = 3,
|
||||
quad = 4,
|
||||
'4 channels' = 5,
|
||||
'5 channels' = 6,
|
||||
'5.1 channels' = 7
|
||||
}
|
||||
/**
|
||||
* Interface to format chunk payload chunk
|
||||
*/
|
||||
export interface IFormatChunk {
|
||||
/**
|
||||
* Version of this file format
|
||||
*/
|
||||
formatVersion: number;
|
||||
/**
|
||||
* Format ID
|
||||
*/
|
||||
formatID: number;
|
||||
/**
|
||||
* Channel Type
|
||||
*/
|
||||
channelType: ChannelType;
|
||||
/**
|
||||
* Channel num
|
||||
*/
|
||||
channelNum: number;
|
||||
/**
|
||||
* Sampling frequency
|
||||
*/
|
||||
samplingFrequency: number;
|
||||
/**
|
||||
* Bits per sample
|
||||
*/
|
||||
bitsPerSample: number;
|
||||
/**
|
||||
* Sample count
|
||||
*/
|
||||
sampleCount: bigint;
|
||||
/**
|
||||
* Block size per channel
|
||||
*/
|
||||
blockSizePerChannel: number;
|
||||
}
|
||||
/**
|
||||
* Common chunk DSD header: the 'chunk name (Four-CC)' & chunk size
|
||||
*/
|
||||
export declare const FormatChunk: IGetToken<IFormatChunk>;
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.FormatChunk = exports.ChannelType = exports.DsdChunk = exports.ChunkHeader = void 0;
|
||||
const Token = require("token-types");
|
||||
const FourCC_1 = require("../common/FourCC");
|
||||
/**
|
||||
* Common chunk DSD header: the 'chunk name (Four-CC)' & chunk size
|
||||
*/
|
||||
exports.ChunkHeader = {
|
||||
len: 12,
|
||||
get: (buf, off) => {
|
||||
return { id: FourCC_1.FourCcToken.get(buf, off), size: Token.UINT64_LE.get(buf, off + 4) };
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Common chunk DSD header: the 'chunk name (Four-CC)' & chunk size
|
||||
*/
|
||||
exports.DsdChunk = {
|
||||
len: 16,
|
||||
get: (buf, off) => {
|
||||
return {
|
||||
fileSize: Token.INT64_LE.get(buf, off),
|
||||
metadataPointer: Token.INT64_LE.get(buf, off + 8)
|
||||
};
|
||||
}
|
||||
};
|
||||
var ChannelType;
|
||||
(function (ChannelType) {
|
||||
ChannelType[ChannelType["mono"] = 1] = "mono";
|
||||
ChannelType[ChannelType["stereo"] = 2] = "stereo";
|
||||
ChannelType[ChannelType["channels"] = 3] = "channels";
|
||||
ChannelType[ChannelType["quad"] = 4] = "quad";
|
||||
ChannelType[ChannelType["4 channels"] = 5] = "4 channels";
|
||||
ChannelType[ChannelType["5 channels"] = 6] = "5 channels";
|
||||
ChannelType[ChannelType["5.1 channels"] = 7] = "5.1 channels";
|
||||
})(ChannelType = exports.ChannelType || (exports.ChannelType = {}));
|
||||
/**
|
||||
* Common chunk DSD header: the 'chunk name (Four-CC)' & chunk size
|
||||
*/
|
||||
exports.FormatChunk = {
|
||||
len: 40,
|
||||
get: (buf, off) => {
|
||||
return {
|
||||
formatVersion: Token.INT32_LE.get(buf, off),
|
||||
formatID: Token.INT32_LE.get(buf, off + 4),
|
||||
channelType: Token.INT32_LE.get(buf, off + 8),
|
||||
channelNum: Token.INT32_LE.get(buf, off + 12),
|
||||
samplingFrequency: Token.INT32_LE.get(buf, off + 16),
|
||||
bitsPerSample: Token.INT32_LE.get(buf, off + 20),
|
||||
sampleCount: Token.INT64_LE.get(buf, off + 24),
|
||||
blockSizePerChannel: Token.INT32_LE.get(buf, off + 32)
|
||||
};
|
||||
}
|
||||
};
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { AbstractID3Parser } from '../id3v2/AbstractID3Parser';
|
||||
/**
|
||||
* DSF (dsd stream file) File Parser
|
||||
* Ref: https://dsd-guide.com/sites/default/files/white-papers/DSFFileFormatSpec_E.pdf
|
||||
*/
|
||||
export declare class DsfParser extends AbstractID3Parser {
|
||||
_parse(): Promise<void>;
|
||||
private parseChunks;
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
'use strict';
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.DsfParser = void 0;
|
||||
const AbstractID3Parser_1 = require("../id3v2/AbstractID3Parser");
|
||||
const _debug = require("debug");
|
||||
const DsfChunk_1 = require("./DsfChunk");
|
||||
const ID3v2Parser_1 = require("../id3v2/ID3v2Parser");
|
||||
const debug = _debug('music-metadata:parser:DSF');
|
||||
/**
|
||||
* DSF (dsd stream file) File Parser
|
||||
* Ref: https://dsd-guide.com/sites/default/files/white-papers/DSFFileFormatSpec_E.pdf
|
||||
*/
|
||||
class DsfParser extends AbstractID3Parser_1.AbstractID3Parser {
|
||||
async _parse() {
|
||||
const p0 = this.tokenizer.position; // mark start position, normally 0
|
||||
const chunkHeader = await this.tokenizer.readToken(DsfChunk_1.ChunkHeader);
|
||||
if (chunkHeader.id !== 'DSD ')
|
||||
throw new Error('Invalid chunk signature');
|
||||
this.metadata.setFormat('container', 'DSF');
|
||||
this.metadata.setFormat('lossless', true);
|
||||
const dsdChunk = await this.tokenizer.readToken(DsfChunk_1.DsdChunk);
|
||||
if (dsdChunk.metadataPointer === BigInt(0)) {
|
||||
debug(`No ID3v2 tag present`);
|
||||
}
|
||||
else {
|
||||
debug(`expect ID3v2 at offset=${dsdChunk.metadataPointer}`);
|
||||
await this.parseChunks(dsdChunk.fileSize - chunkHeader.size);
|
||||
// Jump to ID3 header
|
||||
await this.tokenizer.ignore(Number(dsdChunk.metadataPointer) - this.tokenizer.position - p0);
|
||||
return new ID3v2Parser_1.ID3v2Parser().parse(this.metadata, this.tokenizer, this.options);
|
||||
}
|
||||
}
|
||||
async parseChunks(bytesRemaining) {
|
||||
while (bytesRemaining >= DsfChunk_1.ChunkHeader.len) {
|
||||
const chunkHeader = await this.tokenizer.readToken(DsfChunk_1.ChunkHeader);
|
||||
debug(`Parsing chunk name=${chunkHeader.id} size=${chunkHeader.size}`);
|
||||
switch (chunkHeader.id) {
|
||||
case 'fmt ':
|
||||
const formatChunk = await this.tokenizer.readToken(DsfChunk_1.FormatChunk);
|
||||
this.metadata.setFormat('numberOfChannels', formatChunk.channelNum);
|
||||
this.metadata.setFormat('sampleRate', formatChunk.samplingFrequency);
|
||||
this.metadata.setFormat('bitsPerSample', formatChunk.bitsPerSample);
|
||||
this.metadata.setFormat('numberOfSamples', formatChunk.sampleCount);
|
||||
this.metadata.setFormat('duration', Number(formatChunk.sampleCount) / formatChunk.samplingFrequency);
|
||||
const bitrate = formatChunk.bitsPerSample * formatChunk.samplingFrequency * formatChunk.channelNum;
|
||||
this.metadata.setFormat('bitrate', bitrate);
|
||||
return; // We got what we want, stop further processing of chunks
|
||||
default:
|
||||
this.tokenizer.ignore(Number(chunkHeader.size) - DsfChunk_1.ChunkHeader.len);
|
||||
break;
|
||||
}
|
||||
bytesRemaining -= chunkHeader.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.DsfParser = DsfParser;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { AbstractID3Parser } from '../id3v2/AbstractID3Parser';
|
||||
import { INativeMetadataCollector } from '../common/MetadataCollector';
|
||||
import { ITokenizer } from 'strtok3/lib/core';
|
||||
import { IOptions } from '../type';
|
||||
import { ITokenParser } from '../ParserFactory';
|
||||
export declare class FlacParser extends AbstractID3Parser {
|
||||
private vorbisParser;
|
||||
private padding;
|
||||
/**
|
||||
* Initialize parser with output (metadata), input (tokenizer) & parsing options (options).
|
||||
* @param {INativeMetadataCollector} metadata Output
|
||||
* @param {ITokenizer} tokenizer Input
|
||||
* @param {IOptions} options Parsing options
|
||||
*/
|
||||
init(metadata: INativeMetadataCollector, tokenizer: ITokenizer, options: IOptions): ITokenParser;
|
||||
_parse(): Promise<void>;
|
||||
private parseDataBlock;
|
||||
/**
|
||||
* Parse STREAMINFO
|
||||
*/
|
||||
private parseBlockStreamInfo;
|
||||
/**
|
||||
* Parse VORBIS_COMMENT
|
||||
* Ref: https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-640004.2.3
|
||||
*/
|
||||
private parseComment;
|
||||
private parsePicture;
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
'use strict';
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.FlacParser = void 0;
|
||||
const util = require("../common/Util");
|
||||
const Token = require("token-types");
|
||||
const Vorbis_1 = require("../ogg/vorbis/Vorbis");
|
||||
const AbstractID3Parser_1 = require("../id3v2/AbstractID3Parser");
|
||||
const FourCC_1 = require("../common/FourCC");
|
||||
const _debug = require("debug");
|
||||
const VorbisParser_1 = require("../ogg/vorbis/VorbisParser");
|
||||
const VorbisDecoder_1 = require("../ogg/vorbis/VorbisDecoder");
|
||||
const debug = _debug('music-metadata:parser:FLAC');
|
||||
/**
|
||||
* FLAC supports up to 128 kinds of metadata blocks; currently the following are defined:
|
||||
* ref: https://xiph.org/flac/format.html#metadata_block
|
||||
*/
|
||||
var BlockType;
|
||||
(function (BlockType) {
|
||||
BlockType[BlockType["STREAMINFO"] = 0] = "STREAMINFO";
|
||||
BlockType[BlockType["PADDING"] = 1] = "PADDING";
|
||||
BlockType[BlockType["APPLICATION"] = 2] = "APPLICATION";
|
||||
BlockType[BlockType["SEEKTABLE"] = 3] = "SEEKTABLE";
|
||||
BlockType[BlockType["VORBIS_COMMENT"] = 4] = "VORBIS_COMMENT";
|
||||
BlockType[BlockType["CUESHEET"] = 5] = "CUESHEET";
|
||||
BlockType[BlockType["PICTURE"] = 6] = "PICTURE";
|
||||
})(BlockType || (BlockType = {}));
|
||||
class FlacParser extends AbstractID3Parser_1.AbstractID3Parser {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.padding = 0;
|
||||
}
|
||||
/**
|
||||
* Initialize parser with output (metadata), input (tokenizer) & parsing options (options).
|
||||
* @param {INativeMetadataCollector} metadata Output
|
||||
* @param {ITokenizer} tokenizer Input
|
||||
* @param {IOptions} options Parsing options
|
||||
*/
|
||||
init(metadata, tokenizer, options) {
|
||||
super.init(metadata, tokenizer, options);
|
||||
this.vorbisParser = new VorbisParser_1.VorbisParser(metadata, options);
|
||||
return this;
|
||||
}
|
||||
async _parse() {
|
||||
const fourCC = await this.tokenizer.readToken(FourCC_1.FourCcToken);
|
||||
if (fourCC.toString() !== 'fLaC') {
|
||||
throw new Error('Invalid FLAC preamble');
|
||||
}
|
||||
let blockHeader;
|
||||
do {
|
||||
// Read block header
|
||||
blockHeader = await this.tokenizer.readToken(Metadata.BlockHeader);
|
||||
// Parse block data
|
||||
await this.parseDataBlock(blockHeader);
|
||||
} while (!blockHeader.lastBlock);
|
||||
if (this.tokenizer.fileInfo.size && this.metadata.format.duration) {
|
||||
const dataSize = this.tokenizer.fileInfo.size - this.tokenizer.position;
|
||||
this.metadata.setFormat('bitrate', 8 * dataSize / this.metadata.format.duration);
|
||||
}
|
||||
}
|
||||
parseDataBlock(blockHeader) {
|
||||
debug(`blockHeader type=${blockHeader.type}, length=${blockHeader.length}`);
|
||||
switch (blockHeader.type) {
|
||||
case BlockType.STREAMINFO:
|
||||
return this.parseBlockStreamInfo(blockHeader.length);
|
||||
case BlockType.PADDING:
|
||||
this.padding += blockHeader.length;
|
||||
break;
|
||||
case BlockType.APPLICATION:
|
||||
break;
|
||||
case BlockType.SEEKTABLE:
|
||||
break;
|
||||
case BlockType.VORBIS_COMMENT:
|
||||
return this.parseComment(blockHeader.length);
|
||||
case BlockType.CUESHEET:
|
||||
break;
|
||||
case BlockType.PICTURE:
|
||||
return this.parsePicture(blockHeader.length).then();
|
||||
default:
|
||||
this.metadata.addWarning('Unknown block type: ' + blockHeader.type);
|
||||
}
|
||||
// Ignore data block
|
||||
return this.tokenizer.ignore(blockHeader.length).then();
|
||||
}
|
||||
/**
|
||||
* Parse STREAMINFO
|
||||
*/
|
||||
async parseBlockStreamInfo(dataLen) {
|
||||
if (dataLen !== Metadata.BlockStreamInfo.len)
|
||||
throw new Error('Unexpected block-stream-info length');
|
||||
const streamInfo = await this.tokenizer.readToken(Metadata.BlockStreamInfo);
|
||||
this.metadata.setFormat('container', 'FLAC');
|
||||
this.metadata.setFormat('codec', 'FLAC');
|
||||
this.metadata.setFormat('lossless', true);
|
||||
this.metadata.setFormat('numberOfChannels', streamInfo.channels);
|
||||
this.metadata.setFormat('bitsPerSample', streamInfo.bitsPerSample);
|
||||
this.metadata.setFormat('sampleRate', streamInfo.sampleRate);
|
||||
if (streamInfo.totalSamples > 0) {
|
||||
this.metadata.setFormat('duration', streamInfo.totalSamples / streamInfo.sampleRate);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Parse VORBIS_COMMENT
|
||||
* Ref: https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-640004.2.3
|
||||
*/
|
||||
async parseComment(dataLen) {
|
||||
const data = await this.tokenizer.readToken(new Token.Uint8ArrayType(dataLen));
|
||||
const decoder = new VorbisDecoder_1.VorbisDecoder(data, 0);
|
||||
decoder.readStringUtf8(); // vendor (skip)
|
||||
const commentListLength = decoder.readInt32();
|
||||
for (let i = 0; i < commentListLength; i++) {
|
||||
const tag = decoder.parseUserComment();
|
||||
this.vorbisParser.addTag(tag.key, tag.value);
|
||||
}
|
||||
}
|
||||
async parsePicture(dataLen) {
|
||||
if (this.options.skipCovers) {
|
||||
return this.tokenizer.ignore(dataLen);
|
||||
}
|
||||
else {
|
||||
const picture = await this.tokenizer.readToken(new Vorbis_1.VorbisPictureToken(dataLen));
|
||||
this.vorbisParser.addTag('METADATA_BLOCK_PICTURE', picture);
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.FlacParser = FlacParser;
|
||||
class Metadata {
|
||||
}
|
||||
Metadata.BlockHeader = {
|
||||
len: 4,
|
||||
get: (buf, off) => {
|
||||
return {
|
||||
lastBlock: util.getBit(buf, off, 7),
|
||||
type: util.getBitAllignedNumber(buf, off, 1, 7),
|
||||
length: Token.UINT24_BE.get(buf, off + 1)
|
||||
};
|
||||
}
|
||||
};
|
||||
/**
|
||||
* METADATA_BLOCK_DATA
|
||||
* Ref: https://xiph.org/flac/format.html#metadata_block_streaminfo
|
||||
*/
|
||||
Metadata.BlockStreamInfo = {
|
||||
len: 34,
|
||||
get: (buf, off) => {
|
||||
return {
|
||||
// The minimum block size (in samples) used in the stream.
|
||||
minimumBlockSize: Token.UINT16_BE.get(buf, off),
|
||||
// The maximum block size (in samples) used in the stream.
|
||||
// (Minimum blocksize == maximum blocksize) implies a fixed-blocksize stream.
|
||||
maximumBlockSize: Token.UINT16_BE.get(buf, off + 2) / 1000,
|
||||
// The minimum frame size (in bytes) used in the stream.
|
||||
// May be 0 to imply the value is not known.
|
||||
minimumFrameSize: Token.UINT24_BE.get(buf, off + 4),
|
||||
// The maximum frame size (in bytes) used in the stream.
|
||||
// May be 0 to imply the value is not known.
|
||||
maximumFrameSize: Token.UINT24_BE.get(buf, off + 7),
|
||||
// Sample rate in Hz. Though 20 bits are available,
|
||||
// the maximum sample rate is limited by the structure of frame headers to 655350Hz.
|
||||
// Also, a value of 0 is invalid.
|
||||
sampleRate: Token.UINT24_BE.get(buf, off + 10) >> 4,
|
||||
// probably slower: sampleRate: common.getBitAllignedNumber(buf, off + 10, 0, 20),
|
||||
// (number of channels)-1. FLAC supports from 1 to 8 channels
|
||||
channels: util.getBitAllignedNumber(buf, off + 12, 4, 3) + 1,
|
||||
// bits per sample)-1.
|
||||
// FLAC supports from 4 to 32 bits per sample. Currently the reference encoder and decoders only support up to 24 bits per sample.
|
||||
bitsPerSample: util.getBitAllignedNumber(buf, off + 12, 7, 5) + 1,
|
||||
// Total samples in stream.
|
||||
// 'Samples' means inter-channel sample, i.e. one second of 44.1Khz audio will have 44100 samples regardless of the number of channels.
|
||||
// A value of zero here means the number of total samples is unknown.
|
||||
totalSamples: util.getBitAllignedNumber(buf, off + 13, 4, 36),
|
||||
// the MD5 hash of the file (see notes for usage... it's a littly tricky)
|
||||
fileMD5: new Token.Uint8ArrayType(16).get(buf, off + 18)
|
||||
};
|
||||
}
|
||||
};
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { BasicParser } from '../common/BasicParser';
|
||||
import { IRandomReader } from '../type';
|
||||
/**
|
||||
* ID3v1 Genre mappings
|
||||
* Ref: https://de.wikipedia.org/wiki/Liste_der_ID3v1-Genres
|
||||
*/
|
||||
export declare const Genres: string[];
|
||||
export declare class ID3v1Parser extends BasicParser {
|
||||
private static getGenre;
|
||||
parse(): Promise<void>;
|
||||
private addTag;
|
||||
}
|
||||
export declare function hasID3v1Header(reader: IRandomReader): Promise<boolean>;
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.hasID3v1Header = exports.ID3v1Parser = exports.Genres = void 0;
|
||||
const initDebug = require("debug");
|
||||
const util = require("../common/Util");
|
||||
const Token = require("token-types");
|
||||
const BasicParser_1 = require("../common/BasicParser");
|
||||
const APEv2Parser_1 = require("../apev2/APEv2Parser");
|
||||
const debug = initDebug('music-metadata:parser:ID3v1');
|
||||
/**
|
||||
* ID3v1 Genre mappings
|
||||
* Ref: https://de.wikipedia.org/wiki/Liste_der_ID3v1-Genres
|
||||
*/
|
||||
exports.Genres = [
|
||||
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop",
|
||||
"Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", "Rap", "Reggae", "Rock",
|
||||
"Techno", "Industrial", "Alternative", "Ska", "Death Metal", "Pranks", "Soundtrack",
|
||||
"Euro-Techno", "Ambient", "Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance",
|
||||
"Classical", "Instrumental", "Acid", "House", "Game", "Sound Clip", "Gospel", "Noise",
|
||||
"Alt. Rock", "Bass", "Soul", "Punk", "Space", "Meditative", "Instrumental Pop",
|
||||
"Instrumental Rock", "Ethnic", "Gothic", "Darkwave", "Techno-Industrial",
|
||||
"Electronic", "Pop-Folk", "Eurodance", "Dream", "Southern Rock", "Comedy", "Cult",
|
||||
"Gangsta Rap", "Top 40", "Christian Rap", "Pop/Funk", "Jungle", "Native American",
|
||||
"Cabaret", "New Wave", "Psychedelic", "Rave", "Showtunes", "Trailer", "Lo-Fi", "Tribal",
|
||||
"Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll", "Hard Rock",
|
||||
"Folk", "Folk/Rock", "National Folk", "Swing", "Fast-Fusion", "Bebob", "Latin", "Revival",
|
||||
"Celtic", "Bluegrass", "Avantgarde", "Gothic Rock", "Progressive Rock", "Psychedelic Rock",
|
||||
"Symphonic Rock", "Slow Rock", "Big Band", "Chorus", "Easy Listening", "Acoustic", "Humour",
|
||||
"Speech", "Chanson", "Opera", "Chamber Music", "Sonata", "Symphony", "Booty Bass", "Primus",
|
||||
"Porn Groove", "Satire", "Slow Jam", "Club", "Tango", "Samba", "Folklore",
|
||||
"Ballad", "Power Ballad", "Rhythmic Soul", "Freestyle", "Duet", "Punk Rock", "Drum Solo",
|
||||
"A Cappella", "Euro-House", "Dance Hall", "Goa", "Drum & Bass", "Club-House",
|
||||
"Hardcore", "Terror", "Indie", "BritPop", "Negerpunk", "Polsk Punk", "Beat",
|
||||
"Christian Gangsta Rap", "Heavy Metal", "Black Metal", "Crossover", "Contemporary Christian",
|
||||
"Christian Rock", "Merengue", "Salsa", "Thrash Metal", "Anime", "JPop", "Synthpop",
|
||||
"Abstract", "Art Rock", "Baroque", "Bhangra", "Big Beat", "Breakbeat", "Chillout",
|
||||
"Downtempo", "Dub", "EBM", "Eclectic", "Electro", "Electroclash", "Emo", "Experimental",
|
||||
"Garage", "Global", "IDM", "Illbient", "Industro-Goth", "Jam Band", "Krautrock",
|
||||
"Leftfield", "Lounge", "Math Rock", "New Romantic", "Nu-Breakz", "Post-Punk", "Post-Rock",
|
||||
"Psytrance", "Shoegaze", "Space Rock", "Trop Rock", "World Music", "Neoclassical", "Audiobook",
|
||||
"Audio Theatre", "Neue Deutsche Welle", "Podcast", "Indie Rock", "G-Funk", "Dubstep",
|
||||
"Garage Rock", "Psybient"
|
||||
];
|
||||
/**
|
||||
* Spec: http://id3.org/ID3v1
|
||||
* Wiki: https://en.wikipedia.org/wiki/ID3
|
||||
*/
|
||||
const Iid3v1Token = {
|
||||
len: 128,
|
||||
/**
|
||||
* @param buf Buffer possibly holding the 128 bytes ID3v1.1 metadata header
|
||||
* @param off Offset in buffer in bytes
|
||||
* @returns ID3v1.1 header if first 3 bytes equals 'TAG', otherwise null is returned
|
||||
*/
|
||||
get: (buf, off) => {
|
||||
const header = new Id3v1StringType(3).get(buf, off);
|
||||
return header === "TAG" ? {
|
||||
header,
|
||||
title: new Id3v1StringType(30).get(buf, off + 3),
|
||||
artist: new Id3v1StringType(30).get(buf, off + 33),
|
||||
album: new Id3v1StringType(30).get(buf, off + 63),
|
||||
year: new Id3v1StringType(4).get(buf, off + 93),
|
||||
comment: new Id3v1StringType(28).get(buf, off + 97),
|
||||
// ID3v1.1 separator for track
|
||||
zeroByte: Token.UINT8.get(buf, off + 127),
|
||||
// track: ID3v1.1 field added by Michael Mutschler
|
||||
track: Token.UINT8.get(buf, off + 126),
|
||||
genre: Token.UINT8.get(buf, off + 127)
|
||||
} : null;
|
||||
}
|
||||
};
|
||||
class Id3v1StringType extends Token.StringType {
|
||||
constructor(len) {
|
||||
super(len, "binary");
|
||||
}
|
||||
get(buf, off) {
|
||||
let value = super.get(buf, off);
|
||||
value = util.trimRightNull(value);
|
||||
value = value.trim();
|
||||
return value.length > 0 ? value : undefined;
|
||||
}
|
||||
}
|
||||
class ID3v1Parser extends BasicParser_1.BasicParser {
|
||||
static getGenre(genreIndex) {
|
||||
if (genreIndex < exports.Genres.length) {
|
||||
return exports.Genres[genreIndex];
|
||||
}
|
||||
return undefined; // ToDO: generate warning
|
||||
}
|
||||
async parse() {
|
||||
if (!this.tokenizer.fileInfo.size) {
|
||||
debug('Skip checking for ID3v1 because the file-size is unknown');
|
||||
return;
|
||||
}
|
||||
if (this.options.apeHeader) {
|
||||
this.tokenizer.ignore(this.options.apeHeader.offset - this.tokenizer.position);
|
||||
const apeParser = new APEv2Parser_1.APEv2Parser();
|
||||
apeParser.init(this.metadata, this.tokenizer, this.options);
|
||||
await apeParser.parseTags(this.options.apeHeader.footer);
|
||||
}
|
||||
const offset = this.tokenizer.fileInfo.size - Iid3v1Token.len;
|
||||
if (this.tokenizer.position > offset) {
|
||||
debug('Already consumed the last 128 bytes');
|
||||
return;
|
||||
}
|
||||
const header = await this.tokenizer.readToken(Iid3v1Token, offset);
|
||||
if (header) {
|
||||
debug("ID3v1 header found at: pos=%s", this.tokenizer.fileInfo.size - Iid3v1Token.len);
|
||||
for (const id of ["title", "artist", "album", "comment", "track", "year"]) {
|
||||
if (header[id] && header[id] !== "")
|
||||
this.addTag(id, header[id]);
|
||||
}
|
||||
const genre = ID3v1Parser.getGenre(header.genre);
|
||||
if (genre)
|
||||
this.addTag('genre', genre);
|
||||
}
|
||||
else {
|
||||
debug("ID3v1 header not found at: pos=%s", this.tokenizer.fileInfo.size - Iid3v1Token.len);
|
||||
}
|
||||
}
|
||||
addTag(id, value) {
|
||||
this.metadata.addTag('ID3v1', id, value);
|
||||
}
|
||||
}
|
||||
exports.ID3v1Parser = ID3v1Parser;
|
||||
async function hasID3v1Header(reader) {
|
||||
if (reader.fileSize >= 128) {
|
||||
const tag = Buffer.alloc(3);
|
||||
await reader.randomRead(tag, 0, tag.length, reader.fileSize - 128);
|
||||
return tag.toString('binary') === 'TAG';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
exports.hasID3v1Header = hasID3v1Header;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { CommonTagMapper } from "../common/GenericTagMapper";
|
||||
export declare class ID3v1TagMapper extends CommonTagMapper {
|
||||
constructor();
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ID3v1TagMapper = void 0;
|
||||
const GenericTagMapper_1 = require("../common/GenericTagMapper");
|
||||
/**
|
||||
* ID3v1 tag mappings
|
||||
*/
|
||||
const id3v1TagMap = {
|
||||
title: 'title',
|
||||
artist: 'artist',
|
||||
album: 'album',
|
||||
year: 'year',
|
||||
comment: 'comment',
|
||||
track: 'track',
|
||||
genre: 'genre'
|
||||
};
|
||||
class ID3v1TagMapper extends GenericTagMapper_1.CommonTagMapper {
|
||||
constructor() {
|
||||
super(['ID3v1'], id3v1TagMap);
|
||||
}
|
||||
}
|
||||
exports.ID3v1TagMapper = ID3v1TagMapper;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { ITokenizer } from 'strtok3/lib/core';
|
||||
import { BasicParser } from '../common/BasicParser';
|
||||
/**
|
||||
* Abstract parser which tries take ID3v2 and ID3v1 headers.
|
||||
*/
|
||||
export declare abstract class AbstractID3Parser extends BasicParser {
|
||||
static startsWithID3v2Header(tokenizer: ITokenizer): Promise<boolean>;
|
||||
private id3parser;
|
||||
parse(): Promise<void>;
|
||||
/**
|
||||
* Called after ID3v2 headers are parsed
|
||||
*/
|
||||
abstract _parse(): Promise<void>;
|
||||
protected finalize(): void;
|
||||
private parseID3v2;
|
||||
private tryReadId3v2Headers;
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AbstractID3Parser = void 0;
|
||||
const core_1 = require("strtok3/lib/core");
|
||||
const ID3v2Token_1 = require("./ID3v2Token");
|
||||
const ID3v2Parser_1 = require("./ID3v2Parser");
|
||||
const ID3v1Parser_1 = require("../id3v1/ID3v1Parser");
|
||||
const _debug = require("debug");
|
||||
const BasicParser_1 = require("../common/BasicParser");
|
||||
const debug = _debug('music-metadata:parser:ID3');
|
||||
/**
|
||||
* Abstract parser which tries take ID3v2 and ID3v1 headers.
|
||||
*/
|
||||
class AbstractID3Parser extends BasicParser_1.BasicParser {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.id3parser = new ID3v2Parser_1.ID3v2Parser();
|
||||
}
|
||||
static async startsWithID3v2Header(tokenizer) {
|
||||
return (await tokenizer.peekToken(ID3v2Token_1.ID3v2Header)).fileIdentifier === 'ID3';
|
||||
}
|
||||
async parse() {
|
||||
try {
|
||||
await this.parseID3v2();
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof core_1.EndOfStreamError) {
|
||||
debug(`End-of-stream`);
|
||||
}
|
||||
else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
finalize() {
|
||||
return;
|
||||
}
|
||||
async parseID3v2() {
|
||||
await this.tryReadId3v2Headers();
|
||||
debug("End of ID3v2 header, go to MPEG-parser: pos=%s", this.tokenizer.position);
|
||||
await this._parse();
|
||||
if (this.options.skipPostHeaders && this.metadata.hasAny()) {
|
||||
this.finalize();
|
||||
}
|
||||
else {
|
||||
const id3v1parser = new ID3v1Parser_1.ID3v1Parser();
|
||||
await id3v1parser.init(this.metadata, this.tokenizer, this.options).parse();
|
||||
this.finalize();
|
||||
}
|
||||
}
|
||||
async tryReadId3v2Headers() {
|
||||
const id3Header = await this.tokenizer.peekToken(ID3v2Token_1.ID3v2Header);
|
||||
if (id3Header.fileIdentifier === "ID3") {
|
||||
debug("Found ID3v2 header, pos=%s", this.tokenizer.position);
|
||||
await this.id3parser.parse(this.metadata, this.tokenizer, this.options);
|
||||
return this.tryReadId3v2Headers();
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.AbstractID3Parser = AbstractID3Parser;
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/// <reference types="node" />
|
||||
import { ID3v2MajorVersion } from './ID3v2Token';
|
||||
import { IWarningCollector } from '../common/MetadataCollector';
|
||||
export declare function parseGenre(origVal: string): string[];
|
||||
export declare class FrameParser {
|
||||
private major;
|
||||
private warningCollector;
|
||||
/**
|
||||
* Create id3v2 frame parser
|
||||
* @param major - Major version, e.g. (4) for id3v2.4
|
||||
* @param warningCollector - Used to collect decode issue
|
||||
*/
|
||||
constructor(major: ID3v2MajorVersion, warningCollector: IWarningCollector);
|
||||
readData(b: Buffer, type: string, includeCovers: boolean): any;
|
||||
protected static fixPictureMimeType(pictureType: string): string;
|
||||
/**
|
||||
* Converts TMCL (Musician credits list) or TIPL (Involved people list)
|
||||
* @param entries
|
||||
*/
|
||||
private static functionList;
|
||||
/**
|
||||
* id3v2.4 defines that multiple T* values are separated by 0x00
|
||||
* id3v2.3 defines that TCOM, TEXT, TOLY, TOPE & TPE1 values are separated by /
|
||||
* @param tag - Tag name
|
||||
* @param text - Concatenated tag value
|
||||
* @returns Split tag value
|
||||
*/
|
||||
private splitValue;
|
||||
private static trimArray;
|
||||
private static readIdentifierAndData;
|
||||
private static getNullTerminatorLength;
|
||||
}
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.FrameParser = exports.parseGenre = void 0;
|
||||
const initDebug = require("debug");
|
||||
const Token = require("token-types");
|
||||
const util = require("../common/Util");
|
||||
const ID3v2Token_1 = require("./ID3v2Token");
|
||||
const ID3v1Parser_1 = require("../id3v1/ID3v1Parser");
|
||||
const debug = initDebug('music-metadata:id3v2:frame-parser');
|
||||
const defaultEnc = 'latin1'; // latin1 == iso-8859-1;
|
||||
function parseGenre(origVal) {
|
||||
// match everything inside parentheses
|
||||
const genres = [];
|
||||
let code;
|
||||
let word = '';
|
||||
for (const c of origVal) {
|
||||
if (typeof code === 'string') {
|
||||
if (c === '(' && code === '') {
|
||||
word += '(';
|
||||
code = undefined;
|
||||
}
|
||||
else if (c === ')') {
|
||||
if (word !== '') {
|
||||
genres.push(word);
|
||||
word = '';
|
||||
}
|
||||
const genre = parseGenreCode(code);
|
||||
if (genre) {
|
||||
genres.push(genre);
|
||||
}
|
||||
code = undefined;
|
||||
}
|
||||
else
|
||||
code += c;
|
||||
}
|
||||
else if (c === '(') {
|
||||
code = '';
|
||||
}
|
||||
else {
|
||||
word += c;
|
||||
}
|
||||
}
|
||||
if (word) {
|
||||
if (genres.length === 0 && word.match(/^\d*$/)) {
|
||||
word = ID3v1Parser_1.Genres[word];
|
||||
}
|
||||
genres.push(word);
|
||||
}
|
||||
return genres;
|
||||
}
|
||||
exports.parseGenre = parseGenre;
|
||||
function parseGenreCode(code) {
|
||||
if (code === 'RX')
|
||||
return 'Remix';
|
||||
if (code === 'CR')
|
||||
return 'Cover';
|
||||
if (code.match(/^\d*$/)) {
|
||||
return ID3v1Parser_1.Genres[code];
|
||||
}
|
||||
}
|
||||
class FrameParser {
|
||||
/**
|
||||
* Create id3v2 frame parser
|
||||
* @param major - Major version, e.g. (4) for id3v2.4
|
||||
* @param warningCollector - Used to collect decode issue
|
||||
*/
|
||||
constructor(major, warningCollector) {
|
||||
this.major = major;
|
||||
this.warningCollector = warningCollector;
|
||||
}
|
||||
readData(b, type, includeCovers) {
|
||||
if (b.length === 0) {
|
||||
this.warningCollector.addWarning(`id3v2.${this.major} header has empty tag type=${type}`);
|
||||
return;
|
||||
}
|
||||
const { encoding, bom } = ID3v2Token_1.TextEncodingToken.get(b, 0);
|
||||
const length = b.length;
|
||||
let offset = 0;
|
||||
let output = []; // ToDo
|
||||
const nullTerminatorLength = FrameParser.getNullTerminatorLength(encoding);
|
||||
let fzero;
|
||||
const out = {};
|
||||
debug(`Parsing tag type=${type}, encoding=${encoding}, bom=${bom}`);
|
||||
switch (type !== 'TXXX' && type[0] === 'T' ? 'T*' : type) {
|
||||
case 'T*': // 4.2.1. Text information frames - details
|
||||
case 'IPLS': // v2.3: Involved people list
|
||||
case 'MVIN':
|
||||
case 'MVNM':
|
||||
case 'PCS':
|
||||
case 'PCST':
|
||||
const text = util.decodeString(b.slice(1), encoding).replace(/\x00+$/, '');
|
||||
switch (type) {
|
||||
case 'TMCL': // Musician credits list
|
||||
case 'TIPL': // Involved people list
|
||||
case 'IPLS': // Involved people list
|
||||
output = this.splitValue(type, text);
|
||||
output = FrameParser.functionList(output);
|
||||
break;
|
||||
case 'TRK':
|
||||
case 'TRCK':
|
||||
case 'TPOS':
|
||||
output = text;
|
||||
break;
|
||||
case 'TCOM':
|
||||
case 'TEXT':
|
||||
case 'TOLY':
|
||||
case 'TOPE':
|
||||
case 'TPE1':
|
||||
case 'TSRC':
|
||||
// id3v2.3 defines that TCOM, TEXT, TOLY, TOPE & TPE1 values are separated by /
|
||||
output = this.splitValue(type, text);
|
||||
break;
|
||||
case 'TCO':
|
||||
case 'TCON':
|
||||
output = this.splitValue(type, text).map(v => parseGenre(v)).reduce((acc, val) => acc.concat(val), []);
|
||||
break;
|
||||
case 'PCS':
|
||||
case 'PCST':
|
||||
// TODO: Why `default` not results `1` but `''`?
|
||||
output = this.major >= 4 ? this.splitValue(type, text) : [text];
|
||||
output = (Array.isArray(output) && output[0] === '') ? 1 : 0;
|
||||
break;
|
||||
default:
|
||||
output = this.major >= 4 ? this.splitValue(type, text) : [text];
|
||||
}
|
||||
break;
|
||||
case 'TXXX':
|
||||
output = FrameParser.readIdentifierAndData(b, offset + 1, length, encoding);
|
||||
output = {
|
||||
description: output.id,
|
||||
text: this.splitValue(type, util.decodeString(output.data, encoding).replace(/\x00+$/, ''))
|
||||
};
|
||||
break;
|
||||
case 'PIC':
|
||||
case 'APIC':
|
||||
if (includeCovers) {
|
||||
const pic = {};
|
||||
offset += 1;
|
||||
switch (this.major) {
|
||||
case 2:
|
||||
pic.format = util.decodeString(b.slice(offset, offset + 3), 'latin1'); // 'latin1'; // latin1 == iso-8859-1;
|
||||
offset += 3;
|
||||
break;
|
||||
case 3:
|
||||
case 4:
|
||||
fzero = util.findZero(b, offset, length, defaultEnc);
|
||||
pic.format = util.decodeString(b.slice(offset, fzero), defaultEnc);
|
||||
offset = fzero + 1;
|
||||
break;
|
||||
default:
|
||||
throw new Error('Warning: unexpected major versionIndex: ' + this.major);
|
||||
}
|
||||
pic.format = FrameParser.fixPictureMimeType(pic.format);
|
||||
pic.type = ID3v2Token_1.AttachedPictureType[b[offset]];
|
||||
offset += 1;
|
||||
fzero = util.findZero(b, offset, length, encoding);
|
||||
pic.description = util.decodeString(b.slice(offset, fzero), encoding);
|
||||
offset = fzero + nullTerminatorLength;
|
||||
pic.data = Buffer.from(b.slice(offset, length));
|
||||
output = pic;
|
||||
}
|
||||
break;
|
||||
case 'CNT':
|
||||
case 'PCNT':
|
||||
output = Token.UINT32_BE.get(b, 0);
|
||||
break;
|
||||
case 'SYLT':
|
||||
// skip text encoding (1 byte),
|
||||
// language (3 bytes),
|
||||
// time stamp format (1 byte),
|
||||
// content tagTypes (1 byte),
|
||||
// content descriptor (1 byte)
|
||||
offset += 7;
|
||||
output = [];
|
||||
while (offset < length) {
|
||||
const txt = b.slice(offset, offset = util.findZero(b, offset, length, encoding));
|
||||
offset += 5; // push offset forward one + 4 byte timestamp
|
||||
output.push(util.decodeString(txt, encoding));
|
||||
}
|
||||
break;
|
||||
case 'ULT':
|
||||
case 'USLT':
|
||||
case 'COM':
|
||||
case 'COMM':
|
||||
offset += 1;
|
||||
out.language = util.decodeString(b.slice(offset, offset + 3), defaultEnc);
|
||||
offset += 3;
|
||||
fzero = util.findZero(b, offset, length, encoding);
|
||||
out.description = util.decodeString(b.slice(offset, fzero), encoding);
|
||||
offset = fzero + nullTerminatorLength;
|
||||
out.text = util.decodeString(b.slice(offset, length), encoding).replace(/\x00+$/, '');
|
||||
output = [out];
|
||||
break;
|
||||
case 'UFID':
|
||||
output = FrameParser.readIdentifierAndData(b, offset, length, defaultEnc);
|
||||
output = { owner_identifier: output.id, identifier: output.data };
|
||||
break;
|
||||
case 'PRIV': // private frame
|
||||
output = FrameParser.readIdentifierAndData(b, offset, length, defaultEnc);
|
||||
output = { owner_identifier: output.id, data: output.data };
|
||||
break;
|
||||
case 'POPM': // Popularimeter
|
||||
fzero = util.findZero(b, offset, length, defaultEnc);
|
||||
const email = util.decodeString(b.slice(offset, fzero), defaultEnc);
|
||||
offset = fzero + 1;
|
||||
const dataLen = length - offset;
|
||||
output = {
|
||||
email,
|
||||
rating: b.readUInt8(offset),
|
||||
counter: dataLen >= 5 ? b.readUInt32BE(offset + 1) : undefined
|
||||
};
|
||||
break;
|
||||
case 'GEOB': { // General encapsulated object
|
||||
fzero = util.findZero(b, offset + 1, length, encoding);
|
||||
const mimeType = util.decodeString(b.slice(offset + 1, fzero), defaultEnc);
|
||||
offset = fzero + 1;
|
||||
fzero = util.findZero(b, offset, length - offset, encoding);
|
||||
const filename = util.decodeString(b.slice(offset, fzero), defaultEnc);
|
||||
offset = fzero + 1;
|
||||
fzero = util.findZero(b, offset, length - offset, encoding);
|
||||
const description = util.decodeString(b.slice(offset, fzero), defaultEnc);
|
||||
output = {
|
||||
type: mimeType,
|
||||
filename,
|
||||
description,
|
||||
data: b.slice(offset + 1, length)
|
||||
};
|
||||
break;
|
||||
}
|
||||
// W-Frames:
|
||||
case 'WCOM':
|
||||
case 'WCOP':
|
||||
case 'WOAF':
|
||||
case 'WOAR':
|
||||
case 'WOAS':
|
||||
case 'WORS':
|
||||
case 'WPAY':
|
||||
case 'WPUB':
|
||||
// Decode URL
|
||||
output = util.decodeString(b.slice(offset, fzero), defaultEnc);
|
||||
break;
|
||||
case 'WXXX': {
|
||||
// Decode URL
|
||||
fzero = util.findZero(b, offset + 1, length, encoding);
|
||||
const description = util.decodeString(b.slice(offset + 1, fzero), encoding);
|
||||
offset = fzero + (encoding === 'utf16le' ? 2 : 1);
|
||||
output = { description, url: util.decodeString(b.slice(offset, length), defaultEnc) };
|
||||
break;
|
||||
}
|
||||
case 'WFD':
|
||||
case 'WFED':
|
||||
output = util.decodeString(b.slice(offset + 1, util.findZero(b, offset + 1, length, encoding)), encoding);
|
||||
break;
|
||||
case 'MCDI': {
|
||||
// Music CD identifier
|
||||
output = b.slice(0, length);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
debug('Warning: unsupported id3v2-tag-type: ' + type);
|
||||
break;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
static fixPictureMimeType(pictureType) {
|
||||
pictureType = pictureType.toLocaleLowerCase();
|
||||
switch (pictureType) {
|
||||
case 'jpg':
|
||||
return 'image/jpeg';
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
}
|
||||
return pictureType;
|
||||
}
|
||||
/**
|
||||
* Converts TMCL (Musician credits list) or TIPL (Involved people list)
|
||||
* @param entries
|
||||
*/
|
||||
static functionList(entries) {
|
||||
const res = {};
|
||||
for (let i = 0; i + 1 < entries.length; i += 2) {
|
||||
const names = entries[i + 1].split(',');
|
||||
res[entries[i]] = res.hasOwnProperty(entries[i]) ? res[entries[i]].concat(names) : names;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
/**
|
||||
* id3v2.4 defines that multiple T* values are separated by 0x00
|
||||
* id3v2.3 defines that TCOM, TEXT, TOLY, TOPE & TPE1 values are separated by /
|
||||
* @param tag - Tag name
|
||||
* @param text - Concatenated tag value
|
||||
* @returns Split tag value
|
||||
*/
|
||||
splitValue(tag, text) {
|
||||
let values;
|
||||
if (this.major < 4) {
|
||||
values = text.split(/\x00/g);
|
||||
if (values.length > 1) {
|
||||
this.warningCollector.addWarning(`ID3v2.${this.major} ${tag} uses non standard null-separator.`);
|
||||
}
|
||||
else {
|
||||
values = text.split(/\//g);
|
||||
}
|
||||
}
|
||||
else {
|
||||
values = text.split(/\x00/g);
|
||||
}
|
||||
return FrameParser.trimArray(values);
|
||||
}
|
||||
static trimArray(values) {
|
||||
return values.map(value => value.replace(/\x00+$/, '').trim());
|
||||
}
|
||||
static readIdentifierAndData(b, offset, length, encoding) {
|
||||
const fzero = util.findZero(b, offset, length, encoding);
|
||||
const id = util.decodeString(b.slice(offset, fzero), encoding);
|
||||
offset = fzero + FrameParser.getNullTerminatorLength(encoding);
|
||||
return { id, data: b.slice(offset, length) };
|
||||
}
|
||||
static getNullTerminatorLength(enc) {
|
||||
return enc === 'utf16le' ? 2 : 1;
|
||||
}
|
||||
}
|
||||
exports.FrameParser = FrameParser;
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { INativeTagMap } from '../common/GenericTagTypes';
|
||||
import { CaseInsensitiveTagMap } from '../common/CaseInsensitiveTagMap';
|
||||
/**
|
||||
* ID3v2.2 tag mappings
|
||||
*/
|
||||
export declare const id3v22TagMap: INativeTagMap;
|
||||
export declare class ID3v22TagMapper extends CaseInsensitiveTagMap {
|
||||
constructor();
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ID3v22TagMapper = exports.id3v22TagMap = void 0;
|
||||
const CaseInsensitiveTagMap_1 = require("../common/CaseInsensitiveTagMap");
|
||||
/**
|
||||
* ID3v2.2 tag mappings
|
||||
*/
|
||||
exports.id3v22TagMap = {
|
||||
TT2: 'title',
|
||||
TP1: 'artist',
|
||||
TP2: 'albumartist',
|
||||
TAL: 'album',
|
||||
TYE: 'year',
|
||||
COM: 'comment',
|
||||
TRK: 'track',
|
||||
TPA: 'disk',
|
||||
TCO: 'genre',
|
||||
PIC: 'picture',
|
||||
TCM: 'composer',
|
||||
TOR: 'originaldate',
|
||||
TOT: 'originalalbum',
|
||||
TXT: 'lyricist',
|
||||
TP3: 'conductor',
|
||||
TPB: 'label',
|
||||
TT1: 'grouping',
|
||||
TT3: 'subtitle',
|
||||
TLA: 'language',
|
||||
TCR: 'copyright',
|
||||
WCP: 'license',
|
||||
TEN: 'encodedby',
|
||||
TSS: 'encodersettings',
|
||||
WAR: 'website',
|
||||
'COM:iTunPGAP': 'gapless'
|
||||
/* ToDo: iTunes tags:
|
||||
'COM:iTunNORM': ,
|
||||
'COM:iTunSMPB': 'encoder delay',
|
||||
'COM:iTunes_CDDB_IDs'
|
||||
*/ ,
|
||||
PCS: 'podcast',
|
||||
TCP: "compilation",
|
||||
TDR: 'date',
|
||||
TS2: 'albumartistsort',
|
||||
TSA: 'albumsort',
|
||||
TSC: 'composersort',
|
||||
TSP: 'artistsort',
|
||||
TST: 'titlesort',
|
||||
WFD: 'podcasturl'
|
||||
};
|
||||
class ID3v22TagMapper extends CaseInsensitiveTagMap_1.CaseInsensitiveTagMap {
|
||||
constructor() {
|
||||
super(['ID3v2.2'], exports.id3v22TagMap);
|
||||
}
|
||||
}
|
||||
exports.ID3v22TagMapper = ID3v22TagMapper;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { IRating, ITag } from '../type';
|
||||
import { INativeMetadataCollector } from '../common/MetadataCollector';
|
||||
import { CaseInsensitiveTagMap } from '../common/CaseInsensitiveTagMap';
|
||||
export declare class ID3v24TagMapper extends CaseInsensitiveTagMap {
|
||||
static toRating(popm: any): IRating;
|
||||
constructor();
|
||||
/**
|
||||
* Handle post mapping exceptions / correction
|
||||
* @param {string} tag to post map
|
||||
* @param warnings USed to register warnings
|
||||
* @return Common value e.g. "Buena Vista Social Club"
|
||||
*/
|
||||
protected postMap(tag: ITag, warnings: INativeMetadataCollector): void;
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ID3v24TagMapper = void 0;
|
||||
const GenericTagMapper_1 = require("../common/GenericTagMapper");
|
||||
const util = require("../common/Util");
|
||||
const CaseInsensitiveTagMap_1 = require("../common/CaseInsensitiveTagMap");
|
||||
/**
|
||||
* ID3v2.3/ID3v2.4 tag mappings
|
||||
*/
|
||||
const id3v24TagMap = {
|
||||
// id3v2.3
|
||||
TIT2: 'title',
|
||||
TPE1: 'artist',
|
||||
'TXXX:Artists': 'artists',
|
||||
TPE2: 'albumartist',
|
||||
TALB: 'album',
|
||||
TDRV: 'date',
|
||||
/**
|
||||
* Original release year
|
||||
*/
|
||||
TORY: 'originalyear',
|
||||
TPOS: 'disk',
|
||||
TCON: 'genre',
|
||||
APIC: 'picture',
|
||||
TCOM: 'composer',
|
||||
'USLT:description': 'lyrics',
|
||||
TSOA: 'albumsort',
|
||||
TSOT: 'titlesort',
|
||||
TOAL: 'originalalbum',
|
||||
TSOP: 'artistsort',
|
||||
TSO2: 'albumartistsort',
|
||||
TSOC: 'composersort',
|
||||
TEXT: 'lyricist',
|
||||
'TXXX:Writer': 'writer',
|
||||
TPE3: 'conductor',
|
||||
// 'IPLS:instrument': 'performer:instrument', // ToDo
|
||||
TPE4: 'remixer',
|
||||
'IPLS:arranger': 'arranger',
|
||||
'IPLS:engineer': 'engineer',
|
||||
'IPLS:producer': 'producer',
|
||||
'IPLS:DJ-mix': 'djmixer',
|
||||
'IPLS:mix': 'mixer',
|
||||
TPUB: 'label',
|
||||
TIT1: 'grouping',
|
||||
TIT3: 'subtitle',
|
||||
TRCK: 'track',
|
||||
TCMP: 'compilation',
|
||||
POPM: 'rating',
|
||||
TBPM: 'bpm',
|
||||
TMED: 'media',
|
||||
'TXXX:CATALOGNUMBER': 'catalognumber',
|
||||
'TXXX:MusicBrainz Album Status': 'releasestatus',
|
||||
'TXXX:MusicBrainz Album Type': 'releasetype',
|
||||
/**
|
||||
* Release country as documented: https://picard.musicbrainz.org/docs/mappings/#cite_note-0
|
||||
*/
|
||||
'TXXX:MusicBrainz Album Release Country': 'releasecountry',
|
||||
/**
|
||||
* Release country as implemented // ToDo: report
|
||||
*/
|
||||
'TXXX:RELEASECOUNTRY': 'releasecountry',
|
||||
'TXXX:SCRIPT': 'script',
|
||||
TLAN: 'language',
|
||||
TCOP: 'copyright',
|
||||
WCOP: 'license',
|
||||
TENC: 'encodedby',
|
||||
TSSE: 'encodersettings',
|
||||
'TXXX:BARCODE': 'barcode',
|
||||
'TXXX:ISRC': 'isrc',
|
||||
TSRC: 'isrc',
|
||||
'TXXX:ASIN': 'asin',
|
||||
'TXXX:originalyear': 'originalyear',
|
||||
'UFID:http://musicbrainz.org': 'musicbrainz_recordingid',
|
||||
'TXXX:MusicBrainz Release Track Id': 'musicbrainz_trackid',
|
||||
'TXXX:MusicBrainz Album Id': 'musicbrainz_albumid',
|
||||
'TXXX:MusicBrainz Artist Id': 'musicbrainz_artistid',
|
||||
'TXXX:MusicBrainz Album Artist Id': 'musicbrainz_albumartistid',
|
||||
'TXXX:MusicBrainz Release Group Id': 'musicbrainz_releasegroupid',
|
||||
'TXXX:MusicBrainz Work Id': 'musicbrainz_workid',
|
||||
'TXXX:MusicBrainz TRM Id': 'musicbrainz_trmid',
|
||||
'TXXX:MusicBrainz Disc Id': 'musicbrainz_discid',
|
||||
'TXXX:ACOUSTID_ID': 'acoustid_id',
|
||||
'TXXX:Acoustid Id': 'acoustid_id',
|
||||
'TXXX:Acoustid Fingerprint': 'acoustid_fingerprint',
|
||||
'TXXX:MusicIP PUID': 'musicip_puid',
|
||||
'TXXX:MusicMagic Fingerprint': 'musicip_fingerprint',
|
||||
WOAR: 'website',
|
||||
// id3v2.4
|
||||
// ToDo: In same sequence as defined at http://id3.org/id3v2.4.0-frames
|
||||
TDRC: 'date',
|
||||
TYER: 'year',
|
||||
TDOR: 'originaldate',
|
||||
// 'TMCL:instrument': 'performer:instrument',
|
||||
'TIPL:arranger': 'arranger',
|
||||
'TIPL:engineer': 'engineer',
|
||||
'TIPL:producer': 'producer',
|
||||
'TIPL:DJ-mix': 'djmixer',
|
||||
'TIPL:mix': 'mixer',
|
||||
TMOO: 'mood',
|
||||
// additional mappings:
|
||||
SYLT: 'lyrics',
|
||||
TSST: 'discsubtitle',
|
||||
TKEY: 'key',
|
||||
COMM: 'comment',
|
||||
TOPE: 'originalartist',
|
||||
// Windows Media Player
|
||||
'PRIV:AverageLevel': 'averageLevel',
|
||||
'PRIV:PeakLevel': 'peakLevel',
|
||||
// Discogs
|
||||
'TXXX:DISCOGS_ARTIST_ID': 'discogs_artist_id',
|
||||
'TXXX:DISCOGS_ARTISTS': 'artists',
|
||||
'TXXX:DISCOGS_ARTIST_NAME': 'artists',
|
||||
'TXXX:DISCOGS_ALBUM_ARTISTS': 'albumartist',
|
||||
'TXXX:DISCOGS_CATALOG': 'catalognumber',
|
||||
'TXXX:DISCOGS_COUNTRY': 'releasecountry',
|
||||
'TXXX:DISCOGS_DATE': 'originaldate',
|
||||
'TXXX:DISCOGS_LABEL': 'label',
|
||||
'TXXX:DISCOGS_LABEL_ID': 'discogs_label_id',
|
||||
'TXXX:DISCOGS_MASTER_RELEASE_ID': 'discogs_master_release_id',
|
||||
'TXXX:DISCOGS_RATING': 'discogs_rating',
|
||||
'TXXX:DISCOGS_RELEASED': 'date',
|
||||
'TXXX:DISCOGS_RELEASE_ID': 'discogs_release_id',
|
||||
'TXXX:DISCOGS_VOTES': 'discogs_votes',
|
||||
'TXXX:CATALOGID': 'catalognumber',
|
||||
'TXXX:STYLE': 'genre',
|
||||
'TXXX:REPLAYGAIN_TRACK_PEAK': 'replaygain_track_peak',
|
||||
'TXXX:REPLAYGAIN_TRACK_GAIN': 'replaygain_track_gain',
|
||||
'TXXX:REPLAYGAIN_ALBUM_PEAK': 'replaygain_album_peak',
|
||||
'TXXX:REPLAYGAIN_ALBUM_GAIN': 'replaygain_album_gain',
|
||||
'TXXX:MP3GAIN_MINMAX': 'replaygain_track_minmax',
|
||||
'TXXX:MP3GAIN_ALBUM_MINMAX': 'replaygain_album_minmax',
|
||||
'TXXX:MP3GAIN_UNDO': 'replaygain_undo',
|
||||
MVNM: 'movement',
|
||||
MVIN: 'movementIndex',
|
||||
PCST: 'podcast',
|
||||
TCAT: 'category',
|
||||
TDES: 'description',
|
||||
TDRL: 'date',
|
||||
TGID: 'podcastId',
|
||||
TKWD: 'keywords',
|
||||
WFED: 'podcasturl'
|
||||
};
|
||||
class ID3v24TagMapper extends CaseInsensitiveTagMap_1.CaseInsensitiveTagMap {
|
||||
static toRating(popm) {
|
||||
return {
|
||||
source: popm.email,
|
||||
rating: popm.rating > 0 ? (popm.rating - 1) / 254 * GenericTagMapper_1.CommonTagMapper.maxRatingScore : undefined
|
||||
};
|
||||
}
|
||||
constructor() {
|
||||
super(['ID3v2.3', 'ID3v2.4'], id3v24TagMap);
|
||||
}
|
||||
/**
|
||||
* Handle post mapping exceptions / correction
|
||||
* @param {string} tag to post map
|
||||
* @param warnings USed to register warnings
|
||||
* @return Common value e.g. "Buena Vista Social Club"
|
||||
*/
|
||||
postMap(tag, warnings) {
|
||||
switch (tag.id) {
|
||||
case 'UFID': // decode MusicBrainz Recording Id
|
||||
if (tag.value.owner_identifier === 'http://musicbrainz.org') {
|
||||
tag.id += ':' + tag.value.owner_identifier;
|
||||
tag.value = util.decodeString(tag.value.identifier, 'latin1'); // latin1 == iso-8859-1
|
||||
}
|
||||
break;
|
||||
case 'PRIV':
|
||||
switch (tag.value.owner_identifier) {
|
||||
// decode Windows Media Player
|
||||
case 'AverageLevel':
|
||||
case 'PeakValue':
|
||||
tag.id += ':' + tag.value.owner_identifier;
|
||||
tag.value = tag.value.data.length === 4 ? tag.value.data.readUInt32LE(0) : null;
|
||||
if (tag.value === null) {
|
||||
warnings.addWarning(`Failed to parse PRIV:PeakValue`);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
warnings.addWarning(`Unknown PRIV owner-identifier: ${tag.value.owner_identifier}`);
|
||||
}
|
||||
break;
|
||||
case 'COMM':
|
||||
tag.value = tag.value ? tag.value.text : null;
|
||||
break;
|
||||
case 'POPM':
|
||||
tag.value = ID3v24TagMapper.toRating(tag.value);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.ID3v24TagMapper = ID3v24TagMapper;
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/// <reference types="node" />
|
||||
import { ITokenizer } from 'strtok3/lib/core';
|
||||
import { IOptions } from '../type';
|
||||
import { INativeMetadataCollector } from '../common/MetadataCollector';
|
||||
export declare class ID3v2Parser {
|
||||
static removeUnsyncBytes(buffer: Buffer): Buffer;
|
||||
private static getFrameHeaderLength;
|
||||
private static readFrameFlags;
|
||||
private static readFrameData;
|
||||
/**
|
||||
* Create a combined tag key, of tag & description
|
||||
* @param {string} tag e.g.: COM
|
||||
* @param {string} description e.g. iTunPGAP
|
||||
* @returns {string} e.g. COM:iTunPGAP
|
||||
*/
|
||||
private static makeDescriptionTagName;
|
||||
private tokenizer;
|
||||
private id3Header;
|
||||
private metadata;
|
||||
private headerType;
|
||||
private options;
|
||||
parse(metadata: INativeMetadataCollector, tokenizer: ITokenizer, options: IOptions): Promise<void>;
|
||||
parseExtendedHeader(): Promise<void>;
|
||||
parseExtendedHeaderData(dataRemaining: number, extendedHeaderSize: number): Promise<void>;
|
||||
parseId3Data(dataLen: number): Promise<void>;
|
||||
private addTag;
|
||||
private parseMetadata;
|
||||
private readFrameHeader;
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ID3v2Parser = void 0;
|
||||
const Token = require("token-types");
|
||||
const util = require("../common/Util");
|
||||
const FrameParser_1 = require("./FrameParser");
|
||||
const ID3v2Token_1 = require("./ID3v2Token");
|
||||
class ID3v2Parser {
|
||||
static removeUnsyncBytes(buffer) {
|
||||
let readI = 0;
|
||||
let writeI = 0;
|
||||
while (readI < buffer.length - 1) {
|
||||
if (readI !== writeI) {
|
||||
buffer[writeI] = buffer[readI];
|
||||
}
|
||||
readI += (buffer[readI] === 0xFF && buffer[readI + 1] === 0) ? 2 : 1;
|
||||
writeI++;
|
||||
}
|
||||
if (readI < buffer.length) {
|
||||
buffer[writeI++] = buffer[readI];
|
||||
}
|
||||
return buffer.slice(0, writeI);
|
||||
}
|
||||
static getFrameHeaderLength(majorVer) {
|
||||
switch (majorVer) {
|
||||
case 2:
|
||||
return 6;
|
||||
case 3:
|
||||
case 4:
|
||||
return 10;
|
||||
default:
|
||||
throw new Error('header versionIndex is incorrect');
|
||||
}
|
||||
}
|
||||
static readFrameFlags(b) {
|
||||
return {
|
||||
status: {
|
||||
tag_alter_preservation: util.getBit(b, 0, 6),
|
||||
file_alter_preservation: util.getBit(b, 0, 5),
|
||||
read_only: util.getBit(b, 0, 4)
|
||||
},
|
||||
format: {
|
||||
grouping_identity: util.getBit(b, 1, 7),
|
||||
compression: util.getBit(b, 1, 3),
|
||||
encryption: util.getBit(b, 1, 2),
|
||||
unsynchronisation: util.getBit(b, 1, 1),
|
||||
data_length_indicator: util.getBit(b, 1, 0)
|
||||
}
|
||||
};
|
||||
}
|
||||
static readFrameData(buf, frameHeader, majorVer, includeCovers, warningCollector) {
|
||||
const frameParser = new FrameParser_1.FrameParser(majorVer, warningCollector);
|
||||
switch (majorVer) {
|
||||
case 2:
|
||||
return frameParser.readData(buf, frameHeader.id, includeCovers);
|
||||
case 3:
|
||||
case 4:
|
||||
if (frameHeader.flags.format.unsynchronisation) {
|
||||
buf = ID3v2Parser.removeUnsyncBytes(buf);
|
||||
}
|
||||
if (frameHeader.flags.format.data_length_indicator) {
|
||||
buf = buf.slice(4, buf.length);
|
||||
}
|
||||
return frameParser.readData(buf, frameHeader.id, includeCovers);
|
||||
default:
|
||||
throw new Error('Unexpected majorVer: ' + majorVer);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Create a combined tag key, of tag & description
|
||||
* @param {string} tag e.g.: COM
|
||||
* @param {string} description e.g. iTunPGAP
|
||||
* @returns {string} e.g. COM:iTunPGAP
|
||||
*/
|
||||
static makeDescriptionTagName(tag, description) {
|
||||
return tag + (description ? ':' + description : '');
|
||||
}
|
||||
async parse(metadata, tokenizer, options) {
|
||||
this.tokenizer = tokenizer;
|
||||
this.metadata = metadata;
|
||||
this.options = options;
|
||||
const id3Header = await this.tokenizer.readToken(ID3v2Token_1.ID3v2Header);
|
||||
if (id3Header.fileIdentifier !== 'ID3') {
|
||||
throw new Error('expected ID3-header file-identifier \'ID3\' was not found');
|
||||
}
|
||||
this.id3Header = id3Header;
|
||||
this.headerType = ('ID3v2.' + id3Header.version.major);
|
||||
if (id3Header.flags.isExtendedHeader) {
|
||||
return this.parseExtendedHeader();
|
||||
}
|
||||
else {
|
||||
return this.parseId3Data(id3Header.size);
|
||||
}
|
||||
}
|
||||
async parseExtendedHeader() {
|
||||
const extendedHeader = await this.tokenizer.readToken(ID3v2Token_1.ExtendedHeader);
|
||||
const dataRemaining = extendedHeader.size - ID3v2Token_1.ExtendedHeader.len;
|
||||
if (dataRemaining > 0) {
|
||||
return this.parseExtendedHeaderData(dataRemaining, extendedHeader.size);
|
||||
}
|
||||
else {
|
||||
return this.parseId3Data(this.id3Header.size - extendedHeader.size);
|
||||
}
|
||||
}
|
||||
async parseExtendedHeaderData(dataRemaining, extendedHeaderSize) {
|
||||
const buffer = Buffer.alloc(dataRemaining);
|
||||
await this.tokenizer.readBuffer(buffer, { length: dataRemaining });
|
||||
return this.parseId3Data(this.id3Header.size - extendedHeaderSize);
|
||||
}
|
||||
async parseId3Data(dataLen) {
|
||||
const buffer = Buffer.alloc(dataLen);
|
||||
await this.tokenizer.readBuffer(buffer, { length: dataLen });
|
||||
for (const tag of this.parseMetadata(buffer)) {
|
||||
if (tag.id === 'TXXX') {
|
||||
if (tag.value) {
|
||||
for (const text of tag.value.text) {
|
||||
this.addTag(ID3v2Parser.makeDescriptionTagName(tag.id, tag.value.description), text);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (tag.id === 'COM') {
|
||||
for (const value of tag.value) {
|
||||
this.addTag(ID3v2Parser.makeDescriptionTagName(tag.id, value.description), value.text);
|
||||
}
|
||||
}
|
||||
else if (tag.id === 'COMM') {
|
||||
for (const value of tag.value) {
|
||||
this.addTag(ID3v2Parser.makeDescriptionTagName(tag.id, value.description), value);
|
||||
}
|
||||
}
|
||||
else if (Array.isArray(tag.value)) {
|
||||
for (const value of tag.value) {
|
||||
this.addTag(tag.id, value);
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.addTag(tag.id, tag.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
addTag(id, value) {
|
||||
this.metadata.addTag(this.headerType, id, value);
|
||||
}
|
||||
parseMetadata(data) {
|
||||
let offset = 0;
|
||||
const tags = [];
|
||||
while (true) {
|
||||
if (offset === data.length)
|
||||
break;
|
||||
const frameHeaderLength = ID3v2Parser.getFrameHeaderLength(this.id3Header.version.major);
|
||||
if (offset + frameHeaderLength > data.length) {
|
||||
this.metadata.addWarning('Illegal ID3v2 tag length');
|
||||
break;
|
||||
}
|
||||
const frameHeaderBytes = data.slice(offset, offset += frameHeaderLength);
|
||||
const frameHeader = this.readFrameHeader(frameHeaderBytes, this.id3Header.version.major);
|
||||
const frameDataBytes = data.slice(offset, offset += frameHeader.length);
|
||||
const values = ID3v2Parser.readFrameData(frameDataBytes, frameHeader, this.id3Header.version.major, !this.options.skipCovers, this.metadata);
|
||||
if (values) {
|
||||
tags.push({ id: frameHeader.id, value: values });
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
readFrameHeader(v, majorVer) {
|
||||
let header;
|
||||
switch (majorVer) {
|
||||
case 2:
|
||||
header = {
|
||||
id: v.toString('ascii', 0, 3),
|
||||
length: Token.UINT24_BE.get(v, 3)
|
||||
};
|
||||
if (!header.id.match(/[A-Z0-9]{3}/g)) {
|
||||
this.metadata.addWarning(`Invalid ID3v2.${this.id3Header.version.major} frame-header-ID: ${header.id}`);
|
||||
}
|
||||
break;
|
||||
case 3:
|
||||
case 4:
|
||||
header = {
|
||||
id: v.toString('ascii', 0, 4),
|
||||
length: (majorVer === 4 ? ID3v2Token_1.UINT32SYNCSAFE : Token.UINT32_BE).get(v, 4),
|
||||
flags: ID3v2Parser.readFrameFlags(v.slice(8, 10))
|
||||
};
|
||||
if (!header.id.match(/[A-Z0-9]{4}/g)) {
|
||||
this.metadata.addWarning(`Invalid ID3v2.${this.id3Header.version.major} frame-header-ID: ${header.id}`);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unexpected majorVer: ' + majorVer);
|
||||
}
|
||||
return header;
|
||||
}
|
||||
}
|
||||
exports.ID3v2Parser = ID3v2Parser;
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import * as util from '../common/Util';
|
||||
import { IGetToken } from 'strtok3/lib/core';
|
||||
/**
|
||||
* The picture type according to the ID3v2 APIC frame
|
||||
* Ref: http://id3.org/id3v2.3.0#Attached_picture
|
||||
*/
|
||||
export declare enum AttachedPictureType {
|
||||
'Other' = 0,
|
||||
"32x32 pixels 'file icon' (PNG only)" = 1,
|
||||
'Other file icon' = 2,
|
||||
'Cover (front)' = 3,
|
||||
'Cover (back)' = 4,
|
||||
'Leaflet page' = 5,
|
||||
'Media (e.g. label side of CD)' = 6,
|
||||
'Lead artist/lead performer/soloist' = 7,
|
||||
'Artist/performer' = 8,
|
||||
'Conductor' = 9,
|
||||
'Band/Orchestra' = 10,
|
||||
'Composer' = 11,
|
||||
'Lyricist/text writer' = 12,
|
||||
'Recording Location' = 13,
|
||||
'During recording' = 14,
|
||||
'During performance' = 15,
|
||||
'Movie/video screen capture' = 16,
|
||||
'A bright coloured fish' = 17,
|
||||
'Illustration' = 18,
|
||||
'Band/artist logotype' = 19,
|
||||
'Publisher/Studio logotype' = 20
|
||||
}
|
||||
export declare type ID3v2MajorVersion = 2 | 3 | 4;
|
||||
export interface IExtendedHeader {
|
||||
size: number;
|
||||
extendedFlags: number;
|
||||
sizeOfPadding: number;
|
||||
crcDataPresent: boolean;
|
||||
}
|
||||
/**
|
||||
* 28 bits (representing up to 256MB) integer, the msb is 0 to avoid 'false syncsignals'.
|
||||
* 4 * %0xxxxxxx
|
||||
*/
|
||||
export declare const UINT32SYNCSAFE: {
|
||||
get: (buf: Uint8Array, off: number) => number;
|
||||
len: number;
|
||||
};
|
||||
/**
|
||||
* ID3v2 tag header
|
||||
*/
|
||||
export interface IID3v2header {
|
||||
fileIdentifier: string;
|
||||
version: {
|
||||
major: ID3v2MajorVersion;
|
||||
revision: number;
|
||||
};
|
||||
flags: {
|
||||
unsynchronisation: boolean;
|
||||
isExtendedHeader: boolean;
|
||||
expIndicator: boolean;
|
||||
footer: boolean;
|
||||
};
|
||||
size: number;
|
||||
}
|
||||
/**
|
||||
* ID3v2 header
|
||||
* Ref: http://id3.org/id3v2.3.0#ID3v2_header
|
||||
* ToDo
|
||||
*/
|
||||
export declare const ID3v2Header: IGetToken<IID3v2header>;
|
||||
export declare const ExtendedHeader: IGetToken<IExtendedHeader>;
|
||||
export interface ITextEncoding {
|
||||
encoding: util.StringEncoding;
|
||||
bom?: boolean;
|
||||
}
|
||||
export declare const TextEncodingToken: IGetToken<ITextEncoding>;
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.TextEncodingToken = exports.ExtendedHeader = exports.ID3v2Header = exports.UINT32SYNCSAFE = exports.AttachedPictureType = void 0;
|
||||
const Token = require("token-types");
|
||||
const util = require("../common/Util");
|
||||
/**
|
||||
* The picture type according to the ID3v2 APIC frame
|
||||
* Ref: http://id3.org/id3v2.3.0#Attached_picture
|
||||
*/
|
||||
var AttachedPictureType;
|
||||
(function (AttachedPictureType) {
|
||||
AttachedPictureType[AttachedPictureType["Other"] = 0] = "Other";
|
||||
AttachedPictureType[AttachedPictureType["32x32 pixels 'file icon' (PNG only)"] = 1] = "32x32 pixels 'file icon' (PNG only)";
|
||||
AttachedPictureType[AttachedPictureType["Other file icon"] = 2] = "Other file icon";
|
||||
AttachedPictureType[AttachedPictureType["Cover (front)"] = 3] = "Cover (front)";
|
||||
AttachedPictureType[AttachedPictureType["Cover (back)"] = 4] = "Cover (back)";
|
||||
AttachedPictureType[AttachedPictureType["Leaflet page"] = 5] = "Leaflet page";
|
||||
AttachedPictureType[AttachedPictureType["Media (e.g. label side of CD)"] = 6] = "Media (e.g. label side of CD)";
|
||||
AttachedPictureType[AttachedPictureType["Lead artist/lead performer/soloist"] = 7] = "Lead artist/lead performer/soloist";
|
||||
AttachedPictureType[AttachedPictureType["Artist/performer"] = 8] = "Artist/performer";
|
||||
AttachedPictureType[AttachedPictureType["Conductor"] = 9] = "Conductor";
|
||||
AttachedPictureType[AttachedPictureType["Band/Orchestra"] = 10] = "Band/Orchestra";
|
||||
AttachedPictureType[AttachedPictureType["Composer"] = 11] = "Composer";
|
||||
AttachedPictureType[AttachedPictureType["Lyricist/text writer"] = 12] = "Lyricist/text writer";
|
||||
AttachedPictureType[AttachedPictureType["Recording Location"] = 13] = "Recording Location";
|
||||
AttachedPictureType[AttachedPictureType["During recording"] = 14] = "During recording";
|
||||
AttachedPictureType[AttachedPictureType["During performance"] = 15] = "During performance";
|
||||
AttachedPictureType[AttachedPictureType["Movie/video screen capture"] = 16] = "Movie/video screen capture";
|
||||
AttachedPictureType[AttachedPictureType["A bright coloured fish"] = 17] = "A bright coloured fish";
|
||||
AttachedPictureType[AttachedPictureType["Illustration"] = 18] = "Illustration";
|
||||
AttachedPictureType[AttachedPictureType["Band/artist logotype"] = 19] = "Band/artist logotype";
|
||||
AttachedPictureType[AttachedPictureType["Publisher/Studio logotype"] = 20] = "Publisher/Studio logotype";
|
||||
})(AttachedPictureType = exports.AttachedPictureType || (exports.AttachedPictureType = {}));
|
||||
/**
|
||||
* 28 bits (representing up to 256MB) integer, the msb is 0 to avoid 'false syncsignals'.
|
||||
* 4 * %0xxxxxxx
|
||||
*/
|
||||
exports.UINT32SYNCSAFE = {
|
||||
get: (buf, off) => {
|
||||
return buf[off + 3] & 0x7f | ((buf[off + 2]) << 7) |
|
||||
((buf[off + 1]) << 14) | ((buf[off]) << 21);
|
||||
},
|
||||
len: 4
|
||||
};
|
||||
/**
|
||||
* ID3v2 header
|
||||
* Ref: http://id3.org/id3v2.3.0#ID3v2_header
|
||||
* ToDo
|
||||
*/
|
||||
exports.ID3v2Header = {
|
||||
len: 10,
|
||||
get: (buf, off) => {
|
||||
return {
|
||||
// ID3v2/file identifier "ID3"
|
||||
fileIdentifier: new Token.StringType(3, 'ascii').get(buf, off),
|
||||
// ID3v2 versionIndex
|
||||
version: {
|
||||
major: Token.INT8.get(buf, off + 3),
|
||||
revision: Token.INT8.get(buf, off + 4)
|
||||
},
|
||||
// ID3v2 flags
|
||||
flags: {
|
||||
// Unsynchronisation
|
||||
unsynchronisation: util.getBit(buf, off + 5, 7),
|
||||
// Extended header
|
||||
isExtendedHeader: util.getBit(buf, off + 5, 6),
|
||||
// Experimental indicator
|
||||
expIndicator: util.getBit(buf, off + 5, 5),
|
||||
footer: util.getBit(buf, off + 5, 4)
|
||||
},
|
||||
size: exports.UINT32SYNCSAFE.get(buf, off + 6)
|
||||
};
|
||||
}
|
||||
};
|
||||
exports.ExtendedHeader = {
|
||||
len: 10,
|
||||
get: (buf, off) => {
|
||||
return {
|
||||
// Extended header size
|
||||
size: Token.UINT32_BE.get(buf, off),
|
||||
// Extended Flags
|
||||
extendedFlags: Token.UINT16_BE.get(buf, off + 4),
|
||||
// Size of padding
|
||||
sizeOfPadding: Token.UINT32_BE.get(buf, off + 6),
|
||||
// CRC data present
|
||||
crcDataPresent: util.getBit(buf, off + 4, 31)
|
||||
};
|
||||
}
|
||||
};
|
||||
exports.TextEncodingToken = {
|
||||
len: 1,
|
||||
get: (buf, off) => {
|
||||
switch (buf.readUInt8(off)) {
|
||||
case 0x00:
|
||||
return { encoding: 'latin1' }; // binary
|
||||
case 0x01:
|
||||
return { encoding: 'utf16le', bom: true };
|
||||
case 0x02:
|
||||
return { encoding: 'utf16le', bom: false };
|
||||
case 0x03:
|
||||
return { encoding: 'utf8', bom: false };
|
||||
default:
|
||||
return { encoding: 'utf8', bom: false };
|
||||
}
|
||||
}
|
||||
};
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { IGetToken } from "strtok3/lib/core";
|
||||
/**
|
||||
* "EA IFF 85" Standard for Interchange Format Files
|
||||
* Ref: http://www.martinreddy.net/gfx/2d/IFF.txt
|
||||
*/
|
||||
export interface IChunkHeader {
|
||||
/**
|
||||
* A chunk ID (ie, 4 ASCII bytes)
|
||||
*/
|
||||
chunkID: string;
|
||||
/**
|
||||
* Number of data bytes following this data header
|
||||
*/
|
||||
chunkSize: number;
|
||||
}
|
||||
/**
|
||||
* "EA IFF 85" Standard for Interchange Format Files
|
||||
* Ref: http://www.martinreddy.net/gfx/2d/IFF.txt
|
||||
*/
|
||||
export interface IChunkHeader64 {
|
||||
/**
|
||||
* A chunk ID (ie, 4 ASCII bytes)
|
||||
*/
|
||||
chunkID: string;
|
||||
/**
|
||||
* Number of data bytes following this data header
|
||||
*/
|
||||
chunkSize: bigint;
|
||||
}
|
||||
/**
|
||||
* Common AIFF chunk header
|
||||
*/
|
||||
export declare const Header: IGetToken<IChunkHeader>;
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.Header = void 0;
|
||||
const FourCC_1 = require("../common/FourCC");
|
||||
const Token = require("token-types");
|
||||
/**
|
||||
* Common AIFF chunk header
|
||||
*/
|
||||
exports.Header = {
|
||||
len: 8,
|
||||
get: (buf, off) => {
|
||||
return {
|
||||
// Chunk type ID
|
||||
chunkID: FourCC_1.FourCcToken.get(buf, off),
|
||||
// Chunk size
|
||||
chunkSize: Number(BigInt(Token.UINT32_BE.get(buf, off + 4)))
|
||||
};
|
||||
}
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/// <reference types="node" />
|
||||
import * as Stream from 'stream';
|
||||
import * as strtok3 from 'strtok3';
|
||||
import * as Core from './core';
|
||||
import { IAudioMetadata, IOptions } from './type';
|
||||
export { IAudioMetadata, IOptions, ITag, INativeTagDict, ICommonTagsResult, IFormat, IPicture, IRatio, IChapter } from './type';
|
||||
export { parseFromTokenizer, parseBuffer, IFileInfo, selectCover } from './core';
|
||||
/**
|
||||
* Parse audio from Node Stream.Readable
|
||||
* @param stream - Stream to read the audio track from
|
||||
* @param fileInfo - File information object or MIME-type, e.g.: 'audio/mpeg'
|
||||
* @param options - Parsing options
|
||||
* @returns Metadata
|
||||
*/
|
||||
export declare function parseStream(stream: Stream.Readable, fileInfo?: strtok3.IFileInfo | string, options?: IOptions): Promise<IAudioMetadata>;
|
||||
/**
|
||||
* Parse audio from Node file
|
||||
* @param filePath - Media file to read meta-data from
|
||||
* @param options - Parsing options
|
||||
* @returns Metadata
|
||||
*/
|
||||
export declare function parseFile(filePath: string, options?: IOptions): Promise<IAudioMetadata>;
|
||||
/**
|
||||
* Create a dictionary ordered by their tag id (key)
|
||||
* @param nativeTags - List of tags
|
||||
* @returns Tags indexed by id
|
||||
*/
|
||||
export declare const orderTags: typeof Core.orderTags;
|
||||
/**
|
||||
* Convert rating to 1-5 star rating
|
||||
* @param rating - Normalized rating [0..1] (common.rating[n].rating)
|
||||
* @returns Number of stars: 1, 2, 3, 4 or 5 stars
|
||||
*/
|
||||
export declare const ratingToStars: typeof Core.ratingToStars;
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ratingToStars = exports.orderTags = exports.parseFile = exports.parseStream = exports.selectCover = exports.parseBuffer = exports.parseFromTokenizer = void 0;
|
||||
const strtok3 = require("strtok3");
|
||||
const Core = require("./core");
|
||||
const ParserFactory_1 = require("./ParserFactory");
|
||||
const _debug = require("debug");
|
||||
const RandomFileReader_1 = require("./common/RandomFileReader");
|
||||
const debug = _debug("music-metadata:parser");
|
||||
var core_1 = require("./core");
|
||||
Object.defineProperty(exports, "parseFromTokenizer", { enumerable: true, get: function () { return core_1.parseFromTokenizer; } });
|
||||
Object.defineProperty(exports, "parseBuffer", { enumerable: true, get: function () { return core_1.parseBuffer; } });
|
||||
Object.defineProperty(exports, "selectCover", { enumerable: true, get: function () { return core_1.selectCover; } });
|
||||
/**
|
||||
* Parse audio from Node Stream.Readable
|
||||
* @param stream - Stream to read the audio track from
|
||||
* @param fileInfo - File information object or MIME-type, e.g.: 'audio/mpeg'
|
||||
* @param options - Parsing options
|
||||
* @returns Metadata
|
||||
*/
|
||||
async function parseStream(stream, fileInfo, options = {}) {
|
||||
const tokenizer = await strtok3.fromStream(stream, typeof fileInfo === 'string' ? { mimeType: fileInfo } : fileInfo);
|
||||
return Core.parseFromTokenizer(tokenizer, options);
|
||||
}
|
||||
exports.parseStream = parseStream;
|
||||
/**
|
||||
* Parse audio from Node file
|
||||
* @param filePath - Media file to read meta-data from
|
||||
* @param options - Parsing options
|
||||
* @returns Metadata
|
||||
*/
|
||||
async function parseFile(filePath, options = {}) {
|
||||
debug(`parseFile: ${filePath}`);
|
||||
const fileTokenizer = await strtok3.fromFile(filePath);
|
||||
const fileReader = new RandomFileReader_1.RandomFileReader(filePath, fileTokenizer.fileInfo.size);
|
||||
try {
|
||||
await Core.scanAppendingHeaders(fileReader, options);
|
||||
}
|
||||
finally {
|
||||
fileReader.close();
|
||||
}
|
||||
try {
|
||||
const parserName = ParserFactory_1.ParserFactory.getParserIdForExtension(filePath);
|
||||
if (!parserName)
|
||||
debug(' Parser could not be determined by file extension');
|
||||
return await ParserFactory_1.ParserFactory.parse(fileTokenizer, parserName, options);
|
||||
}
|
||||
finally {
|
||||
await fileTokenizer.close();
|
||||
}
|
||||
}
|
||||
exports.parseFile = parseFile;
|
||||
/**
|
||||
* Create a dictionary ordered by their tag id (key)
|
||||
* @param nativeTags - List of tags
|
||||
* @returns Tags indexed by id
|
||||
*/
|
||||
exports.orderTags = Core.orderTags;
|
||||
/**
|
||||
* Convert rating to 1-5 star rating
|
||||
* @param rating - Normalized rating [0..1] (common.rating[n].rating)
|
||||
* @returns Number of stars: 1, 2, 3, 4 or 5 stars
|
||||
*/
|
||||
exports.ratingToStars = Core.ratingToStars;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { IRandomReader } from '../type';
|
||||
export declare const endTag2 = "LYRICS200";
|
||||
export declare function getLyricsHeaderLength(reader: IRandomReader): Promise<number>;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getLyricsHeaderLength = exports.endTag2 = void 0;
|
||||
exports.endTag2 = 'LYRICS200';
|
||||
async function getLyricsHeaderLength(reader) {
|
||||
if (reader.fileSize >= 143) {
|
||||
const buf = Buffer.alloc(15);
|
||||
await reader.randomRead(buf, 0, buf.length, reader.fileSize - 143);
|
||||
const txt = buf.toString('binary');
|
||||
const tag = txt.substr(6);
|
||||
if (tag === exports.endTag2) {
|
||||
return parseInt(txt.substr(0, 6), 10) + 15;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
exports.getLyricsHeaderLength = getLyricsHeaderLength;
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { IContainerType } from './types';
|
||||
/**
|
||||
* Elements of document type description
|
||||
* Derived from https://github.com/tungol/EBML/blob/master/doctypes/matroska.dtd
|
||||
* Extended with:
|
||||
* https://www.matroska.org/technical/specs/index.html
|
||||
*/
|
||||
export declare const elements: IContainerType;
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.elements = void 0;
|
||||
const types_1 = require("./types");
|
||||
/**
|
||||
* Elements of document type description
|
||||
* Derived from https://github.com/tungol/EBML/blob/master/doctypes/matroska.dtd
|
||||
* Extended with:
|
||||
* https://www.matroska.org/technical/specs/index.html
|
||||
*/
|
||||
exports.elements = {
|
||||
0x1a45dfa3: {
|
||||
name: 'ebml',
|
||||
container: {
|
||||
0x4286: { name: 'ebmlVersion', value: types_1.DataType.uint },
|
||||
0x42f7: { name: 'ebmlReadVersion', value: types_1.DataType.uint },
|
||||
0x42f2: { name: 'ebmlMaxIDWidth', value: types_1.DataType.uint },
|
||||
0x42f3: { name: 'ebmlMaxSizeWidth', value: types_1.DataType.uint },
|
||||
0x4282: { name: 'docType', value: types_1.DataType.string },
|
||||
0x4287: { name: 'docTypeVersion', value: types_1.DataType.uint },
|
||||
0x4285: { name: 'docTypeReadVersion', value: types_1.DataType.uint } // 5.1.7
|
||||
}
|
||||
},
|
||||
// Matroska segments
|
||||
0x18538067: {
|
||||
name: 'segment',
|
||||
container: {
|
||||
// Meta Seek Information
|
||||
0x114d9b74: {
|
||||
name: 'seekHead',
|
||||
container: {
|
||||
0x4dbb: {
|
||||
name: 'seek',
|
||||
container: {
|
||||
0x53ab: { name: 'seekId', value: types_1.DataType.binary },
|
||||
0x53ac: { name: 'seekPosition', value: types_1.DataType.uint }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// Segment Information
|
||||
0x1549a966: {
|
||||
name: 'info',
|
||||
container: {
|
||||
0x73a4: { name: 'uid', value: types_1.DataType.uid },
|
||||
0x7384: { name: 'filename', value: types_1.DataType.string },
|
||||
0x3cb923: { name: 'prevUID', value: types_1.DataType.uid },
|
||||
0x3c83ab: { name: 'prevFilename', value: types_1.DataType.string },
|
||||
0x3eb923: { name: 'nextUID', value: types_1.DataType.uid },
|
||||
0x3e83bb: { name: 'nextFilename', value: types_1.DataType.string },
|
||||
0x2ad7b1: { name: 'timecodeScale', value: types_1.DataType.uint },
|
||||
0x4489: { name: 'duration', value: types_1.DataType.float },
|
||||
0x4461: { name: 'dateUTC', value: types_1.DataType.uint },
|
||||
0x7ba9: { name: 'title', value: types_1.DataType.string },
|
||||
0x4d80: { name: 'muxingApp', value: types_1.DataType.string },
|
||||
0x5741: { name: 'writingApp', value: types_1.DataType.string }
|
||||
}
|
||||
},
|
||||
// Cluster
|
||||
0x1f43b675: {
|
||||
name: 'cluster',
|
||||
multiple: true,
|
||||
container: {
|
||||
0xe7: { name: 'timecode', value: types_1.DataType.uid },
|
||||
0xa3: { name: 'unknown', value: types_1.DataType.binary },
|
||||
0xa7: { name: 'position', value: types_1.DataType.uid },
|
||||
0xab: { name: 'prevSize', value: types_1.DataType.uid }
|
||||
}
|
||||
},
|
||||
// Track
|
||||
0x1654ae6b: {
|
||||
name: 'tracks',
|
||||
container: {
|
||||
0xae: {
|
||||
name: 'entries',
|
||||
multiple: true,
|
||||
container: {
|
||||
0xd7: { name: 'trackNumber', value: types_1.DataType.uint },
|
||||
0x73c5: { name: 'uid', value: types_1.DataType.uid },
|
||||
0x83: { name: 'trackType', value: types_1.DataType.uint },
|
||||
0xb9: { name: 'flagEnabled', value: types_1.DataType.bool },
|
||||
0x88: { name: 'flagDefault', value: types_1.DataType.bool },
|
||||
0x55aa: { name: 'flagForced', value: types_1.DataType.bool },
|
||||
0x9c: { name: 'flagLacing', value: types_1.DataType.bool },
|
||||
0x6de7: { name: 'minCache', value: types_1.DataType.uint },
|
||||
0x6de8: { name: 'maxCache', value: types_1.DataType.uint },
|
||||
0x23e383: { name: 'defaultDuration', value: types_1.DataType.uint },
|
||||
0x23314f: { name: 'timecodeScale', value: types_1.DataType.float },
|
||||
0x536e: { name: 'name', value: types_1.DataType.string },
|
||||
0x22b59c: { name: 'language', value: types_1.DataType.string },
|
||||
0x86: { name: 'codecID', value: types_1.DataType.string },
|
||||
0x63a2: { name: 'codecPrivate', value: types_1.DataType.binary },
|
||||
0x258688: { name: 'codecName', value: types_1.DataType.string },
|
||||
0x3a9697: { name: 'codecSettings', value: types_1.DataType.string },
|
||||
0x3b4040: { name: 'codecInfoUrl', value: types_1.DataType.string },
|
||||
0x26b240: { name: 'codecDownloadUrl', value: types_1.DataType.string },
|
||||
0xaa: { name: 'codecDecodeAll', value: types_1.DataType.bool },
|
||||
0x6fab: { name: 'trackOverlay', value: types_1.DataType.uint },
|
||||
// Video
|
||||
0xe0: {
|
||||
name: 'video',
|
||||
container: {
|
||||
0x9a: { name: 'flagInterlaced', value: types_1.DataType.bool },
|
||||
0x53b8: { name: 'stereoMode', value: types_1.DataType.uint },
|
||||
0xb0: { name: 'pixelWidth', value: types_1.DataType.uint },
|
||||
0xba: { name: 'pixelHeight', value: types_1.DataType.uint },
|
||||
0x54b0: { name: 'displayWidth', value: types_1.DataType.uint },
|
||||
0x54ba: { name: 'displayHeight', value: types_1.DataType.uint },
|
||||
0x54b3: { name: 'aspectRatioType', value: types_1.DataType.uint },
|
||||
0x2eb524: { name: 'colourSpace', value: types_1.DataType.uint },
|
||||
0x2fb523: { name: 'gammaValue', value: types_1.DataType.float }
|
||||
}
|
||||
},
|
||||
// Audio
|
||||
0xe1: {
|
||||
name: 'audio',
|
||||
container: {
|
||||
0xb5: { name: 'samplingFrequency', value: types_1.DataType.float },
|
||||
0x78b5: { name: 'outputSamplingFrequency', value: types_1.DataType.float },
|
||||
0x9f: { name: 'channels', value: types_1.DataType.uint },
|
||||
0x94: { name: 'channels', value: types_1.DataType.uint },
|
||||
0x7d7b: { name: 'channelPositions', value: types_1.DataType.binary },
|
||||
0x6264: { name: 'bitDepth', value: types_1.DataType.uint }
|
||||
}
|
||||
},
|
||||
// Content Encoding
|
||||
0x6d80: {
|
||||
name: 'contentEncodings',
|
||||
container: {
|
||||
0x6240: {
|
||||
name: 'contentEncoding',
|
||||
container: {
|
||||
0x5031: { name: 'order', value: types_1.DataType.uint },
|
||||
0x5032: { name: 'scope', value: types_1.DataType.bool },
|
||||
0x5033: { name: 'type', value: types_1.DataType.uint },
|
||||
0x5034: {
|
||||
name: 'contentEncoding',
|
||||
container: {
|
||||
0x4254: { name: 'contentCompAlgo', value: types_1.DataType.uint },
|
||||
0x4255: { name: 'contentCompSettings', value: types_1.DataType.binary }
|
||||
}
|
||||
},
|
||||
0x5035: {
|
||||
name: 'contentEncoding',
|
||||
container: {
|
||||
0x47e1: { name: 'contentEncAlgo', value: types_1.DataType.uint },
|
||||
0x47e2: { name: 'contentEncKeyID', value: types_1.DataType.binary },
|
||||
0x47e3: { name: 'contentSignature ', value: types_1.DataType.binary },
|
||||
0x47e4: { name: 'ContentSigKeyID ', value: types_1.DataType.binary },
|
||||
0x47e5: { name: 'contentSigAlgo ', value: types_1.DataType.uint },
|
||||
0x47e6: { name: 'contentSigHashAlgo ', value: types_1.DataType.uint }
|
||||
}
|
||||
},
|
||||
0x6264: { name: 'bitDepth', value: types_1.DataType.uint }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// Cueing Data
|
||||
0x1c53bb6b: {
|
||||
name: 'cues',
|
||||
container: {
|
||||
0xbb: {
|
||||
name: 'cuePoint',
|
||||
container: {
|
||||
0xb3: { name: 'cueTime', value: types_1.DataType.uid },
|
||||
0xb7: {
|
||||
name: 'positions',
|
||||
container: {
|
||||
0xf7: { name: 'track', value: types_1.DataType.uint },
|
||||
0xf1: { name: 'clusterPosition', value: types_1.DataType.uint },
|
||||
0x5378: { name: 'blockNumber', value: types_1.DataType.uint },
|
||||
0xea: { name: 'codecState', value: types_1.DataType.uint },
|
||||
0xdb: {
|
||||
name: 'reference', container: {
|
||||
0x96: { name: 'time', value: types_1.DataType.uint },
|
||||
0x97: { name: 'cluster', value: types_1.DataType.uint },
|
||||
0x535f: { name: 'number', value: types_1.DataType.uint },
|
||||
0xeb: { name: 'codecState', value: types_1.DataType.uint }
|
||||
}
|
||||
},
|
||||
0xf0: { name: 'relativePosition', value: types_1.DataType.uint } // extended
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// Attachment
|
||||
0x1941a469: {
|
||||
name: 'attachments',
|
||||
container: {
|
||||
0x61a7: {
|
||||
name: 'attachedFiles',
|
||||
multiple: true,
|
||||
container: {
|
||||
0x467e: { name: 'description', value: types_1.DataType.string },
|
||||
0x466e: { name: 'name', value: types_1.DataType.string },
|
||||
0x4660: { name: 'mimeType', value: types_1.DataType.string },
|
||||
0x465c: { name: 'data', value: types_1.DataType.binary },
|
||||
0x46ae: { name: 'uid', value: types_1.DataType.uid }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// Chapters
|
||||
0x1043a770: {
|
||||
name: 'chapters',
|
||||
container: {
|
||||
0x45b9: {
|
||||
name: 'editionEntry',
|
||||
container: {
|
||||
0xb6: {
|
||||
name: 'chapterAtom',
|
||||
container: {
|
||||
0x73c4: { name: 'uid', value: types_1.DataType.uid },
|
||||
0x91: { name: 'timeStart', value: types_1.DataType.uint },
|
||||
0x92: { name: 'timeEnd', value: types_1.DataType.uid },
|
||||
0x98: { name: 'hidden', value: types_1.DataType.bool },
|
||||
0x4598: { name: 'enabled', value: types_1.DataType.uid },
|
||||
0x8f: { name: 'track', container: {
|
||||
0x89: { name: 'trackNumber', value: types_1.DataType.uid },
|
||||
0x80: {
|
||||
name: 'display', container: {
|
||||
0x85: { name: 'string', value: types_1.DataType.string },
|
||||
0x437c: { name: 'language ', value: types_1.DataType.string },
|
||||
0x437e: { name: 'country ', value: types_1.DataType.string }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// Tagging
|
||||
0x1254c367: {
|
||||
name: 'tags',
|
||||
container: {
|
||||
0x7373: {
|
||||
name: 'tag',
|
||||
multiple: true,
|
||||
container: {
|
||||
0x63c0: {
|
||||
name: 'target',
|
||||
container: {
|
||||
0x63c5: { name: 'tagTrackUID', value: types_1.DataType.uid },
|
||||
0x63c4: { name: 'tagChapterUID', value: types_1.DataType.uint },
|
||||
0x63c6: { name: 'tagAttachmentUID', value: types_1.DataType.uid },
|
||||
0x63ca: { name: 'targetType', value: types_1.DataType.string },
|
||||
0x68ca: { name: 'targetTypeValue', value: types_1.DataType.uint },
|
||||
0x63c9: { name: 'tagEditionUID', value: types_1.DataType.uid } // extended
|
||||
}
|
||||
},
|
||||
0x67c8: {
|
||||
name: 'simpleTags',
|
||||
multiple: true,
|
||||
container: {
|
||||
0x45a3: { name: 'name', value: types_1.DataType.string },
|
||||
0x4487: { name: 'string', value: types_1.DataType.string },
|
||||
0x4485: { name: 'binary', value: types_1.DataType.binary },
|
||||
0x447a: { name: 'language', value: types_1.DataType.string },
|
||||
0x447b: { name: 'languageIETF', value: types_1.DataType.string },
|
||||
0x4484: { name: 'default', value: types_1.DataType.bool } // extended
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { INativeMetadataCollector } from '../common/MetadataCollector';
|
||||
import { ITokenizer } from 'strtok3/lib/core';
|
||||
import { IOptions } from '../type';
|
||||
import { ITokenParser } from '../ParserFactory';
|
||||
import { BasicParser } from '../common/BasicParser';
|
||||
/**
|
||||
* Extensible Binary Meta Language (EBML) parser
|
||||
* https://en.wikipedia.org/wiki/Extensible_Binary_Meta_Language
|
||||
* http://matroska.sourceforge.net/technical/specs/rfc/index.html
|
||||
*
|
||||
* WEBM VP8 AUDIO FILE
|
||||
*/
|
||||
export declare class MatroskaParser extends BasicParser {
|
||||
private padding;
|
||||
private parserMap;
|
||||
private ebmlMaxIDLength;
|
||||
private ebmlMaxSizeLength;
|
||||
constructor();
|
||||
/**
|
||||
* Initialize parser with output (metadata), input (tokenizer) & parsing options (options).
|
||||
* @param {INativeMetadataCollector} metadata Output
|
||||
* @param {ITokenizer} tokenizer Input
|
||||
* @param {IOptions} options Parsing options
|
||||
*/
|
||||
init(metadata: INativeMetadataCollector, tokenizer: ITokenizer, options: IOptions): ITokenParser;
|
||||
parse(): Promise<void>;
|
||||
private parseContainer;
|
||||
private readVintData;
|
||||
private readElement;
|
||||
private isMaxValue;
|
||||
private readFloat;
|
||||
private readFlag;
|
||||
private readUint;
|
||||
private readString;
|
||||
private readBuffer;
|
||||
private addTag;
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.MatroskaParser = void 0;
|
||||
const Token = require("token-types");
|
||||
const _debug = require("debug");
|
||||
const BasicParser_1 = require("../common/BasicParser");
|
||||
const types_1 = require("./types");
|
||||
const matroskaDtd = require("./MatroskaDtd");
|
||||
const debug = _debug('music-metadata:parser:matroska');
|
||||
/**
|
||||
* Extensible Binary Meta Language (EBML) parser
|
||||
* https://en.wikipedia.org/wiki/Extensible_Binary_Meta_Language
|
||||
* http://matroska.sourceforge.net/technical/specs/rfc/index.html
|
||||
*
|
||||
* WEBM VP8 AUDIO FILE
|
||||
*/
|
||||
class MatroskaParser extends BasicParser_1.BasicParser {
|
||||
constructor() {
|
||||
super();
|
||||
this.padding = 0;
|
||||
this.parserMap = new Map();
|
||||
this.ebmlMaxIDLength = 4;
|
||||
this.ebmlMaxSizeLength = 8;
|
||||
this.parserMap.set(types_1.DataType.uint, e => this.readUint(e));
|
||||
this.parserMap.set(types_1.DataType.string, e => this.readString(e));
|
||||
this.parserMap.set(types_1.DataType.binary, e => this.readBuffer(e));
|
||||
this.parserMap.set(types_1.DataType.uid, async (e) => await this.readUint(e) === 1);
|
||||
this.parserMap.set(types_1.DataType.bool, e => this.readFlag(e));
|
||||
this.parserMap.set(types_1.DataType.float, e => this.readFloat(e));
|
||||
}
|
||||
/**
|
||||
* Initialize parser with output (metadata), input (tokenizer) & parsing options (options).
|
||||
* @param {INativeMetadataCollector} metadata Output
|
||||
* @param {ITokenizer} tokenizer Input
|
||||
* @param {IOptions} options Parsing options
|
||||
*/
|
||||
init(metadata, tokenizer, options) {
|
||||
super.init(metadata, tokenizer, options);
|
||||
return this;
|
||||
}
|
||||
async parse() {
|
||||
const matroska = await this.parseContainer(matroskaDtd.elements, this.tokenizer.fileInfo.size, []);
|
||||
this.metadata.setFormat('container', `EBML/${matroska.ebml.docType}`);
|
||||
if (matroska.segment) {
|
||||
const info = matroska.segment.info;
|
||||
if (info) {
|
||||
const timecodeScale = info.timecodeScale ? info.timecodeScale : 1000000;
|
||||
const duration = info.duration * timecodeScale / 1000000000;
|
||||
this.addTag('segment:title', info.title);
|
||||
this.metadata.setFormat('duration', duration);
|
||||
}
|
||||
const audioTracks = matroska.segment.tracks;
|
||||
if (audioTracks && audioTracks.entries) {
|
||||
audioTracks.entries.forEach(entry => {
|
||||
const stream = {
|
||||
codecName: entry.codecID.replace('A_', '').replace('V_', ''),
|
||||
codecSettings: entry.codecSettings,
|
||||
flagDefault: entry.flagDefault,
|
||||
flagLacing: entry.flagLacing,
|
||||
flagEnabled: entry.flagEnabled,
|
||||
language: entry.language,
|
||||
name: entry.name,
|
||||
type: entry.trackType,
|
||||
audio: entry.audio,
|
||||
video: entry.video
|
||||
};
|
||||
this.metadata.addStreamInfo(stream);
|
||||
});
|
||||
const audioTrack = audioTracks.entries
|
||||
.filter(entry => {
|
||||
return entry.trackType === types_1.TrackType.audio.valueOf();
|
||||
})
|
||||
.reduce((acc, cur) => {
|
||||
if (!acc) {
|
||||
return cur;
|
||||
}
|
||||
if (!acc.flagDefault && cur.flagDefault) {
|
||||
return cur;
|
||||
}
|
||||
if (cur.trackNumber && cur.trackNumber < acc.trackNumber) {
|
||||
return cur;
|
||||
}
|
||||
return acc;
|
||||
}, null);
|
||||
if (audioTrack) {
|
||||
this.metadata.setFormat('codec', audioTrack.codecID.replace('A_', ''));
|
||||
this.metadata.setFormat('sampleRate', audioTrack.audio.samplingFrequency);
|
||||
this.metadata.setFormat('numberOfChannels', audioTrack.audio.channels);
|
||||
}
|
||||
if (matroska.segment.tags) {
|
||||
matroska.segment.tags.tag.forEach(tag => {
|
||||
const target = tag.target;
|
||||
const targetType = target.targetTypeValue ? types_1.TargetType[target.targetTypeValue] : (target.targetType ? target.targetType : 'track');
|
||||
tag.simpleTags.forEach(simpleTag => {
|
||||
const value = simpleTag.string ? simpleTag.string : simpleTag.binary;
|
||||
this.addTag(`${targetType}:${simpleTag.name}`, value);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (matroska.segment.attachments) {
|
||||
matroska.segment.attachments.attachedFiles
|
||||
.filter(file => file.mimeType.startsWith('image/'))
|
||||
.map(file => {
|
||||
return {
|
||||
data: file.data,
|
||||
format: file.mimeType,
|
||||
description: file.description,
|
||||
name: file.name
|
||||
};
|
||||
}).forEach(picture => {
|
||||
this.addTag('picture', picture);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
async parseContainer(container, posDone, path) {
|
||||
const tree = {};
|
||||
while (this.tokenizer.position < posDone) {
|
||||
let element;
|
||||
try {
|
||||
element = await this.readElement();
|
||||
}
|
||||
catch (error) {
|
||||
if (error.message === 'End-Of-Stream') {
|
||||
break;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const type = container[element.id];
|
||||
if (type) {
|
||||
debug(`Element: name=${type.name}, container=${!!type.container}`);
|
||||
if (type.container) {
|
||||
const res = await this.parseContainer(type.container, element.len >= 0 ? this.tokenizer.position + element.len : -1, path.concat([type.name]));
|
||||
if (type.multiple) {
|
||||
if (!tree[type.name]) {
|
||||
tree[type.name] = [];
|
||||
}
|
||||
tree[type.name].push(res);
|
||||
}
|
||||
else {
|
||||
tree[type.name] = res;
|
||||
}
|
||||
}
|
||||
else {
|
||||
tree[type.name] = await this.parserMap.get(type.value)(element);
|
||||
}
|
||||
}
|
||||
else {
|
||||
switch (element.id) {
|
||||
case 0xec: // void
|
||||
this.padding += element.len;
|
||||
await this.tokenizer.ignore(element.len);
|
||||
break;
|
||||
default:
|
||||
debug(`parseEbml: path=${path.join('/')}, unknown element: id=${element.id.toString(16)}`);
|
||||
this.padding += element.len;
|
||||
await this.tokenizer.ignore(element.len);
|
||||
}
|
||||
}
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
async readVintData(maxLength) {
|
||||
const msb = await this.tokenizer.peekNumber(Token.UINT8);
|
||||
let mask = 0x80;
|
||||
let oc = 1;
|
||||
// Calculate VINT_WIDTH
|
||||
while ((msb & mask) === 0) {
|
||||
if (oc > maxLength) {
|
||||
throw new Error('VINT value exceeding maximum size');
|
||||
}
|
||||
++oc;
|
||||
mask >>= 1;
|
||||
}
|
||||
const id = Buffer.alloc(oc);
|
||||
await this.tokenizer.readBuffer(id);
|
||||
return id;
|
||||
}
|
||||
async readElement() {
|
||||
const id = await this.readVintData(this.ebmlMaxIDLength);
|
||||
const lenField = await this.readVintData(this.ebmlMaxSizeLength);
|
||||
lenField[0] ^= 0x80 >> (lenField.length - 1);
|
||||
const nrLen = Math.min(6, lenField.length); // JavaScript can max read 6 bytes integer
|
||||
return {
|
||||
id: id.readUIntBE(0, id.length),
|
||||
len: lenField.readUIntBE(lenField.length - nrLen, nrLen)
|
||||
};
|
||||
}
|
||||
isMaxValue(vintData) {
|
||||
if (vintData.length === this.ebmlMaxSizeLength) {
|
||||
for (let n = 1; n < this.ebmlMaxSizeLength; ++n) {
|
||||
if (vintData[n] !== 0xff)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async readFloat(e) {
|
||||
switch (e.len) {
|
||||
case 0:
|
||||
return 0.0;
|
||||
case 4:
|
||||
return this.tokenizer.readNumber(Token.Float32_BE);
|
||||
case 8:
|
||||
return this.tokenizer.readNumber(Token.Float64_BE);
|
||||
case 10:
|
||||
return this.tokenizer.readNumber(Token.Float64_BE);
|
||||
default:
|
||||
throw new Error(`Invalid IEEE-754 float length: ${e.len}`);
|
||||
}
|
||||
}
|
||||
async readFlag(e) {
|
||||
return (await this.readUint(e)) === 1;
|
||||
}
|
||||
async readUint(e) {
|
||||
const buf = await this.readBuffer(e);
|
||||
const nrLen = Math.min(6, e.len); // JavaScript can max read 6 bytes integer
|
||||
return buf.readUIntBE(e.len - nrLen, nrLen);
|
||||
}
|
||||
async readString(e) {
|
||||
const rawString = await this.tokenizer.readToken(new Token.StringType(e.len, 'utf-8'));
|
||||
return rawString.replace(/\00.*$/g, '');
|
||||
}
|
||||
async readBuffer(e) {
|
||||
const buf = Buffer.alloc(e.len);
|
||||
await this.tokenizer.readBuffer(buf);
|
||||
return buf;
|
||||
}
|
||||
addTag(tagId, value) {
|
||||
this.metadata.addTag('matroska', tagId, value);
|
||||
}
|
||||
}
|
||||
exports.MatroskaParser = MatroskaParser;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { CaseInsensitiveTagMap } from '../common/CaseInsensitiveTagMap';
|
||||
export declare class MatroskaTagMapper extends CaseInsensitiveTagMap {
|
||||
constructor();
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.MatroskaTagMapper = void 0;
|
||||
const CaseInsensitiveTagMap_1 = require("../common/CaseInsensitiveTagMap");
|
||||
/**
|
||||
* EBML Tag map
|
||||
*/
|
||||
const ebmlTagMap = {
|
||||
'segment:title': 'title',
|
||||
'album:ARTIST': 'albumartist',
|
||||
'album:ARTISTSORT': 'albumartistsort',
|
||||
'album:TITLE': 'album',
|
||||
'album:DATE_RECORDED': 'originaldate',
|
||||
'album:PART_NUMBER': 'disk',
|
||||
'album:TOTAL_PARTS': 'totaltracks',
|
||||
'track:ARTIST': 'artist',
|
||||
'track:ARTISTSORT': 'artistsort',
|
||||
'track:TITLE': 'title',
|
||||
'track:PART_NUMBER': 'track',
|
||||
'track:MUSICBRAINZ_TRACKID': 'musicbrainz_recordingid',
|
||||
'track:MUSICBRAINZ_ALBUMID': 'musicbrainz_albumid',
|
||||
'track:MUSICBRAINZ_ARTISTID': 'musicbrainz_artistid',
|
||||
'track:PUBLISHER': 'label',
|
||||
'track:GENRE': 'genre',
|
||||
'track:ENCODER': 'encodedby',
|
||||
'track:ENCODER_OPTIONS': 'encodersettings',
|
||||
'edition:TOTAL_PARTS': 'totaldiscs',
|
||||
picture: 'picture'
|
||||
};
|
||||
class MatroskaTagMapper extends CaseInsensitiveTagMap_1.CaseInsensitiveTagMap {
|
||||
constructor() {
|
||||
super(['matroska'], ebmlTagMap);
|
||||
}
|
||||
}
|
||||
exports.MatroskaTagMapper = MatroskaTagMapper;
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
/// <reference types="node" />
|
||||
export interface IHeader {
|
||||
id: number;
|
||||
len: number;
|
||||
}
|
||||
export declare enum DataType {
|
||||
string = 0,
|
||||
uint = 1,
|
||||
uid = 2,
|
||||
bool = 3,
|
||||
binary = 4,
|
||||
float = 5
|
||||
}
|
||||
export interface IElementType<T> {
|
||||
readonly name: string;
|
||||
readonly value?: DataType;
|
||||
readonly container?: IContainerType;
|
||||
readonly multiple?: boolean;
|
||||
}
|
||||
export interface IContainerType {
|
||||
[id: number]: IElementType<string | number | boolean | Buffer>;
|
||||
}
|
||||
export interface ITree {
|
||||
[name: string]: string | number | boolean | Buffer | ITree | ITree[];
|
||||
}
|
||||
export interface ISeekHead {
|
||||
id?: Buffer;
|
||||
position?: number;
|
||||
}
|
||||
export interface IMetaSeekInformation {
|
||||
seekHeads: ISeekHead[];
|
||||
}
|
||||
export interface ISegmentInformation {
|
||||
uid?: Buffer;
|
||||
timecodeScale?: number;
|
||||
duration?: number;
|
||||
dateUTC?: number;
|
||||
title?: string;
|
||||
muxingApp?: string;
|
||||
writingApp?: string;
|
||||
}
|
||||
export interface ITrackEntry {
|
||||
uid?: Buffer;
|
||||
trackNumber?: number;
|
||||
trackType?: TrackType;
|
||||
audio?: ITrackAudio;
|
||||
video?: ITrackVideo;
|
||||
flagEnabled?: boolean;
|
||||
flagDefault?: boolean;
|
||||
flagLacing?: boolean;
|
||||
defaultDuration?: number;
|
||||
trackTimecodeScale?: number;
|
||||
name?: string;
|
||||
language?: string;
|
||||
codecID?: string;
|
||||
codecPrivate?: Buffer;
|
||||
codecName?: string;
|
||||
codecSettings?: string;
|
||||
codecInfoUrl?: string;
|
||||
codecDownloadUrl?: string;
|
||||
codecDecodeAll?: string;
|
||||
trackOverlay?: string;
|
||||
}
|
||||
export interface ITrackVideo {
|
||||
flagInterlaced?: boolean;
|
||||
stereoMode?: number;
|
||||
pixelWidth?: number;
|
||||
pixelHeight?: number;
|
||||
displayWidth?: number;
|
||||
displayHeight?: number;
|
||||
displayUnit?: number;
|
||||
aspectRatioType?: number;
|
||||
colourSpace?: Buffer;
|
||||
gammaValue?: number;
|
||||
}
|
||||
export interface ITrackAudio {
|
||||
samplingFrequency?: number;
|
||||
outputSamplingFrequency?: number;
|
||||
channels?: number;
|
||||
channelPositions?: Buffer;
|
||||
bitDepth?: number;
|
||||
}
|
||||
export interface ICuePoint {
|
||||
cueTime?: number;
|
||||
cueTrackPositions: ICueTrackPosition[];
|
||||
}
|
||||
export interface ICueTrackPosition {
|
||||
cueTrack?: number;
|
||||
cueClusterPosition?: number;
|
||||
cueBlockNumber?: number;
|
||||
cueCodecState?: number;
|
||||
cueReference?: ICueReference;
|
||||
}
|
||||
export interface ICueReference {
|
||||
cueRefTime?: number;
|
||||
cueRefCluster?: number;
|
||||
cueRefNumber?: number;
|
||||
cueRefCodecState?: number;
|
||||
}
|
||||
export interface ISimpleTag {
|
||||
name?: string;
|
||||
string?: string;
|
||||
binary?: Buffer;
|
||||
language?: string;
|
||||
default?: boolean;
|
||||
}
|
||||
export declare enum TargetType {
|
||||
shot = 10,
|
||||
scene = 20,
|
||||
track = 30,
|
||||
part = 40,
|
||||
album = 50,
|
||||
edition = 60,
|
||||
collection = 70
|
||||
}
|
||||
export declare enum TrackType {
|
||||
video = 1,
|
||||
audio = 2,
|
||||
complex = 3,
|
||||
logo = 4,
|
||||
subtitle = 17,
|
||||
button = 18,
|
||||
control = 32
|
||||
}
|
||||
export interface ITarget {
|
||||
trackUID?: Buffer;
|
||||
chapterUID?: Buffer;
|
||||
attachmentUID?: Buffer;
|
||||
targetTypeValue?: TargetType;
|
||||
targetType?: string;
|
||||
}
|
||||
export interface ITag {
|
||||
target: ITarget;
|
||||
simpleTags: ISimpleTag[];
|
||||
}
|
||||
export interface ITags {
|
||||
tag: ITag[];
|
||||
}
|
||||
export interface ITrackElement {
|
||||
entries?: ITrackEntry[];
|
||||
}
|
||||
export interface IAttachmedFile {
|
||||
description?: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
data: Buffer;
|
||||
uid: string;
|
||||
}
|
||||
export interface IAttachments {
|
||||
attachedFiles?: IAttachmedFile[];
|
||||
}
|
||||
export interface IMatroskaSegment {
|
||||
metaSeekInfo?: IMetaSeekInformation;
|
||||
seekHeads?: ISeekHead[];
|
||||
info?: ISegmentInformation;
|
||||
tracks?: ITrackElement;
|
||||
tags?: ITags;
|
||||
cues?: ICuePoint[];
|
||||
attachments?: IAttachments;
|
||||
}
|
||||
export interface IEbmlElements {
|
||||
version?: number;
|
||||
readVersion?: number;
|
||||
maxIDWidth?: number;
|
||||
maxSizeWidth?: number;
|
||||
docType?: string;
|
||||
docTypeVersion?: number;
|
||||
docTypeReadVersion?: number;
|
||||
}
|
||||
export interface IEbmlDoc {
|
||||
ebml: IEbmlElements;
|
||||
}
|
||||
export interface IMatroskaDoc extends IEbmlDoc {
|
||||
segment: IMatroskaSegment;
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.TrackType = exports.TargetType = exports.DataType = void 0;
|
||||
var DataType;
|
||||
(function (DataType) {
|
||||
DataType[DataType["string"] = 0] = "string";
|
||||
DataType[DataType["uint"] = 1] = "uint";
|
||||
DataType[DataType["uid"] = 2] = "uid";
|
||||
DataType[DataType["bool"] = 3] = "bool";
|
||||
DataType[DataType["binary"] = 4] = "binary";
|
||||
DataType[DataType["float"] = 5] = "float";
|
||||
})(DataType = exports.DataType || (exports.DataType = {}));
|
||||
var TargetType;
|
||||
(function (TargetType) {
|
||||
TargetType[TargetType["shot"] = 10] = "shot";
|
||||
TargetType[TargetType["scene"] = 20] = "scene";
|
||||
TargetType[TargetType["track"] = 30] = "track";
|
||||
TargetType[TargetType["part"] = 40] = "part";
|
||||
TargetType[TargetType["album"] = 50] = "album";
|
||||
TargetType[TargetType["edition"] = 60] = "edition";
|
||||
TargetType[TargetType["collection"] = 70] = "collection";
|
||||
})(TargetType = exports.TargetType || (exports.TargetType = {}));
|
||||
var TrackType;
|
||||
(function (TrackType) {
|
||||
TrackType[TrackType["video"] = 1] = "video";
|
||||
TrackType[TrackType["audio"] = 2] = "audio";
|
||||
TrackType[TrackType["complex"] = 3] = "complex";
|
||||
TrackType[TrackType["logo"] = 4] = "logo";
|
||||
TrackType[TrackType["subtitle"] = 17] = "subtitle";
|
||||
TrackType[TrackType["button"] = 18] = "button";
|
||||
TrackType[TrackType["control"] = 32] = "control";
|
||||
})(TrackType = exports.TrackType || (exports.TrackType = {}));
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { ITokenizer } from 'strtok3/lib/core';
|
||||
import * as AtomToken from './AtomToken';
|
||||
export declare type AtomDataHandler = (atom: Atom, remaining: number) => Promise<void>;
|
||||
export declare class Atom {
|
||||
readonly header: AtomToken.IAtomHeader;
|
||||
extended: boolean;
|
||||
readonly parent: Atom;
|
||||
static readAtom(tokenizer: ITokenizer, dataHandler: AtomDataHandler, parent: Atom, remaining: number): Promise<Atom>;
|
||||
readonly children: Atom[];
|
||||
readonly atomPath: string;
|
||||
constructor(header: AtomToken.IAtomHeader, extended: boolean, parent: Atom);
|
||||
getHeaderLength(): number;
|
||||
getPayloadLength(remaining: number): number;
|
||||
readAtoms(tokenizer: ITokenizer, dataHandler: AtomDataHandler, size: number): Promise<void>;
|
||||
private readData;
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.Atom = void 0;
|
||||
const initDebug = require("debug");
|
||||
const AtomToken = require("./AtomToken");
|
||||
const debug = initDebug('music-metadata:parser:MP4:Atom');
|
||||
class Atom {
|
||||
constructor(header, extended, parent) {
|
||||
this.header = header;
|
||||
this.extended = extended;
|
||||
this.parent = parent;
|
||||
this.children = [];
|
||||
this.atomPath = (this.parent ? this.parent.atomPath + '.' : '') + this.header.name;
|
||||
}
|
||||
static async readAtom(tokenizer, dataHandler, parent, remaining) {
|
||||
// Parse atom header
|
||||
const offset = tokenizer.position;
|
||||
// debug(`Reading next token on offset=${offset}...`); // buf.toString('ascii')
|
||||
const header = await tokenizer.readToken(AtomToken.Header);
|
||||
const extended = header.length === BigInt(1);
|
||||
if (extended) {
|
||||
header.length = await tokenizer.readToken(AtomToken.ExtendedSize);
|
||||
}
|
||||
const atomBean = new Atom(header, header.length === BigInt(1), parent);
|
||||
const payloadLength = atomBean.getPayloadLength(remaining);
|
||||
debug(`parse atom name=${atomBean.atomPath}, extended=${atomBean.extended}, offset=${offset}, len=${atomBean.header.length}`); // buf.toString('ascii')
|
||||
await atomBean.readData(tokenizer, dataHandler, payloadLength);
|
||||
return atomBean;
|
||||
}
|
||||
getHeaderLength() {
|
||||
return this.extended ? 16 : 8;
|
||||
}
|
||||
getPayloadLength(remaining) {
|
||||
return (this.header.length === BigInt(0) ? remaining : Number(this.header.length)) - this.getHeaderLength();
|
||||
}
|
||||
async readAtoms(tokenizer, dataHandler, size) {
|
||||
while (size > 0) {
|
||||
const atomBean = await Atom.readAtom(tokenizer, dataHandler, this, size);
|
||||
this.children.push(atomBean);
|
||||
size -= atomBean.header.length === BigInt(0) ? size : Number(atomBean.header.length);
|
||||
}
|
||||
}
|
||||
async readData(tokenizer, dataHandler, remaining) {
|
||||
switch (this.header.name) {
|
||||
// "Container" atoms, contains nested atoms
|
||||
case 'moov': // The Movie Atom: contains other atoms
|
||||
case 'udta': // User defined atom
|
||||
case 'trak':
|
||||
case 'mdia': // Media atom
|
||||
case 'minf': // Media Information Atom
|
||||
case 'stbl': // The Sample Table Atom
|
||||
case '<id>':
|
||||
case 'ilst':
|
||||
case 'tref':
|
||||
return this.readAtoms(tokenizer, dataHandler, this.getPayloadLength(remaining));
|
||||
case 'meta': // Metadata Atom, ref: https://developer.apple.com/library/content/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW8
|
||||
// meta has 4 bytes of padding, ignore
|
||||
await tokenizer.ignore(4);
|
||||
return this.readAtoms(tokenizer, dataHandler, this.getPayloadLength(remaining) - 4);
|
||||
case 'mdhd': // Media header atom
|
||||
case 'mvhd': // 'movie' => 'mvhd': movie header atom; child of Movie Atom
|
||||
case 'tkhd':
|
||||
case 'stsz':
|
||||
case 'mdat':
|
||||
default:
|
||||
return dataHandler(this, remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.Atom = Atom;
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
/// <reference types="node" />
|
||||
import { IToken, IGetToken } from 'strtok3/lib/core';
|
||||
interface IVersionAndFlags {
|
||||
/**
|
||||
* A 1-byte specification of the version
|
||||
*/
|
||||
version: number;
|
||||
/**
|
||||
* Three bytes of space for (future) flags.
|
||||
*/
|
||||
flags: number;
|
||||
}
|
||||
export interface IAtomHeader {
|
||||
length: bigint;
|
||||
name: string;
|
||||
}
|
||||
export interface IAtomFtyp {
|
||||
type: string;
|
||||
}
|
||||
/**
|
||||
* Common interface for the mvhd (Movie Header) & mdhd (Media) atom
|
||||
*/
|
||||
export interface IAtomMxhd extends IVersionAndFlags {
|
||||
/**
|
||||
* A 32-bit integer that specifies (in seconds since midnight, January 1, 1904) when the media atom was created.
|
||||
* It is strongly recommended that this value should be specified using coordinated universal time (UTC).
|
||||
*/
|
||||
creationTime: Date;
|
||||
/**
|
||||
* A 32-bit integer that specifies (in seconds since midnight, January 1, 1904) when the media atom was changed.
|
||||
* It is strongly recommended that this value should be specified using coordinated universal time (UTC).
|
||||
*/
|
||||
modificationTime: Date;
|
||||
/**
|
||||
* A time value that indicates the time scale for this media—that is, the number of time units that pass per second in its time coordinate system.
|
||||
*/
|
||||
timeScale: number;
|
||||
/**
|
||||
* Duration: the duration of this media in units of its time scale.
|
||||
*/
|
||||
duration: number;
|
||||
}
|
||||
/**
|
||||
* Interface for the parsed Movie Header Atom (mvhd)
|
||||
*/
|
||||
export interface IAtomMvhd extends IAtomMxhd {
|
||||
/**
|
||||
* Preferred rate: a 32-bit fixed-point number that specifies the rate at which to play this movie.
|
||||
* A value of 1.0 indicates normal rate.
|
||||
*/
|
||||
preferredRate: number;
|
||||
/**
|
||||
* Preferred volume: A 16-bit fixed-point number that specifies how loud to play this movie’s sound.
|
||||
* A value of 1.0 indicates full volume.
|
||||
*/
|
||||
preferredVolume: number;
|
||||
/**
|
||||
* Reserved: Ten bytes reserved for use by Apple. Set to 0.
|
||||
*/
|
||||
/**
|
||||
* Matrix structure: The matrix structure associated with this movie.
|
||||
* A matrix shows how to map points from one coordinate space into another.
|
||||
* See Matrices for a discussion of how display matrices are used in QuickTime.
|
||||
*/
|
||||
/**
|
||||
* Preview time: The time value in the movie at which the preview begins.
|
||||
*/
|
||||
previewTime: number;
|
||||
/**
|
||||
* Preview duration: The duration of the movie preview in movie time scale units.
|
||||
*/
|
||||
previewDuration: number;
|
||||
/**
|
||||
* Poster time: The time value of the time of the movie poster.
|
||||
*/
|
||||
posterTime: number;
|
||||
/**
|
||||
* selection time: The time value for the start time of the current selection.
|
||||
*/
|
||||
selectionTime: number;
|
||||
/**
|
||||
* Selection duration: The duration of the current selection in movie time scale units.
|
||||
*/
|
||||
selectionDuration: number;
|
||||
/**
|
||||
* Current time: The time value for current time position within the movie.
|
||||
*/
|
||||
currentTime: number;
|
||||
/**
|
||||
* Next track ID: A 32-bit integer that indicates a value to use for the track ID number of the next track added to this movie. Note that 0 is not a valid track ID value.
|
||||
*/
|
||||
nextTrackID: number;
|
||||
}
|
||||
/**
|
||||
* Interface for the metadata header atom: 'mhdr'
|
||||
* Ref: https://developer.apple.com/library/content/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW13
|
||||
*/
|
||||
export interface IMovieHeaderAtom extends IVersionAndFlags {
|
||||
/**
|
||||
* A 32-bit unsigned integer indicating the value to use for the item ID of the next item created or assigned an item ID.
|
||||
* If the value is all ones, it indicates that future additions will require a search for an unused item ID.
|
||||
*/
|
||||
nextItemID: number;
|
||||
}
|
||||
export declare const Header: IToken<IAtomHeader>;
|
||||
/**
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap1/qtff1.html#//apple_ref/doc/uid/TP40000939-CH203-38190
|
||||
*/
|
||||
export declare const ExtendedSize: IToken<bigint>;
|
||||
export declare const ftyp: IGetToken<IAtomFtyp>;
|
||||
export declare const tkhd: IGetToken<IAtomFtyp>;
|
||||
/**
|
||||
* Token: Movie Header Atom
|
||||
*/
|
||||
export declare const mhdr: IGetToken<IMovieHeaderAtom>;
|
||||
/**
|
||||
* Base class for 'fixed' length atoms.
|
||||
* In some cases these atoms are longer then the sum of the described fields.
|
||||
* Issue: https://github.com/Borewit/music-metadata/issues/120
|
||||
*/
|
||||
export declare abstract class FixedLengthAtom {
|
||||
len: number;
|
||||
/**
|
||||
*
|
||||
* @param {number} len Length as specified in the size field
|
||||
* @param {number} expLen Total length of sum of specified fields in the standard
|
||||
*/
|
||||
protected constructor(len: number, expLen: number, atomId: string);
|
||||
}
|
||||
/**
|
||||
* Interface for the parsed Movie Header Atom (mdhd)
|
||||
*/
|
||||
export interface IAtomMdhd extends IAtomMxhd {
|
||||
/**
|
||||
* A 16-bit integer that specifies the language code for this media.
|
||||
* See Language Code Values for valid language codes.
|
||||
* Also see Extended Language Tag Atom for the preferred code to use here if an extended language tag is also included in the media atom.
|
||||
* Ref: https://developer.apple.com/library/content/documentation/QuickTime/QTFF/QTFFChap4/qtff4.html#//apple_ref/doc/uid/TP40000939-CH206-34353
|
||||
*/
|
||||
language: number;
|
||||
quality: number;
|
||||
}
|
||||
/**
|
||||
* Token: Media Header Atom
|
||||
* Ref:
|
||||
* https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-SW34
|
||||
* https://wiki.multimedia.cx/index.php/QuickTime_container#mdhd
|
||||
*/
|
||||
export declare class MdhdAtom extends FixedLengthAtom implements IGetToken<IAtomMdhd> {
|
||||
len: number;
|
||||
constructor(len: number);
|
||||
get(buf: Buffer, off: number): IAtomMdhd;
|
||||
}
|
||||
/**
|
||||
* Token: Movie Header Atom
|
||||
*/
|
||||
export declare class MvhdAtom extends FixedLengthAtom implements IGetToken<IAtomMvhd> {
|
||||
len: number;
|
||||
constructor(len: number);
|
||||
get(buf: Buffer, off: number): IAtomMvhd;
|
||||
}
|
||||
/**
|
||||
* Data Atom Structure ('data')
|
||||
* Ref: https://developer.apple.com/library/content/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW32
|
||||
*/
|
||||
export interface IDataAtom {
|
||||
/**
|
||||
* Type Indicator
|
||||
* Ref: https://developer.apple.com/library/content/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW28
|
||||
*/
|
||||
type: {
|
||||
/**
|
||||
* The set of types from which the type is drawn
|
||||
* If 0, type is drawn from the well-known set of types.
|
||||
*/
|
||||
set: number;
|
||||
type: number;
|
||||
};
|
||||
/**
|
||||
* Locale Indicator
|
||||
*/
|
||||
locale: number;
|
||||
/**
|
||||
* An array of bytes containing the value of the metadata.
|
||||
*/
|
||||
value: Buffer;
|
||||
}
|
||||
/**
|
||||
* Data Atom Structure
|
||||
*/
|
||||
export declare class DataAtom implements IGetToken<IDataAtom> {
|
||||
len: number;
|
||||
constructor(len: number);
|
||||
get(buf: Uint8Array, off: number): IDataAtom;
|
||||
}
|
||||
/**
|
||||
* Data Atom Structure ('data')
|
||||
* Ref: https://developer.apple.com/library/content/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW32
|
||||
*/
|
||||
export interface INameAtom extends IVersionAndFlags {
|
||||
/**
|
||||
* An array of bytes containing the value of the metadata.
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
/**
|
||||
* Data Atom Structure
|
||||
* Ref: https://developer.apple.com/library/content/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW31
|
||||
*/
|
||||
export declare class NameAtom implements IGetToken<INameAtom> {
|
||||
len: number;
|
||||
constructor(len: number);
|
||||
get(buf: Buffer, off: number): INameAtom;
|
||||
}
|
||||
/**
|
||||
* Track Header Atoms interface
|
||||
* Ref: https://developer.apple.com/library/content/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-25550
|
||||
*/
|
||||
export interface ITrackHeaderAtom extends IVersionAndFlags {
|
||||
/**
|
||||
* Creation Time
|
||||
*/
|
||||
creationTime: Date;
|
||||
/**
|
||||
* Modification Time
|
||||
*/
|
||||
modificationTime: Date;
|
||||
/**
|
||||
* TrackID
|
||||
*/
|
||||
trackId: number;
|
||||
/**
|
||||
* A time value that indicates the duration of this track (in the movie’s time coordinate system).
|
||||
* Note that this property is derived from the track’s edits. The value of this field is equal to the sum of the
|
||||
* durations of all of the track’s edits. If there is no edit list, then the duration is the sum of the sample
|
||||
* durations, converted into the movie timescale.
|
||||
*/
|
||||
duration: number;
|
||||
/**
|
||||
* A 16-bit integer that indicates this track’s spatial priority in its movie.
|
||||
* The QuickTime Movie Toolbox uses this value to determine how tracks overlay one another.
|
||||
* Tracks with lower layer values are displayed in front of tracks with higher layer values.
|
||||
*/
|
||||
layer: number;
|
||||
/**
|
||||
* A 16-bit integer that identifies a collection of movie tracks that contain alternate data for one another.
|
||||
* This same identifier appears in each 'tkhd' atom of the other tracks in the group.
|
||||
* QuickTime chooses one track from the group to be used when the movie is played.
|
||||
* The choice may be based on such considerations as playback quality, language, or the capabilities of the computer.
|
||||
* A value of zero indicates that the track is not in an alternate track group.
|
||||
*/
|
||||
alternateGroup: number;
|
||||
/**
|
||||
* A 16-bit fixed-point value that indicates how loudly this track’s sound is to be played.
|
||||
* A value of 1.0 indicates normal volume.
|
||||
*/
|
||||
volume: number;
|
||||
}
|
||||
/**
|
||||
* Track Header Atoms structure
|
||||
* Ref: https://developer.apple.com/library/content/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-25550
|
||||
*/
|
||||
export declare class TrackHeaderAtom implements IGetToken<ITrackHeaderAtom> {
|
||||
len: number;
|
||||
constructor(len: number);
|
||||
get(buf: Buffer, off: number): ITrackHeaderAtom;
|
||||
}
|
||||
/**
|
||||
* Atom: Sample Description Atom ('stsd')
|
||||
*/
|
||||
interface IAtomStsdHeader extends IVersionAndFlags {
|
||||
numberOfEntries: number;
|
||||
}
|
||||
/**
|
||||
* Atom: Sample Description Atom ('stsd')
|
||||
*/
|
||||
export interface ISampleDescription {
|
||||
dataFormat: string;
|
||||
dataReferenceIndex: number;
|
||||
description: Uint8Array;
|
||||
}
|
||||
export interface IAtomStsd {
|
||||
header: IAtomStsdHeader;
|
||||
table: ISampleDescription[];
|
||||
}
|
||||
/**
|
||||
* Atom: Sample-description Atom ('stsd')
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-25691
|
||||
*/
|
||||
export declare class StsdAtom implements IGetToken<IAtomStsd> {
|
||||
len: number;
|
||||
constructor(len: number);
|
||||
get(buf: Buffer, off: number): IAtomStsd;
|
||||
}
|
||||
export interface ISoundSampleDescriptionVersion {
|
||||
version: number;
|
||||
revision: number;
|
||||
vendor: number;
|
||||
}
|
||||
/**
|
||||
* Common Sound Sample Description (version & revision)
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html#//apple_ref/doc/uid/TP40000939-CH205-57317
|
||||
*/
|
||||
export declare const SoundSampleDescriptionVersion: IGetToken<ISoundSampleDescriptionVersion>;
|
||||
export interface ISoundSampleDescriptionV0 {
|
||||
numAudioChannels: number;
|
||||
/**
|
||||
* number of bits in each uncompressed sound sample
|
||||
*/
|
||||
sampleSize: number;
|
||||
/**
|
||||
* Compression ID
|
||||
*/
|
||||
compressionId: number;
|
||||
packetSize: number;
|
||||
sampleRate: number;
|
||||
}
|
||||
/**
|
||||
* Sound Sample Description (Version 0)
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html#//apple_ref/doc/uid/TP40000939-CH205-130736
|
||||
*/
|
||||
export declare const SoundSampleDescriptionV0: IGetToken<ISoundSampleDescriptionV0>;
|
||||
export interface ITableAtom<T> extends IVersionAndFlags {
|
||||
numberOfEntries: number;
|
||||
entries: T[];
|
||||
}
|
||||
declare class SimpleTableAtom<T> implements IGetToken<ITableAtom<T>> {
|
||||
len: number;
|
||||
private token;
|
||||
constructor(len: number, token: IGetToken<T>);
|
||||
get(buf: Buffer, off: number): ITableAtom<T>;
|
||||
}
|
||||
export interface ITimeToSampleToken {
|
||||
count: number;
|
||||
duration: number;
|
||||
}
|
||||
export declare const TimeToSampleToken: IGetToken<ITimeToSampleToken>;
|
||||
/**
|
||||
* Time-to-sample('stts') atom.
|
||||
* Store duration information for a media’s samples.
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-25696
|
||||
*/
|
||||
export declare class SttsAtom extends SimpleTableAtom<ITimeToSampleToken> {
|
||||
len: number;
|
||||
constructor(len: number);
|
||||
}
|
||||
/**
|
||||
* Sample-to-Chunk ('stsc') atom table entry interface
|
||||
*/
|
||||
export interface ISampleToChunk {
|
||||
firstChunk: number;
|
||||
samplesPerChunk: number;
|
||||
sampleDescriptionId: number;
|
||||
}
|
||||
export declare const SampleToChunkToken: IGetToken<ISampleToChunk>;
|
||||
/**
|
||||
* Sample-to-Chunk ('stsc') atom interface
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-25706
|
||||
*/
|
||||
export declare class StscAtom extends SimpleTableAtom<ISampleToChunk> {
|
||||
len: number;
|
||||
constructor(len: number);
|
||||
}
|
||||
/**
|
||||
* Sample-size ('stsz') atom interface
|
||||
*/
|
||||
export interface IStszAtom extends ITableAtom<number> {
|
||||
sampleSize: number;
|
||||
}
|
||||
/**
|
||||
* Sample-size ('stsz') atom
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-25710
|
||||
*/
|
||||
export declare class StszAtom implements IGetToken<IStszAtom> {
|
||||
len: number;
|
||||
constructor(len: number);
|
||||
get(buf: Buffer, off: number): IStszAtom;
|
||||
}
|
||||
/**
|
||||
* Chunk offset atom, 'stco'
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-25715
|
||||
*/
|
||||
export declare class StcoAtom extends SimpleTableAtom<number> {
|
||||
len: number;
|
||||
constructor(len: number);
|
||||
}
|
||||
/**
|
||||
* Token used to decode text-track from 'mdat' atom (raw data stream)
|
||||
*/
|
||||
export declare class ChapterText implements IGetToken<string> {
|
||||
len: number;
|
||||
constructor(len: number);
|
||||
get(buf: Buffer, off: number): string;
|
||||
}
|
||||
export {};
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ChapterText = exports.StcoAtom = exports.StszAtom = exports.StscAtom = exports.SampleToChunkToken = exports.SttsAtom = exports.TimeToSampleToken = exports.SoundSampleDescriptionV0 = exports.SoundSampleDescriptionVersion = exports.StsdAtom = exports.TrackHeaderAtom = exports.NameAtom = exports.DataAtom = exports.MvhdAtom = exports.MdhdAtom = exports.FixedLengthAtom = exports.mhdr = exports.tkhd = exports.ftyp = exports.ExtendedSize = exports.Header = void 0;
|
||||
const Token = require("token-types");
|
||||
const FourCC_1 = require("../common/FourCC");
|
||||
const initDebug = require("debug");
|
||||
const debug = initDebug('music-metadata:parser:MP4:atom');
|
||||
exports.Header = {
|
||||
len: 8,
|
||||
get: (buf, off) => {
|
||||
const length = Token.UINT32_BE.get(buf, off);
|
||||
if (length < 0)
|
||||
throw new Error('Invalid atom header length');
|
||||
return {
|
||||
length: BigInt(length),
|
||||
name: new Token.StringType(4, 'binary').get(buf, off + 4)
|
||||
};
|
||||
},
|
||||
put: (buf, off, hdr) => {
|
||||
Token.UINT32_BE.put(buf, off, Number(hdr.length));
|
||||
return FourCC_1.FourCcToken.put(buf, off + 4, hdr.name);
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap1/qtff1.html#//apple_ref/doc/uid/TP40000939-CH203-38190
|
||||
*/
|
||||
exports.ExtendedSize = Token.UINT64_BE;
|
||||
exports.ftyp = {
|
||||
len: 4,
|
||||
get: (buf, off) => {
|
||||
return {
|
||||
type: new Token.StringType(4, 'ascii').get(buf, off)
|
||||
};
|
||||
}
|
||||
};
|
||||
exports.tkhd = {
|
||||
len: 4,
|
||||
get: (buf, off) => {
|
||||
return {
|
||||
type: new Token.StringType(4, 'ascii').get(buf, off)
|
||||
};
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Token: Movie Header Atom
|
||||
*/
|
||||
exports.mhdr = {
|
||||
len: 8,
|
||||
get: (buf, off) => {
|
||||
return {
|
||||
version: Token.UINT8.get(buf, off),
|
||||
flags: Token.UINT24_BE.get(buf, off + 1),
|
||||
nextItemID: Token.UINT32_BE.get(buf, off + 4)
|
||||
};
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Base class for 'fixed' length atoms.
|
||||
* In some cases these atoms are longer then the sum of the described fields.
|
||||
* Issue: https://github.com/Borewit/music-metadata/issues/120
|
||||
*/
|
||||
class FixedLengthAtom {
|
||||
/**
|
||||
*
|
||||
* @param {number} len Length as specified in the size field
|
||||
* @param {number} expLen Total length of sum of specified fields in the standard
|
||||
*/
|
||||
constructor(len, expLen, atomId) {
|
||||
this.len = len;
|
||||
if (len < expLen) {
|
||||
throw new Error(`Atom ${atomId} expected to be ${expLen}, but specifies ${len} bytes long.`);
|
||||
}
|
||||
else if (len > expLen) {
|
||||
debug(`Warning: atom ${atomId} expected to be ${expLen}, but was actually ${len} bytes long.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.FixedLengthAtom = FixedLengthAtom;
|
||||
/**
|
||||
* Timestamp stored in seconds since Mac Epoch (1 January 1904)
|
||||
*/
|
||||
const SecondsSinceMacEpoch = {
|
||||
len: 4,
|
||||
get: (buf, off) => {
|
||||
const secondsSinceUnixEpoch = Token.UINT32_BE.get(buf, off) - 2082844800;
|
||||
return new Date(secondsSinceUnixEpoch * 1000);
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Token: Media Header Atom
|
||||
* Ref:
|
||||
* https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-SW34
|
||||
* https://wiki.multimedia.cx/index.php/QuickTime_container#mdhd
|
||||
*/
|
||||
class MdhdAtom extends FixedLengthAtom {
|
||||
constructor(len) {
|
||||
super(len, 24, 'mdhd');
|
||||
this.len = len;
|
||||
}
|
||||
get(buf, off) {
|
||||
return {
|
||||
version: Token.UINT8.get(buf, off + 0),
|
||||
flags: Token.UINT24_BE.get(buf, off + 1),
|
||||
creationTime: SecondsSinceMacEpoch.get(buf, off + 4),
|
||||
modificationTime: SecondsSinceMacEpoch.get(buf, off + 8),
|
||||
timeScale: Token.UINT32_BE.get(buf, off + 12),
|
||||
duration: Token.UINT32_BE.get(buf, off + 16),
|
||||
language: Token.UINT16_BE.get(buf, off + 20),
|
||||
quality: Token.UINT16_BE.get(buf, off + 22)
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.MdhdAtom = MdhdAtom;
|
||||
/**
|
||||
* Token: Movie Header Atom
|
||||
*/
|
||||
class MvhdAtom extends FixedLengthAtom {
|
||||
constructor(len) {
|
||||
super(len, 100, 'mvhd');
|
||||
this.len = len;
|
||||
}
|
||||
get(buf, off) {
|
||||
return {
|
||||
version: Token.UINT8.get(buf, off),
|
||||
flags: Token.UINT24_BE.get(buf, off + 1),
|
||||
creationTime: SecondsSinceMacEpoch.get(buf, off + 4),
|
||||
modificationTime: SecondsSinceMacEpoch.get(buf, off + 8),
|
||||
timeScale: Token.UINT32_BE.get(buf, off + 12),
|
||||
duration: Token.UINT32_BE.get(buf, off + 16),
|
||||
preferredRate: Token.UINT32_BE.get(buf, off + 20),
|
||||
preferredVolume: Token.UINT16_BE.get(buf, off + 24),
|
||||
// ignore reserver: 10 bytes
|
||||
// ignore matrix structure: 36 bytes
|
||||
previewTime: Token.UINT32_BE.get(buf, off + 72),
|
||||
previewDuration: Token.UINT32_BE.get(buf, off + 76),
|
||||
posterTime: Token.UINT32_BE.get(buf, off + 80),
|
||||
selectionTime: Token.UINT32_BE.get(buf, off + 84),
|
||||
selectionDuration: Token.UINT32_BE.get(buf, off + 88),
|
||||
currentTime: Token.UINT32_BE.get(buf, off + 92),
|
||||
nextTrackID: Token.UINT32_BE.get(buf, off + 96)
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.MvhdAtom = MvhdAtom;
|
||||
/**
|
||||
* Data Atom Structure
|
||||
*/
|
||||
class DataAtom {
|
||||
constructor(len) {
|
||||
this.len = len;
|
||||
}
|
||||
get(buf, off) {
|
||||
return {
|
||||
type: {
|
||||
set: Token.UINT8.get(buf, off + 0),
|
||||
type: Token.UINT24_BE.get(buf, off + 1)
|
||||
},
|
||||
locale: Token.UINT24_BE.get(buf, off + 4),
|
||||
value: Buffer.from(new Token.Uint8ArrayType(this.len - 8).get(buf, off + 8))
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.DataAtom = DataAtom;
|
||||
/**
|
||||
* Data Atom Structure
|
||||
* Ref: https://developer.apple.com/library/content/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW31
|
||||
*/
|
||||
class NameAtom {
|
||||
constructor(len) {
|
||||
this.len = len;
|
||||
}
|
||||
get(buf, off) {
|
||||
return {
|
||||
version: Token.UINT8.get(buf, off),
|
||||
flags: Token.UINT24_BE.get(buf, off + 1),
|
||||
name: new Token.StringType(this.len - 4, 'utf-8').get(buf, off + 4)
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.NameAtom = NameAtom;
|
||||
/**
|
||||
* Track Header Atoms structure
|
||||
* Ref: https://developer.apple.com/library/content/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-25550
|
||||
*/
|
||||
class TrackHeaderAtom {
|
||||
constructor(len) {
|
||||
this.len = len;
|
||||
}
|
||||
get(buf, off) {
|
||||
return {
|
||||
version: Token.UINT8.get(buf, off),
|
||||
flags: Token.UINT24_BE.get(buf, off + 1),
|
||||
creationTime: SecondsSinceMacEpoch.get(buf, off + 4),
|
||||
modificationTime: SecondsSinceMacEpoch.get(buf, off + 8),
|
||||
trackId: Token.UINT32_BE.get(buf, off + 12),
|
||||
// reserved 4 bytes
|
||||
duration: Token.UINT32_BE.get(buf, off + 20),
|
||||
layer: Token.UINT16_BE.get(buf, off + 24),
|
||||
alternateGroup: Token.UINT16_BE.get(buf, off + 26),
|
||||
volume: Token.UINT16_BE.get(buf, off + 28) // ToDo: fixed point
|
||||
// ToDo: add remaining fields
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.TrackHeaderAtom = TrackHeaderAtom;
|
||||
/**
|
||||
* Atom: Sample Description Atom ('stsd')
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-25691
|
||||
*/
|
||||
const stsdHeader = {
|
||||
len: 8,
|
||||
get: (buf, off) => {
|
||||
return {
|
||||
version: Token.UINT8.get(buf, off),
|
||||
flags: Token.UINT24_BE.get(buf, off + 1),
|
||||
numberOfEntries: Token.UINT32_BE.get(buf, off + 4)
|
||||
};
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Atom: Sample Description Atom ('stsd')
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-25691
|
||||
*/
|
||||
class SampleDescriptionTable {
|
||||
constructor(len) {
|
||||
this.len = len;
|
||||
}
|
||||
get(buf, off) {
|
||||
return {
|
||||
dataFormat: FourCC_1.FourCcToken.get(buf, off),
|
||||
dataReferenceIndex: Token.UINT16_BE.get(buf, off + 10),
|
||||
description: new Token.Uint8ArrayType(this.len - 12).get(buf, off + 12)
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Atom: Sample-description Atom ('stsd')
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-25691
|
||||
*/
|
||||
class StsdAtom {
|
||||
constructor(len) {
|
||||
this.len = len;
|
||||
}
|
||||
get(buf, off) {
|
||||
const header = stsdHeader.get(buf, off);
|
||||
off += stsdHeader.len;
|
||||
const table = [];
|
||||
for (let n = 0; n < header.numberOfEntries; ++n) {
|
||||
const size = Token.UINT32_BE.get(buf, off); // Sample description size
|
||||
off += Token.UINT32_BE.len;
|
||||
table.push(new SampleDescriptionTable(size).get(buf, off));
|
||||
off += size;
|
||||
}
|
||||
return {
|
||||
header,
|
||||
table
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.StsdAtom = StsdAtom;
|
||||
/**
|
||||
* Common Sound Sample Description (version & revision)
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html#//apple_ref/doc/uid/TP40000939-CH205-57317
|
||||
*/
|
||||
exports.SoundSampleDescriptionVersion = {
|
||||
len: 8,
|
||||
get(buf, off) {
|
||||
return {
|
||||
version: Token.INT16_BE.get(buf, off),
|
||||
revision: Token.INT16_BE.get(buf, off + 2),
|
||||
vendor: Token.INT32_BE.get(buf, off + 4)
|
||||
};
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Sound Sample Description (Version 0)
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html#//apple_ref/doc/uid/TP40000939-CH205-130736
|
||||
*/
|
||||
exports.SoundSampleDescriptionV0 = {
|
||||
len: 12,
|
||||
get(buf, off) {
|
||||
return {
|
||||
numAudioChannels: Token.INT16_BE.get(buf, off + 0),
|
||||
sampleSize: Token.INT16_BE.get(buf, off + 2),
|
||||
compressionId: Token.INT16_BE.get(buf, off + 4),
|
||||
packetSize: Token.INT16_BE.get(buf, off + 6),
|
||||
sampleRate: Token.UINT16_BE.get(buf, off + 8) + Token.UINT16_BE.get(buf, off + 10) / 10000
|
||||
};
|
||||
}
|
||||
};
|
||||
class SimpleTableAtom {
|
||||
constructor(len, token) {
|
||||
this.len = len;
|
||||
this.token = token;
|
||||
}
|
||||
get(buf, off) {
|
||||
const nrOfEntries = Token.INT32_BE.get(buf, off + 4);
|
||||
return {
|
||||
version: Token.INT8.get(buf, off + 0),
|
||||
flags: Token.INT24_BE.get(buf, off + 1),
|
||||
numberOfEntries: nrOfEntries,
|
||||
entries: readTokenTable(buf, this.token, off + 8, this.len - 8, nrOfEntries)
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.TimeToSampleToken = {
|
||||
len: 8,
|
||||
get(buf, off) {
|
||||
return {
|
||||
count: Token.INT32_BE.get(buf, off + 0),
|
||||
duration: Token.INT32_BE.get(buf, off + 4)
|
||||
};
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Time-to-sample('stts') atom.
|
||||
* Store duration information for a media’s samples.
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-25696
|
||||
*/
|
||||
class SttsAtom extends SimpleTableAtom {
|
||||
constructor(len) {
|
||||
super(len, exports.TimeToSampleToken);
|
||||
this.len = len;
|
||||
}
|
||||
}
|
||||
exports.SttsAtom = SttsAtom;
|
||||
exports.SampleToChunkToken = {
|
||||
len: 12,
|
||||
get(buf, off) {
|
||||
return {
|
||||
firstChunk: Token.INT32_BE.get(buf, off),
|
||||
samplesPerChunk: Token.INT32_BE.get(buf, off + 4),
|
||||
sampleDescriptionId: Token.INT32_BE.get(buf, off + 8)
|
||||
};
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Sample-to-Chunk ('stsc') atom interface
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-25706
|
||||
*/
|
||||
class StscAtom extends SimpleTableAtom {
|
||||
constructor(len) {
|
||||
super(len, exports.SampleToChunkToken);
|
||||
this.len = len;
|
||||
}
|
||||
}
|
||||
exports.StscAtom = StscAtom;
|
||||
/**
|
||||
* Sample-size ('stsz') atom
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-25710
|
||||
*/
|
||||
class StszAtom {
|
||||
constructor(len) {
|
||||
this.len = len;
|
||||
}
|
||||
get(buf, off) {
|
||||
const nrOfEntries = Token.INT32_BE.get(buf, off + 8);
|
||||
return {
|
||||
version: Token.INT8.get(buf, off),
|
||||
flags: Token.INT24_BE.get(buf, off + 1),
|
||||
sampleSize: Token.INT32_BE.get(buf, off + 4),
|
||||
numberOfEntries: nrOfEntries,
|
||||
entries: readTokenTable(buf, Token.INT32_BE, off + 12, this.len - 12, nrOfEntries)
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.StszAtom = StszAtom;
|
||||
/**
|
||||
* Chunk offset atom, 'stco'
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-25715
|
||||
*/
|
||||
class StcoAtom extends SimpleTableAtom {
|
||||
constructor(len) {
|
||||
super(len, Token.INT32_BE);
|
||||
this.len = len;
|
||||
}
|
||||
}
|
||||
exports.StcoAtom = StcoAtom;
|
||||
/**
|
||||
* Token used to decode text-track from 'mdat' atom (raw data stream)
|
||||
*/
|
||||
class ChapterText {
|
||||
constructor(len) {
|
||||
this.len = len;
|
||||
}
|
||||
get(buf, off) {
|
||||
const titleLen = Token.INT16_BE.get(buf, off + 0);
|
||||
const str = new Token.StringType(titleLen, 'utf-8');
|
||||
return str.get(buf, off + 2);
|
||||
}
|
||||
}
|
||||
exports.ChapterText = ChapterText;
|
||||
function readTokenTable(buf, token, off, remainingLen, numberOfEntries) {
|
||||
debug(`remainingLen=${remainingLen}, numberOfEntries=${numberOfEntries} * token-len=${token.len}`);
|
||||
if (remainingLen === 0)
|
||||
return [];
|
||||
if (remainingLen !== numberOfEntries * token.len)
|
||||
throw new Error('mismatch number-of-entries with remaining atom-length');
|
||||
const entries = [];
|
||||
// parse offset-table
|
||||
for (let n = 0; n < numberOfEntries; ++n) {
|
||||
entries.push(token.get(buf, off));
|
||||
off += token.len;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { BasicParser } from '../common/BasicParser';
|
||||
import { Atom } from './Atom';
|
||||
export declare class MP4Parser extends BasicParser {
|
||||
private static read_BE_Integer;
|
||||
private audioLengthInBytes;
|
||||
private tracks;
|
||||
parse(): Promise<void>;
|
||||
handleAtom(atom: Atom, remaining: number): Promise<void>;
|
||||
private getTrackDescription;
|
||||
private calculateBitRate;
|
||||
private addTag;
|
||||
private addWarning;
|
||||
/**
|
||||
* Parse data of Meta-item-list-atom (item of 'ilst' atom)
|
||||
* @param metaAtom
|
||||
* Ref: https://developer.apple.com/library/content/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW8
|
||||
*/
|
||||
private parseMetadataItemData;
|
||||
private parseValueAtom;
|
||||
private atomParsers;
|
||||
/**
|
||||
* @param sampleDescription
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html#//apple_ref/doc/uid/TP40000939-CH205-128916
|
||||
*/
|
||||
private parseSoundSampleDescription;
|
||||
private parseChapterTrack;
|
||||
private findSampleOffset;
|
||||
private getChunkDuration;
|
||||
private getSamplesPerChunk;
|
||||
}
|
||||
+511
@@ -0,0 +1,511 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.MP4Parser = void 0;
|
||||
const initDebug = require("debug");
|
||||
const Token = require("token-types");
|
||||
const BasicParser_1 = require("../common/BasicParser");
|
||||
const Atom_1 = require("./Atom");
|
||||
const AtomToken = require("./AtomToken");
|
||||
const ID3v1Parser_1 = require("../id3v1/ID3v1Parser");
|
||||
const type_1 = require("../type");
|
||||
const debug = initDebug('music-metadata:parser:MP4');
|
||||
const tagFormat = 'iTunes';
|
||||
const encoderDict = {
|
||||
raw: {
|
||||
lossy: false,
|
||||
format: 'raw'
|
||||
},
|
||||
MAC3: {
|
||||
lossy: true,
|
||||
format: 'MACE 3:1'
|
||||
},
|
||||
MAC6: {
|
||||
lossy: true,
|
||||
format: 'MACE 6:1'
|
||||
},
|
||||
ima4: {
|
||||
lossy: true,
|
||||
format: 'IMA 4:1'
|
||||
},
|
||||
ulaw: {
|
||||
lossy: true,
|
||||
format: 'uLaw 2:1'
|
||||
},
|
||||
alaw: {
|
||||
lossy: true,
|
||||
format: 'uLaw 2:1'
|
||||
},
|
||||
Qclp: {
|
||||
lossy: true,
|
||||
format: 'QUALCOMM PureVoice'
|
||||
},
|
||||
'.mp3': {
|
||||
lossy: true,
|
||||
format: 'MPEG-1 layer 3'
|
||||
},
|
||||
alac: {
|
||||
lossy: false,
|
||||
format: 'ALAC'
|
||||
},
|
||||
'ac-3': {
|
||||
lossy: true,
|
||||
format: 'AC-3'
|
||||
},
|
||||
mp4a: {
|
||||
lossy: true,
|
||||
format: 'MPEG-4/AAC'
|
||||
},
|
||||
mp4s: {
|
||||
lossy: true,
|
||||
format: 'MP4S'
|
||||
},
|
||||
// Closed Captioning Media, https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html#//apple_ref/doc/uid/TP40000939-CH205-SW87
|
||||
c608: {
|
||||
lossy: true,
|
||||
format: 'CEA-608'
|
||||
},
|
||||
c708: {
|
||||
lossy: true,
|
||||
format: 'CEA-708'
|
||||
}
|
||||
};
|
||||
function distinct(value, index, self) {
|
||||
return self.indexOf(value) === index;
|
||||
}
|
||||
/*
|
||||
* Parser for the MP4 (MPEG-4 Part 14) container format
|
||||
* Standard: ISO/IEC 14496-14
|
||||
* supporting:
|
||||
* - QuickTime container
|
||||
* - MP4 File Format
|
||||
* - 3GPP file format
|
||||
* - 3GPP2 file format
|
||||
*
|
||||
* MPEG-4 Audio / Part 3 (.m4a)& MPEG 4 Video (m4v, mp4) extension.
|
||||
* Support for Apple iTunes tags as found in a M4A/M4V files.
|
||||
* Ref:
|
||||
* https://en.wikipedia.org/wiki/ISO_base_media_file_format
|
||||
* https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/Metadata/Metadata.html
|
||||
* http://atomicparsley.sourceforge.net/mpeg-4files.html
|
||||
* https://github.com/sergiomb2/libmp4v2/wiki/iTunesMetadata
|
||||
* https://wiki.multimedia.cx/index.php/QuickTime_container
|
||||
*/
|
||||
class MP4Parser extends BasicParser_1.BasicParser {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.atomParsers = {
|
||||
/**
|
||||
* Parse movie header (mvhd) atom
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-56313
|
||||
*/
|
||||
mvhd: async (len) => {
|
||||
const _mvhd = await this.tokenizer.readToken(new AtomToken.MvhdAtom(len));
|
||||
this.metadata.setFormat('creationTime', _mvhd.creationTime);
|
||||
this.metadata.setFormat('modificationTime', _mvhd.modificationTime);
|
||||
},
|
||||
/**
|
||||
* Parse media header (mdhd) atom
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-25615
|
||||
*/
|
||||
mdhd: async (len) => {
|
||||
const mdhd_data = await this.tokenizer.readToken(new AtomToken.MdhdAtom(len));
|
||||
// this.parse_mxhd(mdhd_data, this.currentTrack);
|
||||
const td = this.getTrackDescription();
|
||||
td.creationTime = mdhd_data.creationTime;
|
||||
td.modificationTime = mdhd_data.modificationTime;
|
||||
td.timeScale = mdhd_data.timeScale;
|
||||
td.duration = mdhd_data.duration;
|
||||
},
|
||||
chap: async (len) => {
|
||||
const td = this.getTrackDescription();
|
||||
const trackIds = [];
|
||||
while (len >= Token.UINT32_BE.len) {
|
||||
trackIds.push(await this.tokenizer.readNumber(Token.UINT32_BE));
|
||||
len -= Token.UINT32_BE.len;
|
||||
}
|
||||
td.chapterList = trackIds;
|
||||
},
|
||||
tkhd: async (len) => {
|
||||
const track = (await this.tokenizer.readToken(new AtomToken.TrackHeaderAtom(len)));
|
||||
this.tracks.push(track);
|
||||
},
|
||||
/**
|
||||
* Parse mdat atom.
|
||||
* Will scan for chapters
|
||||
*/
|
||||
mdat: async (len) => {
|
||||
this.audioLengthInBytes = len;
|
||||
this.calculateBitRate();
|
||||
if (this.options.includeChapters) {
|
||||
const trackWithChapters = this.tracks.filter(track => track.chapterList);
|
||||
if (trackWithChapters.length === 1) {
|
||||
const chapterTrackIds = trackWithChapters[0].chapterList;
|
||||
const chapterTracks = this.tracks.filter(track => chapterTrackIds.indexOf(track.trackId) !== -1);
|
||||
if (chapterTracks.length === 1) {
|
||||
return this.parseChapterTrack(chapterTracks[0], trackWithChapters[0], len);
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.tokenizer.ignore(len);
|
||||
},
|
||||
ftyp: async (len) => {
|
||||
const types = [];
|
||||
while (len > 0) {
|
||||
const ftype = await this.tokenizer.readToken(AtomToken.ftyp);
|
||||
len -= AtomToken.ftyp.len;
|
||||
const value = ftype.type.replace(/\W/g, '');
|
||||
if (value.length > 0) {
|
||||
types.push(value); // unshift for backward compatibility
|
||||
}
|
||||
}
|
||||
debug(`ftyp: ${types.join('/')}`);
|
||||
const x = types.filter(distinct).join('/');
|
||||
this.metadata.setFormat('container', x);
|
||||
},
|
||||
/**
|
||||
* Parse sample description atom
|
||||
*/
|
||||
stsd: async (len) => {
|
||||
const stsd = await this.tokenizer.readToken(new AtomToken.StsdAtom(len));
|
||||
const trackDescription = this.getTrackDescription();
|
||||
trackDescription.soundSampleDescription = stsd.table.map(dfEntry => this.parseSoundSampleDescription(dfEntry));
|
||||
},
|
||||
/**
|
||||
* sample-to-Chunk Atoms
|
||||
*/
|
||||
stsc: async (len) => {
|
||||
const stsc = await this.tokenizer.readToken(new AtomToken.StscAtom(len));
|
||||
this.getTrackDescription().sampleToChunkTable = stsc.entries;
|
||||
},
|
||||
/**
|
||||
* time to sample
|
||||
*/
|
||||
stts: async (len) => {
|
||||
const stts = await this.tokenizer.readToken(new AtomToken.SttsAtom(len));
|
||||
this.getTrackDescription().timeToSampleTable = stts.entries;
|
||||
},
|
||||
/**
|
||||
* Parse sample-sizes atom ('stsz')
|
||||
*/
|
||||
stsz: async (len) => {
|
||||
const stsz = await this.tokenizer.readToken(new AtomToken.StszAtom(len));
|
||||
const td = this.getTrackDescription();
|
||||
td.sampleSize = stsz.sampleSize;
|
||||
td.sampleSizeTable = stsz.entries;
|
||||
},
|
||||
/**
|
||||
* Parse chunk-offset atom ('stco')
|
||||
*/
|
||||
stco: async (len) => {
|
||||
const stco = await this.tokenizer.readToken(new AtomToken.StcoAtom(len));
|
||||
this.getTrackDescription().chunkOffsetTable = stco.entries; // remember chunk offsets
|
||||
},
|
||||
date: async (len) => {
|
||||
const date = await this.tokenizer.readToken(new Token.StringType(len, 'utf-8'));
|
||||
this.addTag('date', date);
|
||||
}
|
||||
};
|
||||
}
|
||||
static read_BE_Integer(array, signed) {
|
||||
const integerType = (signed ? 'INT' : 'UINT') + array.length * 8 + (array.length > 1 ? '_BE' : '');
|
||||
const token = Token[integerType];
|
||||
if (!token) {
|
||||
throw new Error('Token for integer type not found: "' + integerType + '"');
|
||||
}
|
||||
return Number(token.get(array, 0));
|
||||
}
|
||||
async parse() {
|
||||
this.tracks = [];
|
||||
let remainingFileSize = this.tokenizer.fileInfo.size;
|
||||
while (!this.tokenizer.fileInfo.size || remainingFileSize > 0) {
|
||||
try {
|
||||
const token = await this.tokenizer.peekToken(AtomToken.Header);
|
||||
if (token.name === '\0\0\0\0') {
|
||||
const errMsg = `Error at offset=${this.tokenizer.position}: box.id=0`;
|
||||
debug(errMsg);
|
||||
this.addWarning(errMsg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const errMsg = `Error at offset=${this.tokenizer.position}: ${error.message}`;
|
||||
debug(errMsg);
|
||||
this.addWarning(errMsg);
|
||||
break;
|
||||
}
|
||||
const rootAtom = await Atom_1.Atom.readAtom(this.tokenizer, (atom, remaining) => this.handleAtom(atom, remaining), null, remainingFileSize);
|
||||
remainingFileSize -= rootAtom.header.length === BigInt(0) ? remainingFileSize : Number(rootAtom.header.length);
|
||||
}
|
||||
// Post process metadata
|
||||
const formatList = [];
|
||||
this.tracks.forEach(track => {
|
||||
const trackFormats = [];
|
||||
track.soundSampleDescription.forEach(ssd => {
|
||||
const streamInfo = {};
|
||||
const encoderInfo = encoderDict[ssd.dataFormat];
|
||||
if (encoderInfo) {
|
||||
trackFormats.push(encoderInfo.format);
|
||||
streamInfo.codecName = encoderInfo.format;
|
||||
}
|
||||
else {
|
||||
streamInfo.codecName = `<${ssd.dataFormat}>`;
|
||||
}
|
||||
if (ssd.description) {
|
||||
const { description } = ssd;
|
||||
if (description.sampleRate > 0) {
|
||||
streamInfo.type = type_1.TrackType.audio;
|
||||
streamInfo.audio = {
|
||||
samplingFrequency: description.sampleRate,
|
||||
bitDepth: description.sampleSize,
|
||||
channels: description.numAudioChannels
|
||||
};
|
||||
}
|
||||
}
|
||||
this.metadata.addStreamInfo(streamInfo);
|
||||
});
|
||||
if (trackFormats.length >= 1) {
|
||||
formatList.push(trackFormats.join('/'));
|
||||
}
|
||||
});
|
||||
if (formatList.length > 0) {
|
||||
this.metadata.setFormat('codec', formatList.filter(distinct).join('+'));
|
||||
}
|
||||
const audioTracks = this.tracks.filter(track => {
|
||||
return track.soundSampleDescription.length >= 1 && track.soundSampleDescription[0].description && track.soundSampleDescription[0].description.numAudioChannels > 0;
|
||||
});
|
||||
if (audioTracks.length >= 1) {
|
||||
const audioTrack = audioTracks[0];
|
||||
const duration = audioTrack.duration / audioTrack.timeScale;
|
||||
this.metadata.setFormat('duration', duration); // calculate duration in seconds
|
||||
const ssd = audioTrack.soundSampleDescription[0];
|
||||
if (ssd.description) {
|
||||
this.metadata.setFormat('sampleRate', ssd.description.sampleRate);
|
||||
this.metadata.setFormat('bitsPerSample', ssd.description.sampleSize);
|
||||
this.metadata.setFormat('numberOfChannels', ssd.description.numAudioChannels);
|
||||
}
|
||||
const encoderInfo = encoderDict[ssd.dataFormat];
|
||||
if (encoderInfo) {
|
||||
this.metadata.setFormat('lossless', !encoderInfo.lossy);
|
||||
}
|
||||
this.calculateBitRate();
|
||||
}
|
||||
}
|
||||
async handleAtom(atom, remaining) {
|
||||
if (atom.parent) {
|
||||
switch (atom.parent.header.name) {
|
||||
case 'ilst':
|
||||
case '<id>':
|
||||
return this.parseMetadataItemData(atom);
|
||||
}
|
||||
}
|
||||
// const payloadLength = atom.getPayloadLength(remaining);
|
||||
if (this.atomParsers[atom.header.name]) {
|
||||
return this.atomParsers[atom.header.name](remaining);
|
||||
}
|
||||
else {
|
||||
debug(`No parser for atom path=${atom.atomPath}, payload-len=${remaining}, ignoring atom`);
|
||||
await this.tokenizer.ignore(remaining);
|
||||
}
|
||||
}
|
||||
getTrackDescription() {
|
||||
return this.tracks[this.tracks.length - 1];
|
||||
}
|
||||
calculateBitRate() {
|
||||
if (this.audioLengthInBytes && this.metadata.format.duration) {
|
||||
this.metadata.setFormat('bitrate', 8 * this.audioLengthInBytes / this.metadata.format.duration);
|
||||
}
|
||||
}
|
||||
addTag(id, value) {
|
||||
this.metadata.addTag(tagFormat, id, value);
|
||||
}
|
||||
addWarning(message) {
|
||||
debug('Warning: ' + message);
|
||||
this.metadata.addWarning(message);
|
||||
}
|
||||
/**
|
||||
* Parse data of Meta-item-list-atom (item of 'ilst' atom)
|
||||
* @param metaAtom
|
||||
* Ref: https://developer.apple.com/library/content/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW8
|
||||
*/
|
||||
parseMetadataItemData(metaAtom) {
|
||||
let tagKey = metaAtom.header.name;
|
||||
return metaAtom.readAtoms(this.tokenizer, async (child, remaining) => {
|
||||
const payLoadLength = child.getPayloadLength(remaining);
|
||||
switch (child.header.name) {
|
||||
case 'data': // value atom
|
||||
return this.parseValueAtom(tagKey, child);
|
||||
case 'name': // name atom (optional)
|
||||
const name = await this.tokenizer.readToken(new AtomToken.NameAtom(payLoadLength));
|
||||
tagKey += ':' + name.name;
|
||||
break;
|
||||
case 'mean': // name atom (optional)
|
||||
const mean = await this.tokenizer.readToken(new AtomToken.NameAtom(payLoadLength));
|
||||
// console.log(" %s[%s] = %s", tagKey, header.name, mean.name);
|
||||
tagKey += ':' + mean.name;
|
||||
break;
|
||||
default:
|
||||
const dataAtom = await this.tokenizer.readToken(new Token.BufferType(payLoadLength));
|
||||
this.addWarning('Unsupported meta-item: ' + tagKey + '[' + child.header.name + '] => value=' + dataAtom.toString('hex') + ' ascii=' + dataAtom.toString('ascii'));
|
||||
}
|
||||
}, metaAtom.getPayloadLength(0));
|
||||
}
|
||||
async parseValueAtom(tagKey, metaAtom) {
|
||||
const dataAtom = await this.tokenizer.readToken(new AtomToken.DataAtom(Number(metaAtom.header.length) - AtomToken.Header.len));
|
||||
if (dataAtom.type.set !== 0) {
|
||||
throw new Error('Unsupported type-set != 0: ' + dataAtom.type.set);
|
||||
}
|
||||
// Use well-known-type table
|
||||
// Ref: https://developer.apple.com/library/content/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW35
|
||||
switch (dataAtom.type.type) {
|
||||
case 0: // reserved: Reserved for use where no type needs to be indicated
|
||||
switch (tagKey) {
|
||||
case 'trkn':
|
||||
case 'disk':
|
||||
const num = Token.UINT8.get(dataAtom.value, 3);
|
||||
const of = Token.UINT8.get(dataAtom.value, 5);
|
||||
// console.log(" %s[data] = %s/%s", tagKey, num, of);
|
||||
this.addTag(tagKey, num + '/' + of);
|
||||
break;
|
||||
case 'gnre':
|
||||
const genreInt = Token.UINT8.get(dataAtom.value, 1);
|
||||
const genreStr = ID3v1Parser_1.Genres[genreInt - 1];
|
||||
// console.log(" %s[data] = %s", tagKey, genreStr);
|
||||
this.addTag(tagKey, genreStr);
|
||||
break;
|
||||
default:
|
||||
// console.log(" reserved-data: name=%s, len=%s, set=%s, type=%s, locale=%s, value{ hex=%s, ascii=%s }",
|
||||
// header.name, header.length, dataAtom.type.set, dataAtom.type.type, dataAtom.locale, dataAtom.value.toString('hex'), dataAtom.value.toString('ascii'));
|
||||
}
|
||||
break;
|
||||
case 1: // UTF-8: Without any count or NULL terminator
|
||||
case 18: // Unknown: Found in m4b in combination with a '©gen' tag
|
||||
this.addTag(tagKey, dataAtom.value.toString('utf-8'));
|
||||
break;
|
||||
case 13: // JPEG
|
||||
if (this.options.skipCovers)
|
||||
break;
|
||||
this.addTag(tagKey, {
|
||||
format: 'image/jpeg',
|
||||
data: Buffer.from(dataAtom.value)
|
||||
});
|
||||
break;
|
||||
case 14: // PNG
|
||||
if (this.options.skipCovers)
|
||||
break;
|
||||
this.addTag(tagKey, {
|
||||
format: 'image/png',
|
||||
data: Buffer.from(dataAtom.value)
|
||||
});
|
||||
break;
|
||||
case 21: // BE Signed Integer
|
||||
this.addTag(tagKey, MP4Parser.read_BE_Integer(dataAtom.value, true));
|
||||
break;
|
||||
case 22: // BE Unsigned Integer
|
||||
this.addTag(tagKey, MP4Parser.read_BE_Integer(dataAtom.value, false));
|
||||
break;
|
||||
case 65: // An 8-bit signed integer
|
||||
this.addTag(tagKey, dataAtom.value.readInt8(0));
|
||||
break;
|
||||
case 66: // A big-endian 16-bit signed integer
|
||||
this.addTag(tagKey, dataAtom.value.readInt16BE(0));
|
||||
break;
|
||||
case 67: // A big-endian 32-bit signed integer
|
||||
this.addTag(tagKey, dataAtom.value.readInt32BE(0));
|
||||
break;
|
||||
default:
|
||||
this.addWarning(`atom key=${tagKey}, has unknown well-known-type (data-type): ${dataAtom.type.type}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param sampleDescription
|
||||
* Ref: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html#//apple_ref/doc/uid/TP40000939-CH205-128916
|
||||
*/
|
||||
parseSoundSampleDescription(sampleDescription) {
|
||||
const ssd = {
|
||||
dataFormat: sampleDescription.dataFormat,
|
||||
dataReferenceIndex: sampleDescription.dataReferenceIndex
|
||||
};
|
||||
let offset = 0;
|
||||
const version = AtomToken.SoundSampleDescriptionVersion.get(sampleDescription.description, offset);
|
||||
offset += AtomToken.SoundSampleDescriptionVersion.len;
|
||||
if (version.version === 0 || version.version === 1) {
|
||||
// Sound Sample Description (Version 0)
|
||||
ssd.description = AtomToken.SoundSampleDescriptionV0.get(sampleDescription.description, offset);
|
||||
}
|
||||
else {
|
||||
debug(`Warning: sound-sample-description ${version} not implemented`);
|
||||
}
|
||||
return ssd;
|
||||
}
|
||||
async parseChapterTrack(chapterTrack, track, len) {
|
||||
if (!chapterTrack.sampleSize) {
|
||||
if (chapterTrack.chunkOffsetTable.length !== chapterTrack.sampleSizeTable.length)
|
||||
throw new Error('Expected equal chunk-offset-table & sample-size-table length.');
|
||||
}
|
||||
const chapters = [];
|
||||
for (let i = 0; i < chapterTrack.chunkOffsetTable.length && len > 0; ++i) {
|
||||
const chunkOffset = chapterTrack.chunkOffsetTable[i];
|
||||
const nextChunkLen = chunkOffset - this.tokenizer.position;
|
||||
const sampleSize = chapterTrack.sampleSize > 0 ? chapterTrack.sampleSize : chapterTrack.sampleSizeTable[i];
|
||||
len -= nextChunkLen + sampleSize;
|
||||
if (len < 0)
|
||||
throw new Error('Chapter chunk exceeding token length');
|
||||
await this.tokenizer.ignore(nextChunkLen);
|
||||
const title = await this.tokenizer.readToken(new AtomToken.ChapterText(sampleSize));
|
||||
debug(`Chapter ${i + 1}: ${title}`);
|
||||
const chapter = {
|
||||
title,
|
||||
sampleOffset: this.findSampleOffset(track, this.tokenizer.position)
|
||||
};
|
||||
debug(`Chapter title=${chapter.title}, offset=${chapter.sampleOffset}/${this.tracks[0].duration}`);
|
||||
chapters.push(chapter);
|
||||
}
|
||||
this.metadata.setFormat('chapters', chapters);
|
||||
await this.tokenizer.ignore(len);
|
||||
}
|
||||
findSampleOffset(track, chapterOffset) {
|
||||
let totalDuration = 0;
|
||||
track.timeToSampleTable.forEach(e => {
|
||||
totalDuration += e.count * e.duration;
|
||||
});
|
||||
debug(`Total duration=${totalDuration}`);
|
||||
let chunkIndex = 0;
|
||||
while (chunkIndex < track.chunkOffsetTable.length && track.chunkOffsetTable[chunkIndex] < chapterOffset) {
|
||||
++chunkIndex;
|
||||
}
|
||||
return this.getChunkDuration(chunkIndex + 1, track);
|
||||
}
|
||||
getChunkDuration(chunkId, track) {
|
||||
let ttsi = 0;
|
||||
let ttsc = track.timeToSampleTable[ttsi].count;
|
||||
let ttsd = track.timeToSampleTable[ttsi].duration;
|
||||
let curChunkId = 1;
|
||||
let samplesPerChunk = this.getSamplesPerChunk(curChunkId, track.sampleToChunkTable);
|
||||
let totalDuration = 0;
|
||||
while (curChunkId < chunkId) {
|
||||
const nrOfSamples = Math.min(ttsc, samplesPerChunk);
|
||||
totalDuration += nrOfSamples * ttsd;
|
||||
ttsc -= nrOfSamples;
|
||||
samplesPerChunk -= nrOfSamples;
|
||||
if (samplesPerChunk === 0) {
|
||||
++curChunkId;
|
||||
samplesPerChunk = this.getSamplesPerChunk(curChunkId, track.sampleToChunkTable);
|
||||
}
|
||||
else {
|
||||
++ttsi;
|
||||
ttsc = track.timeToSampleTable[ttsi].count;
|
||||
ttsd = track.timeToSampleTable[ttsi].duration;
|
||||
}
|
||||
}
|
||||
return totalDuration;
|
||||
}
|
||||
getSamplesPerChunk(chunkId, stcTable) {
|
||||
for (let i = 0; i < stcTable.length - 1; ++i) {
|
||||
if (chunkId >= stcTable[i].firstChunk && chunkId < stcTable[i + 1].firstChunk) {
|
||||
return stcTable[i].samplesPerChunk;
|
||||
}
|
||||
}
|
||||
return stcTable[stcTable.length - 1].samplesPerChunk;
|
||||
}
|
||||
}
|
||||
exports.MP4Parser = MP4Parser;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { CaseInsensitiveTagMap } from '../common/CaseInsensitiveTagMap';
|
||||
export declare const tagType = "iTunes";
|
||||
export declare class MP4TagMapper extends CaseInsensitiveTagMap {
|
||||
constructor();
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.MP4TagMapper = exports.tagType = void 0;
|
||||
const CaseInsensitiveTagMap_1 = require("../common/CaseInsensitiveTagMap");
|
||||
/**
|
||||
* Ref: https://github.com/sergiomb2/libmp4v2/wiki/iTunesMetadata
|
||||
*/
|
||||
const mp4TagMap = {
|
||||
'©nam': 'title',
|
||||
'©ART': 'artist',
|
||||
aART: 'albumartist',
|
||||
/**
|
||||
* ToDo: Album artist seems to be stored here while Picard documentation says: aART
|
||||
*/
|
||||
'----:com.apple.iTunes:Band': 'albumartist',
|
||||
'©alb': 'album',
|
||||
'©day': 'date',
|
||||
'©cmt': 'comment',
|
||||
'©com': 'comment',
|
||||
trkn: 'track',
|
||||
disk: 'disk',
|
||||
'©gen': 'genre',
|
||||
covr: 'picture',
|
||||
'©wrt': 'composer',
|
||||
'©lyr': 'lyrics',
|
||||
soal: 'albumsort',
|
||||
sonm: 'titlesort',
|
||||
soar: 'artistsort',
|
||||
soaa: 'albumartistsort',
|
||||
soco: 'composersort',
|
||||
'----:com.apple.iTunes:LYRICIST': 'lyricist',
|
||||
'----:com.apple.iTunes:CONDUCTOR': 'conductor',
|
||||
'----:com.apple.iTunes:REMIXER': 'remixer',
|
||||
'----:com.apple.iTunes:ENGINEER': 'engineer',
|
||||
'----:com.apple.iTunes:PRODUCER': 'producer',
|
||||
'----:com.apple.iTunes:DJMIXER': 'djmixer',
|
||||
'----:com.apple.iTunes:MIXER': 'mixer',
|
||||
'----:com.apple.iTunes:LABEL': 'label',
|
||||
'©grp': 'grouping',
|
||||
'----:com.apple.iTunes:SUBTITLE': 'subtitle',
|
||||
'----:com.apple.iTunes:DISCSUBTITLE': 'discsubtitle',
|
||||
cpil: 'compilation',
|
||||
tmpo: 'bpm',
|
||||
'----:com.apple.iTunes:MOOD': 'mood',
|
||||
'----:com.apple.iTunes:MEDIA': 'media',
|
||||
'----:com.apple.iTunes:CATALOGNUMBER': 'catalognumber',
|
||||
tvsh: 'tvShow',
|
||||
tvsn: 'tvSeason',
|
||||
tves: 'tvEpisode',
|
||||
sosn: 'tvShowSort',
|
||||
tven: 'tvEpisodeId',
|
||||
tvnn: 'tvNetwork',
|
||||
pcst: 'podcast',
|
||||
purl: 'podcasturl',
|
||||
'----:com.apple.iTunes:MusicBrainz Album Status': 'releasestatus',
|
||||
'----:com.apple.iTunes:MusicBrainz Album Type': 'releasetype',
|
||||
'----:com.apple.iTunes:MusicBrainz Album Release Country': 'releasecountry',
|
||||
'----:com.apple.iTunes:SCRIPT': 'script',
|
||||
'----:com.apple.iTunes:LANGUAGE': 'language',
|
||||
cprt: 'copyright',
|
||||
'©cpy': 'copyright',
|
||||
'----:com.apple.iTunes:LICENSE': 'license',
|
||||
'©too': 'encodedby',
|
||||
pgap: 'gapless',
|
||||
'----:com.apple.iTunes:BARCODE': 'barcode',
|
||||
'----:com.apple.iTunes:ISRC': 'isrc',
|
||||
'----:com.apple.iTunes:ASIN': 'asin',
|
||||
'----:com.apple.iTunes:NOTES': 'comment',
|
||||
'----:com.apple.iTunes:MusicBrainz Track Id': 'musicbrainz_recordingid',
|
||||
'----:com.apple.iTunes:MusicBrainz Release Track Id': 'musicbrainz_trackid',
|
||||
'----:com.apple.iTunes:MusicBrainz Album Id': 'musicbrainz_albumid',
|
||||
'----:com.apple.iTunes:MusicBrainz Artist Id': 'musicbrainz_artistid',
|
||||
'----:com.apple.iTunes:MusicBrainz Album Artist Id': 'musicbrainz_albumartistid',
|
||||
'----:com.apple.iTunes:MusicBrainz Release Group Id': 'musicbrainz_releasegroupid',
|
||||
'----:com.apple.iTunes:MusicBrainz Work Id': 'musicbrainz_workid',
|
||||
'----:com.apple.iTunes:MusicBrainz TRM Id': 'musicbrainz_trmid',
|
||||
'----:com.apple.iTunes:MusicBrainz Disc Id': 'musicbrainz_discid',
|
||||
'----:com.apple.iTunes:Acoustid Id': 'acoustid_id',
|
||||
'----:com.apple.iTunes:Acoustid Fingerprint': 'acoustid_fingerprint',
|
||||
'----:com.apple.iTunes:MusicIP PUID': 'musicip_puid',
|
||||
'----:com.apple.iTunes:fingerprint': 'musicip_fingerprint',
|
||||
'----:com.apple.iTunes:replaygain_track_gain': 'replaygain_track_gain',
|
||||
'----:com.apple.iTunes:replaygain_track_peak': 'replaygain_track_peak',
|
||||
'----:com.apple.iTunes:replaygain_album_gain': 'replaygain_album_gain',
|
||||
'----:com.apple.iTunes:replaygain_album_peak': 'replaygain_album_peak',
|
||||
'----:com.apple.iTunes:replaygain_track_minmax': 'replaygain_track_minmax',
|
||||
'----:com.apple.iTunes:replaygain_album_minmax': 'replaygain_album_minmax',
|
||||
'----:com.apple.iTunes:replaygain_undo': 'replaygain_undo',
|
||||
// Additional mappings:
|
||||
gnre: 'genre',
|
||||
'----:com.apple.iTunes:ALBUMARTISTSORT': 'albumartistsort',
|
||||
'----:com.apple.iTunes:ARTISTS': 'artists',
|
||||
'----:com.apple.iTunes:ORIGINALDATE': 'originaldate',
|
||||
'----:com.apple.iTunes:ORIGINALYEAR': 'originalyear',
|
||||
// '----:com.apple.iTunes:PERFORMER': 'performer'
|
||||
desc: 'description',
|
||||
ldes: 'longDescription',
|
||||
'©mvn': 'movement',
|
||||
'©mvi': 'movementIndex',
|
||||
'©mvc': 'movementTotal',
|
||||
'©wrk': 'work',
|
||||
catg: 'category',
|
||||
egid: 'podcastId',
|
||||
hdvd: 'hdVideo',
|
||||
keyw: 'keywords',
|
||||
shwm: 'showMovement',
|
||||
stik: 'stik'
|
||||
};
|
||||
exports.tagType = 'iTunes';
|
||||
class MP4TagMapper extends CaseInsensitiveTagMap_1.CaseInsensitiveTagMap {
|
||||
constructor() {
|
||||
super([exports.tagType], mp4TagMap);
|
||||
}
|
||||
}
|
||||
exports.MP4TagMapper = MP4TagMapper;
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Extended Lame Header
|
||||
*/
|
||||
import { IGetToken } from 'strtok3/lib/core';
|
||||
import { IReplayGain } from './ReplayGainDataFormat';
|
||||
/**
|
||||
* LAME Tag, extends the Xing header format
|
||||
* First added in LAME 3.12 for VBR
|
||||
* The modified header is also included in CBR files (effective LAME 3.94), with "Info" instead of "XING" near the beginning.
|
||||
*/
|
||||
export interface IExtendedLameHeader {
|
||||
revision: number;
|
||||
vbr_method: number;
|
||||
lowpass_filter: number;
|
||||
track_peak?: number;
|
||||
track_gain: IReplayGain;
|
||||
album_gain: IReplayGain;
|
||||
music_length: number;
|
||||
music_crc: number;
|
||||
header_crc: number;
|
||||
}
|
||||
/**
|
||||
* Info Tag
|
||||
* @link http://gabriel.mp3-tech.org/mp3infotag.html
|
||||
* @link https://github.com/quodlibet/mutagen/blob/abd58ee58772224334a18817c3fb31103572f70e/mutagen/mp3/_util.py#L112
|
||||
*/
|
||||
export declare const ExtendedLameHeader: IGetToken<IExtendedLameHeader>;
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Extended Lame Header
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ExtendedLameHeader = void 0;
|
||||
const Token = require("token-types");
|
||||
const common = require("../common/Util");
|
||||
const ReplayGainDataFormat_1 = require("./ReplayGainDataFormat");
|
||||
/**
|
||||
* Info Tag
|
||||
* @link http://gabriel.mp3-tech.org/mp3infotag.html
|
||||
* @link https://github.com/quodlibet/mutagen/blob/abd58ee58772224334a18817c3fb31103572f70e/mutagen/mp3/_util.py#L112
|
||||
*/
|
||||
exports.ExtendedLameHeader = {
|
||||
len: 27,
|
||||
get: (buf, off) => {
|
||||
const track_peak = Token.UINT32_BE.get(buf, off + 2);
|
||||
return {
|
||||
revision: common.getBitAllignedNumber(buf, off, 0, 4),
|
||||
vbr_method: common.getBitAllignedNumber(buf, off, 4, 4),
|
||||
lowpass_filter: 100 * Token.UINT8.get(buf, off + 1),
|
||||
track_peak: track_peak === 0 ? undefined : track_peak / Math.pow(2, 23),
|
||||
track_gain: ReplayGainDataFormat_1.ReplayGain.get(buf, 6),
|
||||
album_gain: ReplayGainDataFormat_1.ReplayGain.get(buf, 8),
|
||||
music_length: Token.UINT32_BE.get(buf, off + 20),
|
||||
music_crc: Token.UINT8.get(buf, off + 24),
|
||||
header_crc: Token.UINT16_BE.get(buf, off + 24)
|
||||
};
|
||||
}
|
||||
};
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { AbstractID3Parser } from '../id3v2/AbstractID3Parser';
|
||||
export declare class MpegParser extends AbstractID3Parser {
|
||||
private frameCount;
|
||||
private syncFrameCount;
|
||||
private countSkipFrameData;
|
||||
private totalDataLength;
|
||||
private audioFrameHeader;
|
||||
private bitrates;
|
||||
private offset;
|
||||
private frame_size;
|
||||
private crc;
|
||||
private calculateEofDuration;
|
||||
private samplesPerFrame;
|
||||
private buf_frame_header;
|
||||
/**
|
||||
* Number of bytes already parsed since beginning of stream / file
|
||||
*/
|
||||
private mpegOffset;
|
||||
private syncPeek;
|
||||
/**
|
||||
* Called after ID3 headers have been parsed
|
||||
*/
|
||||
_parse(): Promise<void>;
|
||||
/**
|
||||
* Called after file has been fully parsed, this allows, if present, to exclude the ID3v1.1 header length
|
||||
*/
|
||||
protected finalize(): void;
|
||||
private sync;
|
||||
/**
|
||||
* Combined ADTS & MPEG (MP2 & MP3) header handling
|
||||
* @return {Promise<boolean>} true if parser should quit
|
||||
*/
|
||||
private parseCommonMpegHeader;
|
||||
/**
|
||||
* @return {Promise<boolean>} true if parser should quit
|
||||
*/
|
||||
private parseAudioFrameHeader;
|
||||
private parseAdts;
|
||||
private parseCrc;
|
||||
private skipSideInformation;
|
||||
private readXtraInfoHeader;
|
||||
/**
|
||||
* Ref: http://gabriel.mp3-tech.org/mp3infotag.html
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
private readXingInfoHeader;
|
||||
private skipFrameData;
|
||||
private areAllSame;
|
||||
}
|
||||
+529
@@ -0,0 +1,529 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.MpegParser = void 0;
|
||||
const Token = require("token-types");
|
||||
const core_1 = require("strtok3/lib/core");
|
||||
const initDebug = require("debug");
|
||||
const common = require("../common/Util");
|
||||
const AbstractID3Parser_1 = require("../id3v2/AbstractID3Parser");
|
||||
const XingTag_1 = require("./XingTag");
|
||||
const debug = initDebug('music-metadata:parser:mpeg');
|
||||
/**
|
||||
* Cache buffer size used for searching synchronization preabmle
|
||||
*/
|
||||
const maxPeekLen = 1024;
|
||||
/**
|
||||
* MPEG-4 Audio definitions
|
||||
* Ref: https://wiki.multimedia.cx/index.php/MPEG-4_Audio
|
||||
*/
|
||||
const MPEG4 = {
|
||||
/**
|
||||
* Audio Object Types
|
||||
*/
|
||||
AudioObjectTypes: [
|
||||
'AAC Main',
|
||||
'AAC LC',
|
||||
'AAC SSR',
|
||||
'AAC LTP' // Long Term Prediction
|
||||
],
|
||||
/**
|
||||
* Sampling Frequencies
|
||||
* https://wiki.multimedia.cx/index.php/MPEG-4_Audio#Sampling_Frequencies
|
||||
*/
|
||||
SamplingFrequencies: [
|
||||
96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350, undefined, undefined, -1
|
||||
]
|
||||
/**
|
||||
* Channel Configurations
|
||||
*/
|
||||
};
|
||||
const MPEG4_ChannelConfigurations = [
|
||||
undefined,
|
||||
['front-center'],
|
||||
['front-left', 'front-right'],
|
||||
['front-center', 'front-left', 'front-right'],
|
||||
['front-center', 'front-left', 'front-right', 'back-center'],
|
||||
['front-center', 'front-left', 'front-right', 'back-left', 'back-right'],
|
||||
['front-center', 'front-left', 'front-right', 'back-left', 'back-right', 'LFE-channel'],
|
||||
['front-center', 'front-left', 'front-right', 'side-left', 'side-right', 'back-left', 'back-right', 'LFE-channel']
|
||||
];
|
||||
/**
|
||||
* MPEG Audio Layer I/II/III frame header
|
||||
* Ref: https://www.mp3-tech.org/programmer/frame_header.html
|
||||
* Bit layout: AAAAAAAA AAABBCCD EEEEFFGH IIJJKLMM
|
||||
* Ref: https://wiki.multimedia.cx/index.php/ADTS
|
||||
*/
|
||||
class MpegFrameHeader {
|
||||
constructor(buf, off) {
|
||||
// B(20,19): MPEG Audio versionIndex ID
|
||||
this.versionIndex = common.getBitAllignedNumber(buf, off + 1, 3, 2);
|
||||
// C(18,17): Layer description
|
||||
this.layer = MpegFrameHeader.LayerDescription[common.getBitAllignedNumber(buf, off + 1, 5, 2)];
|
||||
if (this.versionIndex > 1 && this.layer === 0) {
|
||||
this.parseAdtsHeader(buf, off); // Audio Data Transport Stream (ADTS)
|
||||
}
|
||||
else {
|
||||
this.parseMpegHeader(buf, off); // Conventional MPEG header
|
||||
}
|
||||
// D(16): Protection bit (if true 16-bit CRC follows header)
|
||||
this.isProtectedByCRC = !common.isBitSet(buf, off + 1, 7);
|
||||
}
|
||||
calcDuration(numFrames) {
|
||||
return numFrames * this.calcSamplesPerFrame() / this.samplingRate;
|
||||
}
|
||||
calcSamplesPerFrame() {
|
||||
return MpegFrameHeader.samplesInFrameTable[this.version === 1 ? 0 : 1][this.layer];
|
||||
}
|
||||
calculateSideInfoLength() {
|
||||
if (this.layer !== 3)
|
||||
return 2;
|
||||
if (this.channelModeIndex === 3) {
|
||||
// mono
|
||||
if (this.version === 1) {
|
||||
return 17;
|
||||
}
|
||||
else if (this.version === 2 || this.version === 2.5) {
|
||||
return 9;
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (this.version === 1) {
|
||||
return 32;
|
||||
}
|
||||
else if (this.version === 2 || this.version === 2.5) {
|
||||
return 17;
|
||||
}
|
||||
}
|
||||
}
|
||||
calcSlotSize() {
|
||||
return [null, 4, 1, 1][this.layer];
|
||||
}
|
||||
parseMpegHeader(buf, off) {
|
||||
this.container = 'MPEG';
|
||||
// E(15,12): Bitrate index
|
||||
this.bitrateIndex = common.getBitAllignedNumber(buf, off + 2, 0, 4);
|
||||
// F(11,10): Sampling rate frequency index
|
||||
this.sampRateFreqIndex = common.getBitAllignedNumber(buf, off + 2, 4, 2);
|
||||
// G(9): Padding bit
|
||||
this.padding = common.isBitSet(buf, off + 2, 6);
|
||||
// H(8): Private bit
|
||||
this.privateBit = common.isBitSet(buf, off + 2, 7);
|
||||
// I(7,6): Channel Mode
|
||||
this.channelModeIndex = common.getBitAllignedNumber(buf, off + 3, 0, 2);
|
||||
// J(5,4): Mode extension (Only used in Joint stereo)
|
||||
this.modeExtension = common.getBitAllignedNumber(buf, off + 3, 2, 2);
|
||||
// K(3): Copyright
|
||||
this.isCopyrighted = common.isBitSet(buf, off + 3, 4);
|
||||
// L(2): Original
|
||||
this.isOriginalMedia = common.isBitSet(buf, off + 3, 5);
|
||||
// M(3): The original bit indicates, if it is set, that the frame is located on its original media.
|
||||
this.emphasis = common.getBitAllignedNumber(buf, off + 3, 7, 2);
|
||||
this.version = MpegFrameHeader.VersionID[this.versionIndex];
|
||||
this.channelMode = MpegFrameHeader.ChannelMode[this.channelModeIndex];
|
||||
this.codec = `MPEG ${this.version} Layer ${this.layer}`;
|
||||
// Calculate bitrate
|
||||
const bitrateInKbps = this.calcBitrate();
|
||||
if (!bitrateInKbps) {
|
||||
throw new Error('Cannot determine bit-rate');
|
||||
}
|
||||
this.bitrate = bitrateInKbps * 1000;
|
||||
// Calculate sampling rate
|
||||
this.samplingRate = this.calcSamplingRate();
|
||||
if (this.samplingRate == null) {
|
||||
throw new Error('Cannot determine sampling-rate');
|
||||
}
|
||||
}
|
||||
parseAdtsHeader(buf, off) {
|
||||
debug(`layer=0 => ADTS`);
|
||||
this.version = this.versionIndex === 2 ? 4 : 2;
|
||||
this.container = 'ADTS/MPEG-' + this.version;
|
||||
const profileIndex = common.getBitAllignedNumber(buf, off + 2, 0, 2);
|
||||
this.codec = 'AAC';
|
||||
this.codecProfile = MPEG4.AudioObjectTypes[profileIndex];
|
||||
debug(`MPEG-4 audio-codec=${this.codec}`);
|
||||
const samplingFrequencyIndex = common.getBitAllignedNumber(buf, off + 2, 2, 4);
|
||||
this.samplingRate = MPEG4.SamplingFrequencies[samplingFrequencyIndex];
|
||||
debug(`sampling-rate=${this.samplingRate}`);
|
||||
const channelIndex = common.getBitAllignedNumber(buf, off + 2, 7, 3);
|
||||
this.mp4ChannelConfig = MPEG4_ChannelConfigurations[channelIndex];
|
||||
debug(`channel-config=${this.mp4ChannelConfig.join('+')}`);
|
||||
this.frameLength = common.getBitAllignedNumber(buf, off + 3, 6, 2) << 11;
|
||||
}
|
||||
calcBitrate() {
|
||||
if (this.bitrateIndex === 0x00 || // free
|
||||
this.bitrateIndex === 0x0F) { // reserved
|
||||
return;
|
||||
}
|
||||
const codecIndex = `${Math.floor(this.version)}${this.layer}`;
|
||||
return MpegFrameHeader.bitrate_index[this.bitrateIndex][codecIndex];
|
||||
}
|
||||
calcSamplingRate() {
|
||||
if (this.sampRateFreqIndex === 0x03)
|
||||
return null; // 'reserved'
|
||||
return MpegFrameHeader.sampling_rate_freq_index[this.version][this.sampRateFreqIndex];
|
||||
}
|
||||
}
|
||||
MpegFrameHeader.SyncByte1 = 0xFF;
|
||||
MpegFrameHeader.SyncByte2 = 0xE0;
|
||||
MpegFrameHeader.VersionID = [2.5, null, 2, 1];
|
||||
MpegFrameHeader.LayerDescription = [0, 3, 2, 1];
|
||||
MpegFrameHeader.ChannelMode = ['stereo', 'joint_stereo', 'dual_channel', 'mono'];
|
||||
MpegFrameHeader.bitrate_index = {
|
||||
0x01: { 11: 32, 12: 32, 13: 32, 21: 32, 22: 8, 23: 8 },
|
||||
0x02: { 11: 64, 12: 48, 13: 40, 21: 48, 22: 16, 23: 16 },
|
||||
0x03: { 11: 96, 12: 56, 13: 48, 21: 56, 22: 24, 23: 24 },
|
||||
0x04: { 11: 128, 12: 64, 13: 56, 21: 64, 22: 32, 23: 32 },
|
||||
0x05: { 11: 160, 12: 80, 13: 64, 21: 80, 22: 40, 23: 40 },
|
||||
0x06: { 11: 192, 12: 96, 13: 80, 21: 96, 22: 48, 23: 48 },
|
||||
0x07: { 11: 224, 12: 112, 13: 96, 21: 112, 22: 56, 23: 56 },
|
||||
0x08: { 11: 256, 12: 128, 13: 112, 21: 128, 22: 64, 23: 64 },
|
||||
0x09: { 11: 288, 12: 160, 13: 128, 21: 144, 22: 80, 23: 80 },
|
||||
0x0A: { 11: 320, 12: 192, 13: 160, 21: 160, 22: 96, 23: 96 },
|
||||
0x0B: { 11: 352, 12: 224, 13: 192, 21: 176, 22: 112, 23: 112 },
|
||||
0x0C: { 11: 384, 12: 256, 13: 224, 21: 192, 22: 128, 23: 128 },
|
||||
0x0D: { 11: 416, 12: 320, 13: 256, 21: 224, 22: 144, 23: 144 },
|
||||
0x0E: { 11: 448, 12: 384, 13: 320, 21: 256, 22: 160, 23: 160 }
|
||||
};
|
||||
MpegFrameHeader.sampling_rate_freq_index = {
|
||||
1: { 0x00: 44100, 0x01: 48000, 0x02: 32000 },
|
||||
2: { 0x00: 22050, 0x01: 24000, 0x02: 16000 },
|
||||
2.5: { 0x00: 11025, 0x01: 12000, 0x02: 8000 }
|
||||
};
|
||||
MpegFrameHeader.samplesInFrameTable = [
|
||||
/* Layer I II III */
|
||||
[0, 384, 1152, 1152],
|
||||
[0, 384, 1152, 576] // MPEG-2(.5
|
||||
];
|
||||
/**
|
||||
* MPEG Audio Layer I/II/III
|
||||
*/
|
||||
const FrameHeader = {
|
||||
len: 4,
|
||||
get: (buf, off) => {
|
||||
return new MpegFrameHeader(buf, off);
|
||||
}
|
||||
};
|
||||
function getVbrCodecProfile(vbrScale) {
|
||||
return 'V' + Math.floor((100 - vbrScale) / 10);
|
||||
}
|
||||
class MpegParser extends AbstractID3Parser_1.AbstractID3Parser {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.frameCount = 0;
|
||||
this.syncFrameCount = -1;
|
||||
this.countSkipFrameData = 0;
|
||||
this.totalDataLength = 0;
|
||||
this.bitrates = [];
|
||||
this.calculateEofDuration = false;
|
||||
this.buf_frame_header = Buffer.alloc(4);
|
||||
this.syncPeek = {
|
||||
buf: Buffer.alloc(maxPeekLen),
|
||||
len: 0
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Called after ID3 headers have been parsed
|
||||
*/
|
||||
async _parse() {
|
||||
this.metadata.setFormat('lossless', false);
|
||||
try {
|
||||
let quit = false;
|
||||
while (!quit) {
|
||||
await this.sync();
|
||||
quit = await this.parseCommonMpegHeader();
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof core_1.EndOfStreamError) {
|
||||
debug(`End-of-stream`);
|
||||
if (this.calculateEofDuration) {
|
||||
const numberOfSamples = this.frameCount * this.samplesPerFrame;
|
||||
this.metadata.setFormat('numberOfSamples', numberOfSamples);
|
||||
const duration = numberOfSamples / this.metadata.format.sampleRate;
|
||||
debug(`Calculate duration at EOF: ${duration} sec.`, duration);
|
||||
this.metadata.setFormat('duration', duration);
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Called after file has been fully parsed, this allows, if present, to exclude the ID3v1.1 header length
|
||||
*/
|
||||
finalize() {
|
||||
const format = this.metadata.format;
|
||||
const hasID3v1 = this.metadata.native.hasOwnProperty('ID3v1');
|
||||
if (format.duration && this.tokenizer.fileInfo.size) {
|
||||
const mpegSize = this.tokenizer.fileInfo.size - this.mpegOffset - (hasID3v1 ? 128 : 0);
|
||||
if (format.codecProfile && format.codecProfile[0] === 'V') {
|
||||
this.metadata.setFormat('bitrate', mpegSize * 8 / format.duration);
|
||||
}
|
||||
}
|
||||
else if (this.tokenizer.fileInfo.size && format.codecProfile === 'CBR') {
|
||||
const mpegSize = this.tokenizer.fileInfo.size - this.mpegOffset - (hasID3v1 ? 128 : 0);
|
||||
const numberOfSamples = Math.round(mpegSize / this.frame_size) * this.samplesPerFrame;
|
||||
this.metadata.setFormat('numberOfSamples', numberOfSamples);
|
||||
const duration = numberOfSamples / format.sampleRate;
|
||||
debug("Calculate CBR duration based on file size: %s", duration);
|
||||
this.metadata.setFormat('duration', duration);
|
||||
}
|
||||
}
|
||||
async sync() {
|
||||
let gotFirstSync = false;
|
||||
while (true) {
|
||||
let bo = 0;
|
||||
this.syncPeek.len = await this.tokenizer.peekBuffer(this.syncPeek.buf, { length: maxPeekLen, mayBeLess: true });
|
||||
if (this.syncPeek.len <= 163) {
|
||||
throw new core_1.EndOfStreamError();
|
||||
}
|
||||
while (true) {
|
||||
if (gotFirstSync && (this.syncPeek.buf[bo] & 0xE0) === 0xE0) {
|
||||
this.buf_frame_header[0] = MpegFrameHeader.SyncByte1;
|
||||
this.buf_frame_header[1] = this.syncPeek.buf[bo];
|
||||
await this.tokenizer.ignore(bo);
|
||||
debug(`Sync at offset=${this.tokenizer.position - 1}, frameCount=${this.frameCount}`);
|
||||
if (this.syncFrameCount === this.frameCount) {
|
||||
debug(`Re-synced MPEG stream, frameCount=${this.frameCount}`);
|
||||
this.frameCount = 0;
|
||||
this.frame_size = 0;
|
||||
}
|
||||
this.syncFrameCount = this.frameCount;
|
||||
return; // sync
|
||||
}
|
||||
else {
|
||||
gotFirstSync = false;
|
||||
bo = this.syncPeek.buf.indexOf(MpegFrameHeader.SyncByte1, bo);
|
||||
if (bo === -1) {
|
||||
if (this.syncPeek.len < this.syncPeek.buf.length) {
|
||||
throw new core_1.EndOfStreamError();
|
||||
}
|
||||
await this.tokenizer.ignore(this.syncPeek.len);
|
||||
break; // continue with next buffer
|
||||
}
|
||||
else {
|
||||
++bo;
|
||||
gotFirstSync = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Combined ADTS & MPEG (MP2 & MP3) header handling
|
||||
* @return {Promise<boolean>} true if parser should quit
|
||||
*/
|
||||
async parseCommonMpegHeader() {
|
||||
if (this.frameCount === 0) {
|
||||
this.mpegOffset = this.tokenizer.position - 1;
|
||||
}
|
||||
await this.tokenizer.peekBuffer(this.buf_frame_header, { offset: 1, length: 3 });
|
||||
let header;
|
||||
try {
|
||||
header = FrameHeader.get(this.buf_frame_header, 0);
|
||||
}
|
||||
catch (err) {
|
||||
await this.tokenizer.ignore(1);
|
||||
this.metadata.addWarning('Parse error: ' + err.message);
|
||||
return false; // sync
|
||||
}
|
||||
await this.tokenizer.ignore(3);
|
||||
this.metadata.setFormat('container', header.container);
|
||||
this.metadata.setFormat('codec', header.codec);
|
||||
this.metadata.setFormat('lossless', false);
|
||||
this.metadata.setFormat('sampleRate', header.samplingRate);
|
||||
this.frameCount++;
|
||||
if (header.version >= 2 && header.layer === 0) {
|
||||
return this.parseAdts(header); // ADTS, usually AAC
|
||||
}
|
||||
else {
|
||||
return this.parseAudioFrameHeader(header); // MP3
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @return {Promise<boolean>} true if parser should quit
|
||||
*/
|
||||
async parseAudioFrameHeader(header) {
|
||||
this.metadata.setFormat('numberOfChannels', header.channelMode === 'mono' ? 1 : 2);
|
||||
this.metadata.setFormat('bitrate', header.bitrate);
|
||||
if (this.frameCount < 20 * 10000) {
|
||||
debug('offset=%s MP%s bitrate=%s sample-rate=%s', this.tokenizer.position - 4, header.layer, header.bitrate, header.samplingRate);
|
||||
}
|
||||
const slot_size = header.calcSlotSize();
|
||||
if (slot_size === null) {
|
||||
throw new Error('invalid slot_size');
|
||||
}
|
||||
const samples_per_frame = header.calcSamplesPerFrame();
|
||||
debug(`samples_per_frame=${samples_per_frame}`);
|
||||
const bps = samples_per_frame / 8.0;
|
||||
const fsize = (bps * header.bitrate / header.samplingRate) +
|
||||
((header.padding) ? slot_size : 0);
|
||||
this.frame_size = Math.floor(fsize);
|
||||
this.audioFrameHeader = header;
|
||||
this.bitrates.push(header.bitrate);
|
||||
// xtra header only exists in first frame
|
||||
if (this.frameCount === 1) {
|
||||
this.offset = FrameHeader.len;
|
||||
await this.skipSideInformation();
|
||||
return false;
|
||||
}
|
||||
if (this.frameCount === 3) {
|
||||
// the stream is CBR if the first 3 frame bitrates are the same
|
||||
if (this.areAllSame(this.bitrates)) {
|
||||
// Actual calculation will be done in finalize
|
||||
this.samplesPerFrame = samples_per_frame;
|
||||
this.metadata.setFormat('codecProfile', 'CBR');
|
||||
if (this.tokenizer.fileInfo.size)
|
||||
return true; // Will calculate duration based on the file size
|
||||
}
|
||||
else if (this.metadata.format.duration) {
|
||||
return true; // We already got the duration, stop processing MPEG stream any further
|
||||
}
|
||||
if (!this.options.duration) {
|
||||
return true; // Enforce duration not enabled, stop processing entire stream
|
||||
}
|
||||
}
|
||||
// once we know the file is VBR attach listener to end of
|
||||
// stream so we can do the duration calculation when we
|
||||
// have counted all the frames
|
||||
if (this.options.duration && this.frameCount === 4) {
|
||||
this.samplesPerFrame = samples_per_frame;
|
||||
this.calculateEofDuration = true;
|
||||
}
|
||||
this.offset = 4;
|
||||
if (header.isProtectedByCRC) {
|
||||
await this.parseCrc();
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
await this.skipSideInformation();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async parseAdts(header) {
|
||||
const buf = Buffer.alloc(3);
|
||||
await this.tokenizer.readBuffer(buf);
|
||||
header.frameLength += common.getBitAllignedNumber(buf, 0, 0, 11);
|
||||
this.totalDataLength += header.frameLength;
|
||||
this.samplesPerFrame = 1024;
|
||||
const framesPerSec = header.samplingRate / this.samplesPerFrame;
|
||||
const bytesPerFrame = this.frameCount === 0 ? 0 : this.totalDataLength / this.frameCount;
|
||||
const bitrate = 8 * bytesPerFrame * framesPerSec + 0.5;
|
||||
this.metadata.setFormat('bitrate', bitrate);
|
||||
debug(`frame-count=${this.frameCount}, size=${header.frameLength} bytes, bit-rate=${bitrate}`);
|
||||
await this.tokenizer.ignore(header.frameLength > 7 ? header.frameLength - 7 : 1);
|
||||
// Consume remaining header and frame data
|
||||
if (this.frameCount === 3) {
|
||||
this.metadata.setFormat('codecProfile', header.codecProfile);
|
||||
if (header.mp4ChannelConfig) {
|
||||
this.metadata.setFormat('numberOfChannels', header.mp4ChannelConfig.length);
|
||||
}
|
||||
if (this.options.duration) {
|
||||
this.calculateEofDuration = true;
|
||||
}
|
||||
else {
|
||||
return true; // Stop parsing after the third frame
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async parseCrc() {
|
||||
this.crc = await this.tokenizer.readNumber(Token.INT16_BE);
|
||||
this.offset += 2;
|
||||
return this.skipSideInformation();
|
||||
}
|
||||
async skipSideInformation() {
|
||||
const sideinfo_length = this.audioFrameHeader.calculateSideInfoLength();
|
||||
// side information
|
||||
await this.tokenizer.readToken(new Token.Uint8ArrayType(sideinfo_length));
|
||||
this.offset += sideinfo_length;
|
||||
await this.readXtraInfoHeader();
|
||||
return;
|
||||
}
|
||||
async readXtraInfoHeader() {
|
||||
const headerTag = await this.tokenizer.readToken(XingTag_1.InfoTagHeaderTag);
|
||||
this.offset += XingTag_1.InfoTagHeaderTag.len; // 12
|
||||
switch (headerTag) {
|
||||
case 'Info':
|
||||
this.metadata.setFormat('codecProfile', 'CBR');
|
||||
return this.readXingInfoHeader();
|
||||
case 'Xing':
|
||||
const infoTag = await this.readXingInfoHeader();
|
||||
const codecProfile = getVbrCodecProfile(infoTag.vbrScale);
|
||||
this.metadata.setFormat('codecProfile', codecProfile);
|
||||
return null;
|
||||
case 'Xtra':
|
||||
// ToDo: ???
|
||||
break;
|
||||
case 'LAME':
|
||||
const version = await this.tokenizer.readToken(XingTag_1.LameEncoderVersion);
|
||||
if (this.frame_size >= this.offset + XingTag_1.LameEncoderVersion.len) {
|
||||
this.offset += XingTag_1.LameEncoderVersion.len;
|
||||
this.metadata.setFormat('tool', 'LAME ' + version);
|
||||
await this.skipFrameData(this.frame_size - this.offset);
|
||||
return null;
|
||||
}
|
||||
else {
|
||||
this.metadata.addWarning('Corrupt LAME header');
|
||||
break;
|
||||
}
|
||||
// ToDo: ???
|
||||
}
|
||||
// ToDo: promise duration???
|
||||
const frameDataLeft = this.frame_size - this.offset;
|
||||
if (frameDataLeft < 0) {
|
||||
this.metadata.addWarning('Frame ' + this.frameCount + 'corrupt: negative frameDataLeft');
|
||||
}
|
||||
else {
|
||||
await this.skipFrameData(frameDataLeft);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Ref: http://gabriel.mp3-tech.org/mp3infotag.html
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async readXingInfoHeader() {
|
||||
const _offset = this.tokenizer.position;
|
||||
const infoTag = await (0, XingTag_1.readXingHeader)(this.tokenizer);
|
||||
this.offset += this.tokenizer.position - _offset;
|
||||
if (infoTag.lame) {
|
||||
this.metadata.setFormat('tool', 'LAME ' + common.stripNulls(infoTag.lame.version));
|
||||
if (infoTag.lame.extended) {
|
||||
// this.metadata.setFormat('trackGain', infoTag.lame.extended.track_gain);
|
||||
this.metadata.setFormat('trackPeakLevel', infoTag.lame.extended.track_peak);
|
||||
if (infoTag.lame.extended.track_gain) {
|
||||
this.metadata.setFormat('trackGain', infoTag.lame.extended.track_gain.adjustment);
|
||||
}
|
||||
if (infoTag.lame.extended.album_gain) {
|
||||
this.metadata.setFormat('albumGain', infoTag.lame.extended.album_gain.adjustment);
|
||||
}
|
||||
this.metadata.setFormat('duration', infoTag.lame.extended.music_length / 1000);
|
||||
}
|
||||
}
|
||||
if (infoTag.streamSize) {
|
||||
const duration = this.audioFrameHeader.calcDuration(infoTag.numFrames);
|
||||
this.metadata.setFormat('duration', duration);
|
||||
debug('Get duration from Xing header: %s', this.metadata.format.duration);
|
||||
return infoTag;
|
||||
}
|
||||
// frames field is not present
|
||||
const frameDataLeft = this.frame_size - this.offset;
|
||||
await this.skipFrameData(frameDataLeft);
|
||||
return infoTag;
|
||||
}
|
||||
async skipFrameData(frameDataLeft) {
|
||||
if (frameDataLeft < 0)
|
||||
throw new Error('frame-data-left cannot be negative');
|
||||
await this.tokenizer.ignore(frameDataLeft);
|
||||
this.countSkipFrameData += frameDataLeft;
|
||||
}
|
||||
areAllSame(array) {
|
||||
const first = array[0];
|
||||
return array.every(element => {
|
||||
return element === first;
|
||||
});
|
||||
}
|
||||
}
|
||||
exports.MpegParser = MpegParser;
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { IGetToken } from 'strtok3/lib/core';
|
||||
export interface IReplayGain {
|
||||
type: NameCode;
|
||||
origin: ReplayGainOriginator;
|
||||
adjustment: number;
|
||||
}
|
||||
/**
|
||||
* https://github.com/Borewit/music-metadata/wiki/Replay-Gain-Data-Format#name-code
|
||||
*/
|
||||
declare enum NameCode {
|
||||
/**
|
||||
* not set
|
||||
*/
|
||||
not_set = 0,
|
||||
/**
|
||||
* Radio Gain Adjustment
|
||||
*/
|
||||
radio = 1,
|
||||
/**
|
||||
* Audiophile Gain Adjustment
|
||||
*/
|
||||
audiophile = 2
|
||||
}
|
||||
/**
|
||||
* https://github.com/Borewit/music-metadata/wiki/Replay-Gain-Data-Format#originator-code
|
||||
*/
|
||||
declare enum ReplayGainOriginator {
|
||||
/**
|
||||
* Replay Gain unspecified
|
||||
*/
|
||||
unspecified = 0,
|
||||
/**
|
||||
* Replay Gain pre-set by artist/producer/mastering engineer
|
||||
*/
|
||||
engineer = 1,
|
||||
/**
|
||||
* Replay Gain set by user
|
||||
*/
|
||||
user = 2,
|
||||
/**
|
||||
* Replay Gain determined automatically, as described on this site
|
||||
*/
|
||||
automatic = 3,
|
||||
/**
|
||||
* Set by simple RMS average
|
||||
*/
|
||||
rms_average = 4
|
||||
}
|
||||
/**
|
||||
* Replay Gain Data Format
|
||||
*
|
||||
* https://github.com/Borewit/music-metadata/wiki/Replay-Gain-Data-Format
|
||||
*/
|
||||
export declare const ReplayGain: IGetToken<IReplayGain>;
|
||||
export {};
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ReplayGain = void 0;
|
||||
const common = require("../common/Util");
|
||||
/**
|
||||
* https://github.com/Borewit/music-metadata/wiki/Replay-Gain-Data-Format#name-code
|
||||
*/
|
||||
var NameCode;
|
||||
(function (NameCode) {
|
||||
/**
|
||||
* not set
|
||||
*/
|
||||
NameCode[NameCode["not_set"] = 0] = "not_set";
|
||||
/**
|
||||
* Radio Gain Adjustment
|
||||
*/
|
||||
NameCode[NameCode["radio"] = 1] = "radio";
|
||||
/**
|
||||
* Audiophile Gain Adjustment
|
||||
*/
|
||||
NameCode[NameCode["audiophile"] = 2] = "audiophile";
|
||||
})(NameCode || (NameCode = {}));
|
||||
/**
|
||||
* https://github.com/Borewit/music-metadata/wiki/Replay-Gain-Data-Format#originator-code
|
||||
*/
|
||||
var ReplayGainOriginator;
|
||||
(function (ReplayGainOriginator) {
|
||||
/**
|
||||
* Replay Gain unspecified
|
||||
*/
|
||||
ReplayGainOriginator[ReplayGainOriginator["unspecified"] = 0] = "unspecified";
|
||||
/**
|
||||
* Replay Gain pre-set by artist/producer/mastering engineer
|
||||
*/
|
||||
ReplayGainOriginator[ReplayGainOriginator["engineer"] = 1] = "engineer";
|
||||
/**
|
||||
* Replay Gain set by user
|
||||
*/
|
||||
ReplayGainOriginator[ReplayGainOriginator["user"] = 2] = "user";
|
||||
/**
|
||||
* Replay Gain determined automatically, as described on this site
|
||||
*/
|
||||
ReplayGainOriginator[ReplayGainOriginator["automatic"] = 3] = "automatic";
|
||||
/**
|
||||
* Set by simple RMS average
|
||||
*/
|
||||
ReplayGainOriginator[ReplayGainOriginator["rms_average"] = 4] = "rms_average";
|
||||
})(ReplayGainOriginator || (ReplayGainOriginator = {}));
|
||||
/**
|
||||
* Replay Gain Data Format
|
||||
*
|
||||
* https://github.com/Borewit/music-metadata/wiki/Replay-Gain-Data-Format
|
||||
*/
|
||||
exports.ReplayGain = {
|
||||
len: 2,
|
||||
get: (buf, off) => {
|
||||
const gain_type = common.getBitAllignedNumber(buf, off, 0, 3);
|
||||
const sign = common.getBitAllignedNumber(buf, off, 6, 1);
|
||||
const gain_adj = common.getBitAllignedNumber(buf, off, 7, 9) / 10.0;
|
||||
if (gain_type > 0) {
|
||||
return {
|
||||
type: common.getBitAllignedNumber(buf, off, 0, 3),
|
||||
origin: common.getBitAllignedNumber(buf, off, 3, 3),
|
||||
adjustment: (sign ? -gain_adj : gain_adj)
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/// <reference types="node" />
|
||||
import * as Token from "token-types";
|
||||
import { IGetToken, ITokenizer } from 'strtok3/lib/core';
|
||||
import { IExtendedLameHeader } from './ExtendedLameHeader';
|
||||
export interface IXingHeaderFlags {
|
||||
frames: boolean;
|
||||
bytes: boolean;
|
||||
toc: boolean;
|
||||
vbrScale: boolean;
|
||||
}
|
||||
/**
|
||||
* Info Tag: Xing, LAME
|
||||
*/
|
||||
export declare const InfoTagHeaderTag: Token.StringType;
|
||||
/**
|
||||
* LAME TAG value
|
||||
* Did not find any official documentation for this
|
||||
* Value e.g.: "3.98.4"
|
||||
*/
|
||||
export declare const LameEncoderVersion: Token.StringType;
|
||||
export interface IXingInfoTag {
|
||||
/**
|
||||
* total bit stream frames from Vbr header data
|
||||
*/
|
||||
numFrames?: number;
|
||||
/**
|
||||
* Actual stream size = file size - header(s) size [bytes]
|
||||
*/
|
||||
streamSize?: number;
|
||||
toc?: Buffer;
|
||||
/**
|
||||
* the number of header data bytes (from original file)
|
||||
*/
|
||||
vbrScale?: number;
|
||||
lame?: {
|
||||
version: string;
|
||||
extended?: IExtendedLameHeader;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Info Tag
|
||||
* Ref: http://gabriel.mp3-tech.org/mp3infotag.html
|
||||
*/
|
||||
export declare const XingHeaderFlags: IGetToken<IXingHeaderFlags>;
|
||||
export declare function readXingHeader(tokenizer: ITokenizer): Promise<IXingInfoTag>;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user