diff --git a/src/MusicMetadataParser.ts b/src/MusicMetadataParser.ts new file mode 100644 index 000000000..202a35d84 --- /dev/null +++ b/src/MusicMetadataParser.ts @@ -0,0 +1,98 @@ +import {CombinedTagMapper, IAudioMetadata, INativeAudioMetadata, IOptions} from "./"; +import {TagPriority, TagType} from "./common/GenericTagTypes"; +import {ParserFactory} from "./ParserFactory"; +import {Promise} from "es6-promise"; +import {ITokenizer} from "strtok3"; + +export class MusicMetadataParser { + + public static joinArtists(artists: string[]): string { + if (artists.length > 2) { + return artists.slice(0, artists.length - 1).join(', ') + ' & ' + artists[artists.length - 1]; + } + return artists.join(' & '); + } + + private tagMapper = new CombinedTagMapper(); + + /** + * Parse data provided by tokenizer. + * @param {ITokenizer} tokenizer feed input (abstracting input) + * @param {IOptions} options + * @returns {Promise} + */ + public parse(tokenizer: ITokenizer, options?: IOptions): Promise { + if (!tokenizer.fileSize && options.fileSize) { + tokenizer.fileSize = options.fileSize; + } + return ParserFactory.parse(tokenizer, options).then(nativeTags => { + return this.parseNativeTags(nativeTags, options.native, options.mergeTagHeaders); + }); + } + + /** + * Convert native tags to common tags + * @param nativeData + * @param includeNative return native tags in result + * @param mergeTagHeaders Merge tah headers + * @returns {IAudioMetadata} Native + common tags + */ + public parseNativeTags(nativeData: INativeAudioMetadata, includeNative?: boolean, mergeTagHeaders?: boolean): IAudioMetadata { + + const metadata: IAudioMetadata = { + format: nativeData.format, + native: includeNative ? nativeData.native : undefined, + common: {} as any + }; + + metadata.format.tagTypes = []; + + for (const tagType in nativeData.native) { + metadata.format.tagTypes.push(tagType as TagType); + } + + for (const tagType of TagPriority) { + + if (nativeData.native[tagType]) { + if (nativeData.native[tagType].length === 0) { + // ToDo: register warning: empty tag header + } else { + + const common = { + track: {no: null, of: null}, + disk: {no: null, of: null} + }; + + for (const tag of nativeData.native[tagType]) { + this.tagMapper.setGenericTag(common, tagType as TagType, tag); + } + + for (const tag of Object.keys(common)) { + if (!metadata.common[tag]) { + metadata.common[tag] = common[tag]; + } + } + + if (!mergeTagHeaders) { + break; + } + } + } + } + + if (metadata.common.artists && metadata.common.artists.length > 0) { + // common.artists explicitly by meta-data + metadata.common.artist = !metadata.common.artist ? MusicMetadataParser.joinArtists(metadata.common.artists) : metadata.common.artist[0]; + } else { + if (metadata.common.artist) { + metadata.common.artists = metadata.common.artist as any; + if (metadata.common.artist.length > 1) { + delete metadata.common.artist; + } else { + metadata.common.artist = metadata.common.artist[0]; + } + } + } + return metadata; + } +} diff --git a/src/ParserFactory.ts b/src/ParserFactory.ts index 8289dc0f5..2b2c8c64d 100644 --- a/src/ParserFactory.ts +++ b/src/ParserFactory.ts @@ -1,7 +1,5 @@ import {INativeAudioMetadata, IOptions} from "./"; -import * as strtok3 from "strtok3"; -import * as Stream from "stream"; -import * as path from "path"; +import {ITokenizer} from "strtok3"; import * as fileType from "file-type"; import * as MimeType from "media-typer"; import {Promise} from "es6-promise"; @@ -11,59 +9,11 @@ import * as _debug from "debug"; const debug = _debug("music-metadata:parser:factory"); export interface ITokenParser { - parse(tokenizer: strtok3.ITokenizer, options: IOptions): Promise; + parse(tokenizer: ITokenizer, options: IOptions): Promise; } export class ParserFactory { - /** - * Extract metadata from the given audio file - * @param filePath File path of the audio file to parse - * @param opts - * .fileSize=true Return filesize - * .native=true Will return original header in result - * @returns {Promise} - */ - public static parseFile(filePath: string, opts: IOptions = {}): Promise { - - return strtok3.fromFile(filePath).then(fileTokenizer => { - const parserName = ParserFactory.getParserIdForExtension(filePath); - if (parserName) { - return ParserFactory.loadParser(parserName, opts).then(parser => { - return parser.parse(fileTokenizer, opts).then(metadata => { - return fileTokenizer.close().then(() => { - return metadata; - }); - }).catch(err => { - return fileTokenizer.close().then(() => { - throw err; - }); - }); - }); - - } else { - throw new Error('No parser found for extension: ' + path.extname(filePath)); - } - }); - } - - /** - * Parse metadata from stream - * @param stream Node stream - * @param mimeType The mime-type, e.g. "audio/mpeg", extension e.g. ".mp3" or filename. This is used to redirect to the correct parser. - * @param opts Parsing options - * @returns {Promise} - */ - public static parseStream(stream: Stream.Readable, mimeType: string, opts: IOptions = {}): Promise { - - return strtok3.fromStream(stream).then(tokenizer => { - if (!tokenizer.fileSize && opts.fileSize) { - tokenizer.fileSize = opts.fileSize; - } - return this.parse(tokenizer, mimeType, opts); - }); - } - /** * Parse metadata from tokenizer * @param {ITokenizer} tokenizer @@ -71,14 +21,28 @@ export class ParserFactory { * @param {IOptions} opts * @returns {Promise} */ - public static parse(tokenizer: strtok3.ITokenizer, contentType: string, opts: IOptions = {}): Promise { + public static parse(tokenizer: ITokenizer, opts: IOptions = {}): Promise { // Resolve parser based on MIME-type or file extension - let parserId = ParserFactory.getParserIdForMimeType(contentType) || ParserFactory.getParserIdForExtension(contentType); + let parserId; + + if (opts.contentType) { + parserId = ParserFactory.getParserIdForMimeType(opts.contentType) || ParserFactory.getParserIdForExtension(opts.contentType); + if (!parserId) { + debug("No parser found for MIME-type:" + opts.contentType); + } + } + + if (!parserId && opts.path) { + parserId = ParserFactory.getParserIdForExtension(opts.path); + if (!parserId) { + debug("No parser found for path:" + opts.path); + } + } if (!parserId) { // No MIME-type mapping found - debug("No parser found for MIME-type / extension:" + contentType); + debug("Try to determine type based content..."); const buf = Buffer.alloc(4100); return tokenizer.peekBuffer(buf).then(() => { @@ -108,7 +72,7 @@ export class ParserFactory { if (!filePath) return; - const extension = path.extname(filePath).toLocaleLowerCase() || filePath; + const extension = filePath.slice((filePath.lastIndexOf(".") - 1 >>> 0) + 1) || filePath; switch (extension) { diff --git a/src/browser/index.ts b/src/browser/index.ts new file mode 100644 index 000000000..942d5c0cd --- /dev/null +++ b/src/browser/index.ts @@ -0,0 +1,35 @@ +import {IAudioMetadata, IOptions} from "../"; +import {Promise} from "es6-promise"; +import * as strtok3 from "strtok3/lib/browser"; +import {MusicMetadataParser} from "../MusicMetadataParser"; +import * as Stream from "stream"; + +/** + * Get audio track using XML-HTTP-Request (XHR). + * @param {string} url URL to read from + * @param {IOptions} opts Options + * @returns {Promise} + */ +export function parseUrl(url: string, opts?: IOptions): Promise { + + return strtok3.fromUrl(url).then(tokenizer => { + opts.contentType = tokenizer.contentType || opts.contentType; + return new MusicMetadataParser().parse(tokenizer, opts); // ToDo: mime type + }); +} + +/** + * Parse audio from stream + * @param stream Node stream + * @param mimeType The mime-type, e.g. "audio/mpeg", extension e.g. ".mp3" or filename. This is used to redirect to the correct parser. + * @param opts Parsing options + * .native=true Will return original header in result + * .mergeTagHeaders=false Populate common from data of all headers available + * @returns {Promise} + */ +export function parseStream(stream: Stream.Readable, mimeType?: string, opts?: IOptions): Promise { + opts.contentType = mimeType || opts.contentType; + return strtok3.fromStream(stream).then(tokenizer => { + return new MusicMetadataParser().parse(tokenizer, opts); + }); +} diff --git a/src/index.ts b/src/index.ts index 972165918..898d0636c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ 'use strict'; -import {TagPriority, TagType} from './common/GenericTagTypes'; -import {ITokenParser, ParserFactory} from "./ParserFactory"; +import {TagType} from './common/GenericTagTypes'; +import {ITokenParser} from "./ParserFactory"; import * as Stream from "stream"; import {IGenericTagMapper} from "./common/GenericTagMapper"; import {ID3v24TagMapper} from "./id3v2/ID3v24TagMapper"; @@ -13,6 +13,8 @@ import {ID3v1TagMapper} from "./id3v1/ID3v1TagMap"; import {AsfTagMapper} from "./asf/AsfTagMapper"; import {RiffInfoTagMapper} from "./riff/RiffInfoTagMap"; import {Promise} from "es6-promise"; +import * as strtok3 from "strtok3"; +import {MusicMetadataParser} from "./MusicMetadataParser"; /** * Attached picture, typically used for cover art @@ -245,8 +247,17 @@ export interface IAudioMetadata extends INativeAudioMetadata { } export interface IOptions { + + /** + * The filename of the audio file + */ path?: string, + /** + * The contentType of the audio data or stream + */ + contentType?: string, + /** * default: `undefined`, pass the */ @@ -326,118 +337,6 @@ export class CombinedTagMapper { } } -export class MusicMetadataParser { - - public static getInstance(): MusicMetadataParser { - return new MusicMetadataParser(); - } - - public static joinArtists(artists: string[]): string { - if (artists.length > 2) { - return artists.slice(0, artists.length - 1).join(', ') + ' & ' + artists[artists.length - 1]; - } - return artists.join(' & '); - } - - private tagMapper = new CombinedTagMapper(); - - /** - * Extract metadata from the given audio file - * @param filePath File path of the audio file to parse - * @param opts - * .filesize=true Return filesize - * .native=true Will return original header in result - * @returns {Promise} - */ - public parseFile(filePath: string, opts: IOptions = {}): Promise { - - return ParserFactory.parseFile(filePath, opts).then(nativeData => { - return this.parseNativeTags(nativeData, opts.native, opts.mergeTagHeaders); - }); - - } - - /** - * Extract metadata from the given audio file - * @param stream Audio ReadableStream - * @param mimeType Mime-Type of Stream - * @param opts - * .filesize=true Return filesize - * .native=true Will return original header in result - * @returns {Promise} - */ - public parseStream(stream: Stream.Readable, mimeType: string, opts: IOptions = {}): Promise { - return ParserFactory.parseStream(stream, mimeType, opts).then(nativeData => { - return this.parseNativeTags(nativeData, opts.native, opts.mergeTagHeaders); - }); - } - - /** - * Convert native tags to common tags - * @param nativeData - * @includeNative return native tags in result - * @returns {IAudioMetadata} Native + common tags - */ - public parseNativeTags(nativeData: INativeAudioMetadata, includeNative?: boolean, mergeTagHeaders?: boolean): IAudioMetadata { - - const metadata: IAudioMetadata = { - format: nativeData.format, - native: includeNative ? nativeData.native : undefined, - common: {} as any - }; - - metadata.format.tagTypes = []; - - for (const tagType in nativeData.native) { - metadata.format.tagTypes.push(tagType as TagType); - } - - for (const tagType of TagPriority) { - - if (nativeData.native[tagType]) { - if (nativeData.native[tagType].length === 0) { - // ToDo: register warning: empty tag header - } else { - - const common = { - track: {no: null, of: null}, - disk: {no: null, of: null} - }; - - for (const tag of nativeData.native[tagType]) { - this.tagMapper.setGenericTag(common, tagType as TagType, tag); - } - - for (const tag of Object.keys(common)) { - if (!metadata.common[tag]) { - metadata.common[tag] = common[tag]; - } - } - - if (!mergeTagHeaders) { - break; - } - } - } - } - - if (metadata.common.artists && metadata.common.artists.length > 0) { - // common.artists explicitly by meta-data - metadata.common.artist = !metadata.common.artist ? MusicMetadataParser.joinArtists(metadata.common.artists) : metadata.common.artist[0]; - } else { - if (metadata.common.artist) { - metadata.common.artists = metadata.common.artist as any; - if (metadata.common.artist.length > 1) { - delete metadata.common.artist; - } else { - metadata.common.artist = metadata.common.artist[0]; - } - } - } - return metadata; - } -} - /** * Parse audio file * @param filePath Media file to read meta-data from @@ -447,20 +346,34 @@ export class MusicMetadataParser { * @returns {Promise} */ export function parseFile(filePath: string, options?: IOptions): Promise { - return MusicMetadataParser.getInstance().parseFile(filePath, options); + options = options ? options : {}; + return strtok3.fromFile(filePath).then(fileTokenizer => { + options.path = filePath || options.path; + return new MusicMetadataParser().parse(fileTokenizer, options).then(metadata => { + return fileTokenizer.close().then(() => metadata); + }).catch(err => { + return fileTokenizer.close().then(() => { + throw err; + }); + }); + }); } /** - * Parse audio Stream - * @param stream - * @param mimeType + * Parse audio from stream + * @param stream Node stream + * @param mimeType The mime-type, e.g. "audio/mpeg", extension e.g. ".mp3" or filename. This is used to redirect to the correct parser. * @param opts Parsing options * .native=true Will return original header in result * .mergeTagHeaders=false Populate common from data of all headers available * @returns {Promise} */ export function parseStream(stream: Stream.Readable, mimeType?: string, opts?: IOptions): Promise { - return MusicMetadataParser.getInstance().parseStream(stream, mimeType, opts); + opts = opts || {}; + opts.contentType = mimeType || opts.contentType; + return strtok3.fromStream(stream).then(tokenizer => { + return new MusicMetadataParser().parse(tokenizer, opts); + }); } /** diff --git a/test/test-common.ts b/test/test-common.ts index ac4f7761f..187de74f6 100644 --- a/test/test-common.ts +++ b/test/test-common.ts @@ -4,6 +4,7 @@ import {CommonTagMapper} from "../src/common/GenericTagMapper"; import {commonTags, isSingleton} from "../src/common/GenericTagTypes"; import * as path from "path"; import * as mm from "../src"; +import {MusicMetadataParser} from "../src/MusicMetadataParser"; import * as MimeType from "media-typer"; const t = assert; @@ -60,9 +61,9 @@ describe("GenericTagMap", () => { describe("common.artist / common.artists mapping", () => { it("should be able to join artists", () => { - t.equal(mm.MusicMetadataParser.joinArtists(["David Bowie"]), "David Bowie"); - t.equal(mm.MusicMetadataParser.joinArtists(["David Bowie", "Stevie Ray Vaughan"]), "David Bowie & Stevie Ray Vaughan"); - t.equal(mm.MusicMetadataParser.joinArtists(["David Bowie", "Queen", "Mick Ronson"]), "David Bowie, Queen & Mick Ronson"); + t.equal(MusicMetadataParser.joinArtists(["David Bowie"]), "David Bowie"); + t.equal(MusicMetadataParser.joinArtists(["David Bowie", "Stevie Ray Vaughan"]), "David Bowie & Stevie Ray Vaughan"); + t.equal(MusicMetadataParser.joinArtists(["David Bowie", "Queen", "Mick Ronson"]), "David Bowie, Queen & Mick Ronson"); }); it("parse RIFF tags", () => { diff --git a/tslint.json b/tslint.json index 2b4b50743..4ca6be58c 100644 --- a/tslint.json +++ b/tslint.json @@ -25,7 +25,8 @@ "ordered-imports": false, "forin": false, "arrow-parens": [true, "ban-single-arg-parens"], - "no-implicit-dependencies": [true, "dev"] + "no-implicit-dependencies": [true, "dev"], + "no-submodule-imports": [true, "strtok3"] }, "jsRules": { /*