From e30ed13c5691e55c6b9db516ba8ee7f904830ad5 Mon Sep 17 00:00:00 2001 From: Borewit Date: Fri, 9 Aug 2024 17:26:42 +0200 Subject: [PATCH] Fix id3v2.3 & id3v2.4 W-Frames Resolves #2206 --- lib/id3v2/FrameParser.ts | 1 + test/test-id3v2.3.ts | 946 +++++++++++++++++++-------------------- 2 files changed, 474 insertions(+), 473 deletions(-) diff --git a/lib/id3v2/FrameParser.ts b/lib/id3v2/FrameParser.ts index 52f2948f5..e1b728207 100644 --- a/lib/id3v2/FrameParser.ts +++ b/lib/id3v2/FrameParser.ts @@ -333,6 +333,7 @@ export class FrameParser { case 'WPAY': case 'WPUB': // Decode URL + fzero = util.findZero(uint8Array, offset + 1, length, encoding); output = util.decodeString(uint8Array.slice(offset, fzero), defaultEnc); break; diff --git a/test/test-id3v2.3.ts b/test/test-id3v2.3.ts index b6584531c..b1321ddac 100644 --- a/test/test-id3v2.3.ts +++ b/test/test-id3v2.3.ts @@ -1,473 +1,473 @@ -import { assert } from 'chai'; -import path from 'node:path'; -import * as strtok from 'strtok3'; - -import { ID3v2Parser } from '../lib/id3v2/ID3v2Parser.js'; -import { MetadataCollector } from '../lib/common/MetadataCollector.js'; -import * as mm from '../lib/index.js'; -import { samplePath } from './util.js'; -import { LyricsContentType, TimestampFormat, type ILyricsTag } from '../lib/core.js'; -import type { IPicture } from '../lib/index.js'; -import type { IGeneralEncapsulatedObject } from '../lib/id3v2/FrameParser.js'; - -describe('Extract metadata from ID3v2.3 header', () => { - - it('should parse a raw ID3v2.3 header', () => { - - const filePath = path.join(samplePath, 'MusicBrainz - Beth Hart - Sinner\'s Prayer.id3v23'); - - const metadata = new MetadataCollector({}); - - return strtok.fromFile(filePath).then(tokenizer => { - return new ID3v2Parser().parse(metadata, tokenizer, {}).then(() => { - - assert.strictEqual(33, metadata.native['ID3v2.3'].length); - - const id3v23 = mm.orderTags(metadata.native['ID3v2.3']); - assert.isDefined(id3v23.UFID, 'check if ID3v2.3-UFID is set'); - }); - }); - }); - - it('parse a ID3v2.3', async () => { - - const filePath = path.join(samplePath, 'id3v2.3.mp3'); - - function checkFormat(format) { - assert.deepEqual(format.tagTypes, ['ID3v2.3', 'ID3v1'], 'format.type'); - assert.strictEqual(format.duration, 0.7836734693877551, 'format.duration'); // FooBar says 0.732 seconds (32.727 samples) - assert.strictEqual(format.sampleRate, 44100, 'format.sampleRate = 44.1 kHz'); - assert.strictEqual(format.bitrate, 128000, 'format.bitrate = 128 kbit/sec'); - assert.strictEqual(format.numberOfChannels, 2, 'format.numberOfChannels 2 (stereo)'); - assert.strictEqual(format.container, 'MPEG', 'format.container'); - assert.strictEqual(format.codec, 'MPEG 1 Layer 3', 'format.codec'); - assert.strictEqual(format.tool, 'LAME 3.98r', 'format.tool'); - assert.strictEqual(format.codecProfile, 'CBR', 'format.codecProfile'); - } - - function checkCommon(common) { - assert.strictEqual(common.title, 'Home', 'common.title'); - assert.deepEqual(common.artists, ['Explosions In The Sky', 'Another', 'And Another'], 'common.artists'); - assert.strictEqual(common.albumartist, 'Soundtrack', 'common.albumartist'); - assert.strictEqual(common.album, 'Friday Night Lights [Original Movie Soundtrack]', 'common.album'); - assert.strictEqual(common.year, 2004, 'common.year'); - assert.strictEqual(common.track.no, 5, 'common.track.no'); - assert.strictEqual(common.track.of, null, 'common.track.of'); - assert.strictEqual(common.disk.no, 1, 'common.disk.no'); - assert.strictEqual(common.disk.of, 1, 'common.disk.of'); - assert.strictEqual(common.genre[0], 'Soundtrack', 'common.genre'); - assert.strictEqual(common.picture[0].format, 'image/jpeg', 'common.picture format'); - assert.strictEqual(common.picture[0].data.length, 80938, 'common.picture length'); - } - - function checkID3v1(id3v11: mm.INativeTagDict) { - - assert.deepEqual(id3v11.title, ['Home'], 'id3v11.title'); - assert.deepEqual(id3v11.album, ['Friday Night Lights [Original'], 'id3v11.album'); - assert.deepEqual(id3v11.artist, ['Explosions In The Sky/Another/'], 'id3v11.artist'); - assert.deepEqual(id3v11.genre, ['Soundtrack'], 'id3v11.genre'); - assert.deepEqual(id3v11.track, [5], 'id3v11.track'); - assert.deepEqual(id3v11.year, ['2004'], 'id3v11.year'); - } - - function checkID3v23(id3v23: mm.INativeTagDict) { - - assert.deepEqual(id3v23.TALB, ['Friday Night Lights [Original Movie Soundtrack]'], 'native: TALB'); - assert.deepEqual(id3v23.TPE1, ['Explosions In The Sky', 'Another', 'And Another'], 'native: TPE1'); - assert.deepEqual(id3v23.TPE2, ['Soundtrack'], 'native: TPE2'); - assert.deepEqual(id3v23.TCOM, ['Explosions in the Sky'], 'native: TCOM'); - assert.deepEqual(id3v23.TPOS, ['1/1'], 'native: TPOS'); - assert.deepEqual(id3v23.TCON, ['Soundtrack'], 'native: TCON'); - assert.deepEqual(id3v23.TIT2, ['Home'], 'native: TIT2'); - assert.deepEqual(id3v23.TRCK, ['5'], 'native: TRCK'); - assert.deepEqual(id3v23.TYER, ['2004'], 'native: TYER'); - assert.deepEqual(id3v23['TXXX:PERFORMER'], ['Explosions In The Sky'], 'native: TXXX:PERFORMER'); - assert.deepEqual(id3v23['TXXX:PERFORMER'], ['Explosions In The Sky'], 'native: TXXX:PERFORMER'); - - const apic = id3v23.APIC[0] as IPicture; - assert.strictEqual(apic.format, 'image/jpeg', 'raw APIC format'); - assert.strictEqual(apic.type, 'Cover (front)', 'raw APIC tagTypes'); - assert.strictEqual(apic.description, '', 'raw APIC description'); - assert.strictEqual(apic.data.length, 80938, 'raw APIC length'); - } - - const metadata = await mm.parseFile(filePath, {duration: true}); - checkFormat(metadata.format); - checkCommon(metadata.common); - checkID3v1(mm.orderTags(metadata.native.ID3v1)); - checkID3v23(mm.orderTags(metadata.native['ID3v2.3'])); - - }); - - describe('corrupt header / tags', () => { - - it('should decode corrupt ID3v2.3 header: \'Strawberry\'', () => { - - /** - * Kept 25 frames from original MP3; concatenated copied last 128 bytes to restore ID3v1.0 header - */ - const filePath = path.join(samplePath, '04-Strawberry.mp3'); - - function checkFormat(format: mm.IFormat) { - assert.strictEqual(format.duration, 247.84979591836733, 'format.duration'); - assert.deepEqual(format.tagTypes, ['ID3v2.3'], 'format.tagTypes'); - assert.strictEqual(format.container, 'MPEG', 'format.container'); - assert.strictEqual(format.codec, 'MPEG 1 Layer 3', 'format.codec'); - assert.strictEqual(format.lossless, false, 'format.lossless'); - assert.strictEqual(format.sampleRate, 44100, 'format.sampleRate = 44.1 kHz'); - assert.strictEqual(format.bitrate, 128000, 'format.bitrate = 128 bit/sec'); - assert.strictEqual(format.numberOfChannels, 2, 'format.numberOfChannels 2 (stereo)'); - } - - function checkCommon(common: mm.ICommonTagsResult) { - assert.strictEqual(common.title, 'Strawberry', 'common.title'); - assert.strictEqual(common.artist, 'Union Youth', 'common.artist'); - assert.strictEqual(common.album, 'The Royal Gene', 'common.album'); - assert.strictEqual(common.albumartist, undefined, 'common.albumartist'); - assert.strictEqual(common.year, 2002, 'common.year'); - assert.deepEqual(common.track, {no: 4, of: null}, 'common.track = 4/?'); - assert.strictEqual(common.track.of, null, 'common.track.of = null'); - assert.deepEqual(common.genre, ['Alternative'], 'common.genre'); - assert.isUndefined(common.comment, 'common.comment'); - } - - return mm.parseFile(filePath).then(result => { - checkFormat(result.format); - checkCommon(result.common); - }); - - }); - - it('should decode PeakValue without data', async () => { - - const filePath = path.join(samplePath, 'issue_56.mp3'); - - const metadata = await mm.parseFile(filePath, {duration: true}); - assert.deepEqual(metadata.format.tagTypes, ['ID3v2.3', 'APEv2', 'ID3v1'], 'format.tagTypes'); // ToDo: has hale APEv2 tag header - }); - - }); - - /** - * id3v2.4 defines that multiple T* values are separated by 0x00 - * id3v2.3 defines that multiple T* values are separated by / - * Related issue: https://github.com/Borewit/music-metadata/issues/52 - * Specification: http://id3.org/id3v2.3.0#line-290 - */ - it('slash delimited fields', async () => { - const filePath = path.join(samplePath, 'Their - They\'re - Therapy - 1sec.mp3'); - - const metadata = await mm.parseFile(filePath); - assert.isDefined(metadata.native['ID3v2.3'], 'Expect ID3v2.3 tag'); - const id3v23 = mm.orderTags(metadata.native['ID3v2.3']); - // It should not split the id3v23.TIT2 tag (containing '/') - assert.deepEqual(id3v23.TIT2, ['Their / They\'re / Therapy'], 'id3v23.TIT2'); - // The artist name is actually "Their / They're / There" - // Specification: http://id3.org/id3v2.3.0#line-455 - assert.deepEqual(id3v23.TPE1, ['Their', 'They\'re', 'There'], 'id3v23.TPE1'); - }); - - it('null delimited fields (non-standard)', async () => { - - const filePath = path.join(samplePath, 'mp3', 'null-separator.id3v2.3.mp3'); - - const {format, common, native, quality} = await mm.parseFile(filePath); - - assert.strictEqual(format.container, 'MPEG', 'format.container'); - assert.strictEqual(format.codec, 'MPEG 1 Layer 3', 'format.codec'); - assert.deepEqual(format.tagTypes, ['ID3v2.3'], 'format.tagTypes'); - - const id3v23 = mm.orderTags(native['ID3v2.3']); - assert.deepEqual(id3v23.TPE1, ['2 Unlimited2', 'Ray', 'Anita'], 'null separated id3v23.TPE1'); - - assert.deepEqual(common.artists, ['2 Unlimited2', 'Ray', 'Anita'], 'common.artists'); - assert.isDefined(common.comment, 'common.comment'); - assert.deepEqual(common.comment, [ - { - descriptor: "", - language: "eng", - text: "[DJSet]" - }, - { - descriptor: "", - language: "eng", - text: "[All]" - } - ], 'common.comment'); - assert.deepEqual(common.genre, ['Dance', 'Classics'], 'common.genre'); - - ['TPE1', 'TCOM', 'TCON'].forEach(tag => { - assert.includeDeepMembers(quality.warnings, [{message: `ID3v2.3 ${tag} uses non standard null-separator.`}], `expect warning: null separator ID3v2.3 ${tag}`); - }); - }); - - describe('4.2.1 Text information frames', () => { - - // http://id3.org/id3v2.3.0#line-299 - it('TCON: Content type (genres)', async () => { - const filePath = path.join(samplePath, 'mp3', 'tcon.mp3'); - const {format, common} = await mm.parseFile(filePath); - assert.strictEqual(format.container, 'MPEG', 'format.container'); - assert.strictEqual(format.codec, 'MPEG 2 Layer 3', 'format.codec'); - assert.deepStrictEqual(common.genre, ['Electronic', 'Pop-Folk'], 'common.genre'); - }); - - }); - - describe('Decode frames', () => { - - // http://id3.org/id3v2.3.0#URL_link_frames_-_details - it('4.3.1 WCOM: Commercial information', async () => { - const metadata = await mm.parseFile(path.join(samplePath, 'id3v2-lyrics.mp3')); - const id3v23 = mm.orderTags(metadata.native['ID3v2.3']); - /* eslint-disable max-len */ - assert.deepEqual(id3v23.WCOM[0], 'http://www.amazon.com/Rotation-Cute-What-We-Aim/dp/B0018QCXAU%3FSubscriptionId%3D0R6CGKPJ3EKNPQBPYJR2%26tag%3Dsoftpointer-20%26linkCode%3Dxm2%26camp%3D2025%26creative%3D165953%26creativeASIN%3DB0018QCXAU'); - }); - - describe('4.3.2 WXXX: User defined URL link frame', () => { - - // http://id3.org/id3v2.3.0#User_defined_URL_link_frame - it('decoding #1', async () => { - const metadata = await mm.parseFile(path.join(samplePath, 'bug-unkown encoding.mp3')); - const id3v23 = mm.orderTags(metadata.native['ID3v2.3']); - assert.deepEqual(id3v23.WXXX[0], { - description: 'Tempa at bleep', - url: 'http://www.bleep.com/tempa' - }); - }); - - it('decoding #2', async () => { - - const filePath = path.join(samplePath, 'mp3', 'issue-453.mp3'); - - const metadata = await mm.parseFile(filePath); - assert.deepEqual(metadata.format.tagTypes, ['ID3v2.3', 'ID3v1']); - - const id3 = mm.orderTags(metadata.native['ID3v2.3']); - assert.deepEqual(id3.WXXX[0], { - description: 'あ', - url: 'https://www.example.com' - }); - }); - - }); - - // http://id3.org/id3v2.3.0#Music_CD_identifier - it('4.5 MCDI: Music CD identifier', async () => { - const metadata = await mm.parseFile(path.join(samplePath, '04-Strawberry.mp3')); - const id3v23 = mm.orderTags(metadata.native['ID3v2.3']); - assert.equal((id3v23.MCDI[0] as string).length, 804, 'TOC'); - }); - - // https://id3.org/id3v2.3.0#Unsychronised_lyrics.2Ftext_transcription - it('4.9 USLT: Unsychronised lyrics/text transcription', async () => { - - const expectedLyricsText = "Lord, have mercy, Lord, have mercy on me\nLord, have mercy, Lord, have mercy on me\n" + - "Well, if I've done somebody wrong\nLord, have mercy if you please\n\n" + - "I used to have plenty of money\nThe finest clothes in town\n" + - "Bad luck and trouble overtook me\nAnd God, look at me now\n\n" + - "Please have mercy, Lord, have mercy on me\nAnd if I've done somebody wrong\nLord, have mercy if you please\n\n" + - "Keep on working, my child\nOh, in the morning, oh\nLord, have mercy\n\nIf I've been a bad girl, baby\nYeah, I'll change my ways\n" + - "Don't want bad luck and trouble\nOn me all my days\n\n" + - "Please have mercy, Lord, have mercy on me\nAnd if I've done somebody wrong\nLord, have mercy if you please\n" + - "Have mercy on me"; - - const {native, common} = await mm.parseFile(path.join(samplePath, 'MusicBrainz - Beth Hart - Sinner\'s Prayer [id3v2.3].V2.mp3')); - - const id3v23 = mm.orderTags(native['ID3v2.3']); - assert.isDefined(id3v23.USLT, 'Should contain ID3v2.3 USLT tag'); - assert.strictEqual(id3v23.USLT.length,1, 'id3v23.USLT.length'); - const uslt = id3v23.USLT[0] as ILyricsTag; - assert.strictEqual(uslt.descriptor, "Sinner's Prayer", 'id3v23.USLT.description'); - assert.strictEqual(uslt.language, "eng", 'id3v23.USLT.language'); - assert.isDefined(uslt.text, 'id3v23.USLT.text'); - assert.strictEqual(uslt.text, expectedLyricsText, 'id3v23.USLT.text'); - - // Check mapping to common IDv2.3 tag - assert.isDefined(common.lyrics, 'Should map tag id3v23.USLT to common.lyrics'); - const lyrics = common.lyrics[0]; - assert.strictEqual(lyrics.descriptor, "Sinner's Prayer", 'common.lyrics.descriptor'); - assert.strictEqual(lyrics.language, "eng", 'common.lyrics.language'); - assert.isDefined(lyrics.text, 'common.lyrics.text'); - assert.strictEqual(lyrics.text, expectedLyricsText, 'common.lyrics.text'); - }); - - // Issue: https://github.com/Borewit/music-metadata/issues/2134 - it('4.10. Synchronised lyrics/text', async () => { - const filePath = path.join(samplePath, 'mp3', 'menu-sash.mp3'); - - const {format, native} = await mm.parseFile(filePath); - - assert.strictEqual(format.container, 'MPEG'); - assert.strictEqual(format.codec, 'MPEG 1 Layer 3'); - assert.isDefined(native['ID3v2.3'], 'Presence of ID3v2.3 tag header'); - const id3v23 = mm.orderTags(native['ID3v2.3']); - const syltTags = id3v23.SYLT as ILyricsTag[]; - assert.strictEqual(syltTags.length, 3, 'Number of SYLT tags'); - - [syltTags[0], syltTags[1]].forEach(sylt => { - assert.strictEqual(sylt.descriptor, 'captions', 'id3v23.sylt.descriptor'); - assert.strictEqual(sylt.timeStampFormat, TimestampFormat.milliseconds, 'id3v23.sylt.timeStampFormat in milliseconds'); - assert.strictEqual(sylt.language, 'eng', 'id3v23.sylt.language'); - assert.strictEqual(sylt.contentType, LyricsContentType.text, 'id3v23.sylt.contentType'); - assert.deepEqual(sylt.syncText, [ - { - text: 'Check out your sash!', - timestamp: 9 - } - ], 'id3v23.sylt.syncText'); - }); - - assert.strictEqual(syltTags[2].descriptor, 'lipsync', 'id3v23.sylt.descriptor'); - assert.strictEqual(syltTags[2].timeStampFormat, TimestampFormat.milliseconds, 'id3v23.sylt.timeStampFormat in milliseconds'); - assert.strictEqual(syltTags[2].language, 'eng', 'id3v23.sylt.language'); - assert.strictEqual(syltTags[2].contentType, LyricsContentType.other, 'id3v23.sylt.contentType'); - assert.deepEqual(syltTags[2].syncText, [ - { - text: 'X', - timestamp: 0 - }, { - text: 'F', - timestamp: 110 - }, { - text: 'C', - timestamp: 280 - }, { - text: 'F', - timestamp: 350 - }, { - text: 'B', - timestamp: 560 - }, { - text: 'C', - timestamp: 630 - }, { - text: 'B', - timestamp: 980 - }, { - text: 'X', - timestamp: 1120 - }], 'id3v23.sylt.syncText'); - }); - - // http://id3.org/id3v2.3.0#General_encapsulated_object - // Issue: https://github.com/Borewit/music-metadata/issues/406 - it('4.16 GEOB: General encapsulated object', async () => { - - const filePath = path.join(samplePath, 'mp3', 'issue-406-geob.mp3'); - - const {format, common, native} = await mm.parseFile(filePath); - - await mm.parseFile(filePath); - - assert.strictEqual(format.container, 'MPEG', 'format.container'); - assert.deepEqual(format.tagTypes, ['ID3v2.3'], 'format.tagTypes'); - assert.strictEqual(common.title, 'test', 'common.title'); - - assert.isDefined(native['ID3v2.3'], 'Presence of ID3v2.3 tag header'); - const id3v2 = mm.orderTags(native['ID3v2.3']); - const geob = id3v2.GEOB[0] as IGeneralEncapsulatedObject; - assert.deepEqual(geob.type, 'application/octet-stream', 'ID3v2.GEOB[0].type'); - assert.deepEqual(geob.filename, '', 'ID3v2.GEOB[0].filename'); - assert.deepEqual(geob.description, 'Serato Overview', 'ID3v2.GEOB[0].description'); - }); - - it('4.18 POPM', async () => { - - // Rating (stars) assigned with Winamp 5.666 Media Library. - const testCaseFiles = [ - {file: 'testcase-0star.mp3', stars: undefined}, - {file: 'testcase-1star.mp3', stars: 1}, - {file: 'testcase-2star.mp3', stars: 2}, - {file: 'testcase-3star.mp3', stars: 3}, - {file: 'testcase-4star.mp3', stars: 4}, - {file: 'testcase-5star.mp3', stars: 5} - ]; - - for (const testCaseFile of testCaseFiles) { - const filePath = path.join(samplePath, 'rating', testCaseFile.file); - const {common} = await mm.parseFile(filePath); - if (common.rating === undefined) { - assert.isUndefined(common.rating, `Expect no rating property to be present in: ${testCaseFile.file}`); - } else { - assert.isDefined(common.rating, `Expect rating property to be present in: ${testCaseFile.file}`); - assert.equal(Math.round(common.rating[0].rating * 4 + 1), testCaseFile.stars, `ID3v2.3 rating conversion in: ${testCaseFile.file}`); - assert.equal(mm.ratingToStars(common.rating[0].rating), testCaseFile.stars, `ID3v2.3 rating conversion in: ${testCaseFile.file}`); - } - } - }); - - describe('TXXX', async () => { - - it('Handle empty TXXX', async () => { - const {format, quality, common} = await mm.parseFile(path.join(samplePath, 'mp3', 'issue-471.mp3')); - - assert.strictEqual(format.container, 'MPEG', 'format.container'); - assert.strictEqual(format.codec, 'MPEG 1 Layer 3', 'format.codec'); - assert.approximately(format.duration, 309.629387755102, 1 / 200, 'format.duration'); - assert.strictEqual(format.sampleRate, 44100, 'format.sampleRate'); - assert.strictEqual(format.bitrate, 128000, 'format.bitrate'); - - assert.includeDeepMembers(quality.warnings, [{message: 'id3v2.3 header has empty tag type=TXXX'}], 'quality.warnings includes: \'id3v2.3 header has empty tag type=TXXX\''); - - assert.strictEqual(common.title, 'Between Worlds', 'common.title'); - assert.strictEqual(common.artist, 'Roger Subirana', 'common.artist'); - assert.strictEqual(common.album, 'XII', 'common.album'); - }); - }); - - describe('PRIV', async () => { - - it('Handle empty PRIV tag', async () => { - - const filePath = path.join(samplePath, 'mp3', 'issue-691.mp3'); - const {format, quality} = await mm.parseFile(filePath); - - assert.strictEqual(format.container, 'MPEG', 'format.container'); - assert.strictEqual(format.codec, 'MPEG 1 Layer 3', 'format.codec'); - - assert.includeDeepMembers(quality.warnings, [ - {message: 'id3v2.3 header has empty tag type=PRIV'}, - {message: 'Invalid ID3v2.3 frame-header-ID: \u0000\u0000\u0000\u0000'} - ], 'quality.warnings includes'); - - }); - - }); - - it('Handle ID32.2 tag ID\'s in ID32.3 header', async () => { - const filePath = path.join(samplePath, 'mp3', 'issue-795.mp3'); - - const {native, quality, common} = await mm.parseFile(filePath); - assert.isDefined(native['ID3v2.3'], 'native[\'ID3v2.3\']'); - const ids = native['ID3v2.3'].map(tag => { - return tag.id; - }); - assert.deepStrictEqual(ids, ['TP1\u0000', 'TP2\u0000', 'TAL\u0000', 'TEN\u0000', 'TIT2'], 'Decode id3v2.3 TAG names'); - - assert.includeDeepMembers(quality.warnings, [ - {message: 'Invalid ID3v2.3 frame-header-ID: TP1\u0000'}, - {message: 'Invalid ID3v2.3 frame-header-ID: TP2\u0000'}, - {message: 'Invalid ID3v2.3 frame-header-ID: TAL\u0000'}, - {message: 'Invalid ID3v2.3 frame-header-ID: TEN\u0000'} - ], 'Warning invalid ID: TP1\u0000, TP2\u0000, TAL\u0000 & TEN\u0000'); - - assert.strictEqual(common.title, 'FDP (Clean Edit)', 'common.title'); - }); - - it('GRP1', async () => { - - const filePath = path.join(samplePath, 'mp3', 'herbal-tea-GRP1.mp3'); - - const {native, common} = await mm.parseFile(filePath); - assert.isDefined(native['ID3v2.4'], 'Expect ID3v2.4 tag header to be present'); - const grp1Tags = native['ID3v2.4'].filter(tag => tag.id === 'GRP1'); - assert.strictEqual(grp1Tags.length, 1, 'Expect ID3v2.4 GRP1 tag be present'); - assert.strictEqual(grp1Tags[0].value, 'GRP1-Test', 'Expect ID3v2.4 GRP1 value'); - - assert.deepStrictEqual(common.grouping, 'GRP1-Test', 'Mapping ID3v2.4 GRP1 => common.grouping'); - }); - - }); - -}); +import { assert } from 'chai'; +import path from 'node:path'; +import * as strtok from 'strtok3'; + +import { ID3v2Parser } from '../lib/id3v2/ID3v2Parser.js'; +import { MetadataCollector } from '../lib/common/MetadataCollector.js'; +import * as mm from '../lib/index.js'; +import { samplePath } from './util.js'; +import { LyricsContentType, TimestampFormat, type ILyricsTag } from '../lib/core.js'; +import type { IPicture } from '../lib/index.js'; +import type { IGeneralEncapsulatedObject } from '../lib/id3v2/FrameParser.js'; + +describe('Extract metadata from ID3v2.3 header', () => { + + it('should parse a raw ID3v2.3 header', () => { + + const filePath = path.join(samplePath, 'MusicBrainz - Beth Hart - Sinner\'s Prayer.id3v23'); + + const metadata = new MetadataCollector({}); + + return strtok.fromFile(filePath).then(tokenizer => { + return new ID3v2Parser().parse(metadata, tokenizer, {}).then(() => { + + assert.strictEqual(33, metadata.native['ID3v2.3'].length); + + const id3v23 = mm.orderTags(metadata.native['ID3v2.3']); + assert.isDefined(id3v23.UFID, 'check if ID3v2.3-UFID is set'); + }); + }); + }); + + it('parse a ID3v2.3', async () => { + + const filePath = path.join(samplePath, 'id3v2.3.mp3'); + + function checkFormat(format) { + assert.deepEqual(format.tagTypes, ['ID3v2.3', 'ID3v1'], 'format.type'); + assert.strictEqual(format.duration, 0.7836734693877551, 'format.duration'); // FooBar says 0.732 seconds (32.727 samples) + assert.strictEqual(format.sampleRate, 44100, 'format.sampleRate = 44.1 kHz'); + assert.strictEqual(format.bitrate, 128000, 'format.bitrate = 128 kbit/sec'); + assert.strictEqual(format.numberOfChannels, 2, 'format.numberOfChannels 2 (stereo)'); + assert.strictEqual(format.container, 'MPEG', 'format.container'); + assert.strictEqual(format.codec, 'MPEG 1 Layer 3', 'format.codec'); + assert.strictEqual(format.tool, 'LAME 3.98r', 'format.tool'); + assert.strictEqual(format.codecProfile, 'CBR', 'format.codecProfile'); + } + + function checkCommon(common) { + assert.strictEqual(common.title, 'Home', 'common.title'); + assert.deepEqual(common.artists, ['Explosions In The Sky', 'Another', 'And Another'], 'common.artists'); + assert.strictEqual(common.albumartist, 'Soundtrack', 'common.albumartist'); + assert.strictEqual(common.album, 'Friday Night Lights [Original Movie Soundtrack]', 'common.album'); + assert.strictEqual(common.year, 2004, 'common.year'); + assert.strictEqual(common.track.no, 5, 'common.track.no'); + assert.strictEqual(common.track.of, null, 'common.track.of'); + assert.strictEqual(common.disk.no, 1, 'common.disk.no'); + assert.strictEqual(common.disk.of, 1, 'common.disk.of'); + assert.strictEqual(common.genre[0], 'Soundtrack', 'common.genre'); + assert.strictEqual(common.picture[0].format, 'image/jpeg', 'common.picture format'); + assert.strictEqual(common.picture[0].data.length, 80938, 'common.picture length'); + } + + function checkID3v1(id3v11: mm.INativeTagDict) { + + assert.deepEqual(id3v11.title, ['Home'], 'id3v11.title'); + assert.deepEqual(id3v11.album, ['Friday Night Lights [Original'], 'id3v11.album'); + assert.deepEqual(id3v11.artist, ['Explosions In The Sky/Another/'], 'id3v11.artist'); + assert.deepEqual(id3v11.genre, ['Soundtrack'], 'id3v11.genre'); + assert.deepEqual(id3v11.track, [5], 'id3v11.track'); + assert.deepEqual(id3v11.year, ['2004'], 'id3v11.year'); + } + + function checkID3v23(id3v23: mm.INativeTagDict) { + + assert.deepEqual(id3v23.TALB, ['Friday Night Lights [Original Movie Soundtrack]'], 'native: TALB'); + assert.deepEqual(id3v23.TPE1, ['Explosions In The Sky', 'Another', 'And Another'], 'native: TPE1'); + assert.deepEqual(id3v23.TPE2, ['Soundtrack'], 'native: TPE2'); + assert.deepEqual(id3v23.TCOM, ['Explosions in the Sky'], 'native: TCOM'); + assert.deepEqual(id3v23.TPOS, ['1/1'], 'native: TPOS'); + assert.deepEqual(id3v23.TCON, ['Soundtrack'], 'native: TCON'); + assert.deepEqual(id3v23.TIT2, ['Home'], 'native: TIT2'); + assert.deepEqual(id3v23.TRCK, ['5'], 'native: TRCK'); + assert.deepEqual(id3v23.TYER, ['2004'], 'native: TYER'); + assert.deepEqual(id3v23['TXXX:PERFORMER'], ['Explosions In The Sky'], 'native: TXXX:PERFORMER'); + assert.deepEqual(id3v23['TXXX:PERFORMER'], ['Explosions In The Sky'], 'native: TXXX:PERFORMER'); + + const apic = id3v23.APIC[0] as IPicture; + assert.strictEqual(apic.format, 'image/jpeg', 'raw APIC format'); + assert.strictEqual(apic.type, 'Cover (front)', 'raw APIC tagTypes'); + assert.strictEqual(apic.description, '', 'raw APIC description'); + assert.strictEqual(apic.data.length, 80938, 'raw APIC length'); + } + + const metadata = await mm.parseFile(filePath, {duration: true}); + checkFormat(metadata.format); + checkCommon(metadata.common); + checkID3v1(mm.orderTags(metadata.native.ID3v1)); + checkID3v23(mm.orderTags(metadata.native['ID3v2.3'])); + + }); + + describe('corrupt header / tags', () => { + + it('should decode corrupt ID3v2.3 header: \'Strawberry\'', () => { + + /** + * Kept 25 frames from original MP3; concatenated copied last 128 bytes to restore ID3v1.0 header + */ + const filePath = path.join(samplePath, '04-Strawberry.mp3'); + + function checkFormat(format: mm.IFormat) { + assert.strictEqual(format.duration, 247.84979591836733, 'format.duration'); + assert.deepEqual(format.tagTypes, ['ID3v2.3'], 'format.tagTypes'); + assert.strictEqual(format.container, 'MPEG', 'format.container'); + assert.strictEqual(format.codec, 'MPEG 1 Layer 3', 'format.codec'); + assert.strictEqual(format.lossless, false, 'format.lossless'); + assert.strictEqual(format.sampleRate, 44100, 'format.sampleRate = 44.1 kHz'); + assert.strictEqual(format.bitrate, 128000, 'format.bitrate = 128 bit/sec'); + assert.strictEqual(format.numberOfChannels, 2, 'format.numberOfChannels 2 (stereo)'); + } + + function checkCommon(common: mm.ICommonTagsResult) { + assert.strictEqual(common.title, 'Strawberry', 'common.title'); + assert.strictEqual(common.artist, 'Union Youth', 'common.artist'); + assert.strictEqual(common.album, 'The Royal Gene', 'common.album'); + assert.strictEqual(common.albumartist, undefined, 'common.albumartist'); + assert.strictEqual(common.year, 2002, 'common.year'); + assert.deepEqual(common.track, {no: 4, of: null}, 'common.track = 4/?'); + assert.strictEqual(common.track.of, null, 'common.track.of = null'); + assert.deepEqual(common.genre, ['Alternative'], 'common.genre'); + assert.isUndefined(common.comment, 'common.comment'); + } + + return mm.parseFile(filePath).then(result => { + checkFormat(result.format); + checkCommon(result.common); + }); + + }); + + it('should decode PeakValue without data', async () => { + + const filePath = path.join(samplePath, 'issue_56.mp3'); + + const metadata = await mm.parseFile(filePath, {duration: true}); + assert.deepEqual(metadata.format.tagTypes, ['ID3v2.3', 'APEv2', 'ID3v1'], 'format.tagTypes'); // ToDo: has hale APEv2 tag header + }); + + }); + + /** + * id3v2.4 defines that multiple T* values are separated by 0x00 + * id3v2.3 defines that multiple T* values are separated by / + * Related issue: https://github.com/Borewit/music-metadata/issues/52 + * Specification: http://id3.org/id3v2.3.0#line-290 + */ + it('slash delimited fields', async () => { + const filePath = path.join(samplePath, 'Their - They\'re - Therapy - 1sec.mp3'); + + const metadata = await mm.parseFile(filePath); + assert.isDefined(metadata.native['ID3v2.3'], 'Expect ID3v2.3 tag'); + const id3v23 = mm.orderTags(metadata.native['ID3v2.3']); + // It should not split the id3v23.TIT2 tag (containing '/') + assert.deepEqual(id3v23.TIT2, ['Their / They\'re / Therapy'], 'id3v23.TIT2'); + // The artist name is actually "Their / They're / There" + // Specification: http://id3.org/id3v2.3.0#line-455 + assert.deepEqual(id3v23.TPE1, ['Their', 'They\'re', 'There'], 'id3v23.TPE1'); + }); + + it('null delimited fields (non-standard)', async () => { + + const filePath = path.join(samplePath, 'mp3', 'null-separator.id3v2.3.mp3'); + + const {format, common, native, quality} = await mm.parseFile(filePath); + + assert.strictEqual(format.container, 'MPEG', 'format.container'); + assert.strictEqual(format.codec, 'MPEG 1 Layer 3', 'format.codec'); + assert.deepEqual(format.tagTypes, ['ID3v2.3'], 'format.tagTypes'); + + const id3v23 = mm.orderTags(native['ID3v2.3']); + assert.deepEqual(id3v23.TPE1, ['2 Unlimited2', 'Ray', 'Anita'], 'null separated id3v23.TPE1'); + + assert.deepEqual(common.artists, ['2 Unlimited2', 'Ray', 'Anita'], 'common.artists'); + assert.isDefined(common.comment, 'common.comment'); + assert.deepEqual(common.comment, [ + { + descriptor: "", + language: "eng", + text: "[DJSet]" + }, + { + descriptor: "", + language: "eng", + text: "[All]" + } + ], 'common.comment'); + assert.deepEqual(common.genre, ['Dance', 'Classics'], 'common.genre'); + + ['TPE1', 'TCOM', 'TCON'].forEach(tag => { + assert.includeDeepMembers(quality.warnings, [{message: `ID3v2.3 ${tag} uses non standard null-separator.`}], `expect warning: null separator ID3v2.3 ${tag}`); + }); + }); + + describe('4.2.1 Text information frames', () => { + + // http://id3.org/id3v2.3.0#line-299 + it('TCON: Content type (genres)', async () => { + const filePath = path.join(samplePath, 'mp3', 'tcon.mp3'); + const {format, common} = await mm.parseFile(filePath); + assert.strictEqual(format.container, 'MPEG', 'format.container'); + assert.strictEqual(format.codec, 'MPEG 2 Layer 3', 'format.codec'); + assert.deepStrictEqual(common.genre, ['Electronic', 'Pop-Folk'], 'common.genre'); + }); + + }); + + describe('Decode frames', () => { + + // http://id3.org/id3v2.3.0#URL_link_frames_-_details + it('4.3.1 WCOM: Commercial information', async () => { + const metadata = await mm.parseFile(path.join(samplePath, 'id3v2-lyrics.mp3')); + const id3v23 = mm.orderTags(metadata.native['ID3v2.3']); + /* eslint-disable max-len */ + assert.deepEqual(id3v23.WCOM[0], 'http://www.amazon.com/Rotation-Cute-What-We-Aim/dp/B0018QCXAU%3FSubscriptionId%3D0R6CGKPJ3EKNPQBPYJR2%26tag%3Dsoftpointer-20%26linkCode%3Dxm2%26camp%3D2025%26creative%3D165953%26creativeASIN%3DB0018QCXAU'); + }); + + describe('4.3.2 WXXX: User defined URL link frame', () => { + + // http://id3.org/id3v2.3.0#User_defined_URL_link_frame + it('decoding #1', async () => { + const metadata = await mm.parseFile(path.join(samplePath, 'bug-unkown encoding.mp3')); + const id3v23 = mm.orderTags(metadata.native['ID3v2.3']); + assert.deepEqual(id3v23.WXXX[0], { + description: 'Tempa at bleep', + url: 'http://www.bleep.com/tempa' + }); + }); + + it('decoding #2', async () => { + + const filePath = path.join(samplePath, 'mp3', 'issue-453.mp3'); + + const metadata = await mm.parseFile(filePath); + assert.deepEqual(metadata.format.tagTypes, ['ID3v2.3', 'ID3v1']); + + const id3 = mm.orderTags(metadata.native['ID3v2.3']); + assert.deepEqual(id3.WXXX[0], { + description: 'あ', + url: 'https://www.example.com' + }); + }); + + }); + + // http://id3.org/id3v2.3.0#Music_CD_identifier + it('4.5 MCDI: Music CD identifier', async () => { + const metadata = await mm.parseFile(path.join(samplePath, '04-Strawberry.mp3')); + const id3v23 = mm.orderTags(metadata.native['ID3v2.3']); + assert.equal((id3v23.MCDI[0] as string).length, 804, 'TOC'); + }); + + // https://id3.org/id3v2.3.0#Unsychronised_lyrics.2Ftext_transcription + it('4.9 USLT: Unsychronised lyrics/text transcription', async () => { + + const expectedLyricsText = "Lord, have mercy, Lord, have mercy on me\nLord, have mercy, Lord, have mercy on me\n" + + "Well, if I've done somebody wrong\nLord, have mercy if you please\n\n" + + "I used to have plenty of money\nThe finest clothes in town\n" + + "Bad luck and trouble overtook me\nAnd God, look at me now\n\n" + + "Please have mercy, Lord, have mercy on me\nAnd if I've done somebody wrong\nLord, have mercy if you please\n\n" + + "Keep on working, my child\nOh, in the morning, oh\nLord, have mercy\n\nIf I've been a bad girl, baby\nYeah, I'll change my ways\n" + + "Don't want bad luck and trouble\nOn me all my days\n\n" + + "Please have mercy, Lord, have mercy on me\nAnd if I've done somebody wrong\nLord, have mercy if you please\n" + + "Have mercy on me"; + + const {native, common} = await mm.parseFile(path.join(samplePath, 'MusicBrainz - Beth Hart - Sinner\'s Prayer [id3v2.3].V2.mp3')); + + const id3v23 = mm.orderTags(native['ID3v2.3']); + assert.isDefined(id3v23.USLT, 'Should contain ID3v2.3 USLT tag'); + assert.strictEqual(id3v23.USLT.length,1, 'id3v23.USLT.length'); + const uslt = id3v23.USLT[0] as ILyricsTag; + assert.strictEqual(uslt.descriptor, "Sinner's Prayer", 'id3v23.USLT.description'); + assert.strictEqual(uslt.language, "eng", 'id3v23.USLT.language'); + assert.isDefined(uslt.text, 'id3v23.USLT.text'); + assert.strictEqual(uslt.text, expectedLyricsText, 'id3v23.USLT.text'); + + // Check mapping to common IDv2.3 tag + assert.isDefined(common.lyrics, 'Should map tag id3v23.USLT to common.lyrics'); + const lyrics = common.lyrics[0]; + assert.strictEqual(lyrics.descriptor, "Sinner's Prayer", 'common.lyrics.descriptor'); + assert.strictEqual(lyrics.language, "eng", 'common.lyrics.language'); + assert.isDefined(lyrics.text, 'common.lyrics.text'); + assert.strictEqual(lyrics.text, expectedLyricsText, 'common.lyrics.text'); + }); + + // Issue: https://github.com/Borewit/music-metadata/issues/2134 + it('4.10. Synchronised lyrics/text', async () => { + const filePath = path.join(samplePath, 'mp3', 'menu-sash.mp3'); + + const {format, native} = await mm.parseFile(filePath); + + assert.strictEqual(format.container, 'MPEG'); + assert.strictEqual(format.codec, 'MPEG 1 Layer 3'); + assert.isDefined(native['ID3v2.3'], 'Presence of ID3v2.3 tag header'); + const id3v23 = mm.orderTags(native['ID3v2.3']); + const syltTags = id3v23.SYLT as ILyricsTag[]; + assert.strictEqual(syltTags.length, 3, 'Number of SYLT tags'); + + [syltTags[0], syltTags[1]].forEach(sylt => { + assert.strictEqual(sylt.descriptor, 'captions', 'id3v23.sylt.descriptor'); + assert.strictEqual(sylt.timeStampFormat, TimestampFormat.milliseconds, 'id3v23.sylt.timeStampFormat in milliseconds'); + assert.strictEqual(sylt.language, 'eng', 'id3v23.sylt.language'); + assert.strictEqual(sylt.contentType, LyricsContentType.text, 'id3v23.sylt.contentType'); + assert.deepEqual(sylt.syncText, [ + { + text: 'Check out your sash!', + timestamp: 9 + } + ], 'id3v23.sylt.syncText'); + }); + + assert.strictEqual(syltTags[2].descriptor, 'lipsync', 'id3v23.sylt.descriptor'); + assert.strictEqual(syltTags[2].timeStampFormat, TimestampFormat.milliseconds, 'id3v23.sylt.timeStampFormat in milliseconds'); + assert.strictEqual(syltTags[2].language, 'eng', 'id3v23.sylt.language'); + assert.strictEqual(syltTags[2].contentType, LyricsContentType.other, 'id3v23.sylt.contentType'); + assert.deepEqual(syltTags[2].syncText, [ + { + text: 'X', + timestamp: 0 + }, { + text: 'F', + timestamp: 110 + }, { + text: 'C', + timestamp: 280 + }, { + text: 'F', + timestamp: 350 + }, { + text: 'B', + timestamp: 560 + }, { + text: 'C', + timestamp: 630 + }, { + text: 'B', + timestamp: 980 + }, { + text: 'X', + timestamp: 1120 + }], 'id3v23.sylt.syncText'); + }); + + // http://id3.org/id3v2.3.0#General_encapsulated_object + // Issue: https://github.com/Borewit/music-metadata/issues/406 + it('4.16 GEOB: General encapsulated object', async () => { + + const filePath = path.join(samplePath, 'mp3', 'issue-406-geob.mp3'); + + const {format, common, native} = await mm.parseFile(filePath); + + await mm.parseFile(filePath); + + assert.strictEqual(format.container, 'MPEG', 'format.container'); + assert.deepEqual(format.tagTypes, ['ID3v2.3'], 'format.tagTypes'); + assert.strictEqual(common.title, 'test', 'common.title'); + + assert.isDefined(native['ID3v2.3'], 'Presence of ID3v2.3 tag header'); + const id3v2 = mm.orderTags(native['ID3v2.3']); + const geob = id3v2.GEOB[0] as IGeneralEncapsulatedObject; + assert.deepEqual(geob.type, 'application/octet-stream', 'ID3v2.GEOB[0].type'); + assert.deepEqual(geob.filename, '', 'ID3v2.GEOB[0].filename'); + assert.deepEqual(geob.description, 'Serato Overview', 'ID3v2.GEOB[0].description'); + }); + + it('4.18 POPM', async () => { + + // Rating (stars) assigned with Winamp 5.666 Media Library. + const testCaseFiles = [ + {file: 'testcase-0star.mp3', stars: undefined}, + {file: 'testcase-1star.mp3', stars: 1}, + {file: 'testcase-2star.mp3', stars: 2}, + {file: 'testcase-3star.mp3', stars: 3}, + {file: 'testcase-4star.mp3', stars: 4}, + {file: 'testcase-5star.mp3', stars: 5} + ]; + + for (const testCaseFile of testCaseFiles) { + const filePath = path.join(samplePath, 'rating', testCaseFile.file); + const {common} = await mm.parseFile(filePath); + if (common.rating === undefined) { + assert.isUndefined(common.rating, `Expect no rating property to be present in: ${testCaseFile.file}`); + } else { + assert.isDefined(common.rating, `Expect rating property to be present in: ${testCaseFile.file}`); + assert.equal(Math.round(common.rating[0].rating * 4 + 1), testCaseFile.stars, `ID3v2.3 rating conversion in: ${testCaseFile.file}`); + assert.equal(mm.ratingToStars(common.rating[0].rating), testCaseFile.stars, `ID3v2.3 rating conversion in: ${testCaseFile.file}`); + } + } + }); + + describe('TXXX', async () => { + + it('Handle empty TXXX', async () => { + const {format, quality, common} = await mm.parseFile(path.join(samplePath, 'mp3', 'issue-471.mp3')); + + assert.strictEqual(format.container, 'MPEG', 'format.container'); + assert.strictEqual(format.codec, 'MPEG 1 Layer 3', 'format.codec'); + assert.approximately(format.duration, 309.629387755102, 1 / 200, 'format.duration'); + assert.strictEqual(format.sampleRate, 44100, 'format.sampleRate'); + assert.strictEqual(format.bitrate, 128000, 'format.bitrate'); + + assert.includeDeepMembers(quality.warnings, [{message: 'id3v2.3 header has empty tag type=TXXX'}], 'quality.warnings includes: \'id3v2.3 header has empty tag type=TXXX\''); + + assert.strictEqual(common.title, 'Between Worlds', 'common.title'); + assert.strictEqual(common.artist, 'Roger Subirana', 'common.artist'); + assert.strictEqual(common.album, 'XII', 'common.album'); + }); + }); + + describe('PRIV', async () => { + + it('Handle empty PRIV tag', async () => { + + const filePath = path.join(samplePath, 'mp3', 'issue-691.mp3'); + const {format, quality} = await mm.parseFile(filePath); + + assert.strictEqual(format.container, 'MPEG', 'format.container'); + assert.strictEqual(format.codec, 'MPEG 1 Layer 3', 'format.codec'); + + assert.includeDeepMembers(quality.warnings, [ + {message: 'id3v2.3 header has empty tag type=PRIV'}, + {message: 'Invalid ID3v2.3 frame-header-ID: \u0000\u0000\u0000\u0000'} + ], 'quality.warnings includes'); + + }); + + }); + + it('Handle ID32.2 tag ID\'s in ID32.3 header', async () => { + const filePath = path.join(samplePath, 'mp3', 'issue-795.mp3'); + + const {native, quality, common} = await mm.parseFile(filePath); + assert.isDefined(native['ID3v2.3'], 'native[\'ID3v2.3\']'); + const ids = native['ID3v2.3'].map(tag => { + return tag.id; + }); + assert.deepStrictEqual(ids, ['TP1\u0000', 'TP2\u0000', 'TAL\u0000', 'TEN\u0000', 'TIT2'], 'Decode id3v2.3 TAG names'); + + assert.includeDeepMembers(quality.warnings, [ + {message: 'Invalid ID3v2.3 frame-header-ID: TP1\u0000'}, + {message: 'Invalid ID3v2.3 frame-header-ID: TP2\u0000'}, + {message: 'Invalid ID3v2.3 frame-header-ID: TAL\u0000'}, + {message: 'Invalid ID3v2.3 frame-header-ID: TEN\u0000'} + ], 'Warning invalid ID: TP1\u0000, TP2\u0000, TAL\u0000 & TEN\u0000'); + + assert.strictEqual(common.title, 'FDP (Clean Edit)', 'common.title'); + }); + + it('GRP1', async () => { + + const filePath = path.join(samplePath, 'mp3', 'herbal-tea-GRP1.mp3'); + + const {native, common} = await mm.parseFile(filePath); + assert.isDefined(native['ID3v2.4'], 'Expect ID3v2.4 tag header to be present'); + const grp1Tags = native['ID3v2.4'].filter(tag => tag.id === 'GRP1'); + assert.strictEqual(grp1Tags.length, 1, 'Expect ID3v2.4 GRP1 tag be present'); + assert.strictEqual(grp1Tags[0].value, 'GRP1-Test', 'Expect ID3v2.4 GRP1 value'); + + assert.deepStrictEqual(common.grouping, 'GRP1-Test', 'Mapping ID3v2.4 GRP1 => common.grouping'); + }); + + }); + +});