diff --git a/dev/demux.html b/dev/demux.html index 526b99c..53817d5 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -14,6 +14,9 @@ source: new Mediabunny.BlobSource(file), }); + console.log(await input.getMetadataTags()); + + /* console.log(await input.computeDuration()); return; @@ -25,6 +28,7 @@ } console.log("Done") + */ /* diff --git a/docs/index.md b/docs/index.md index 21fb5ba..23bb727 100644 --- a/docs/index.md +++ b/docs/index.md @@ -115,6 +115,7 @@ const sponsors = { { image: 'https://avatars.githubusercontent.com/u/61233224', name: 'Allwhy', url: 'https://github.com/Allwhy' }, { image: 'https://avatars.githubusercontent.com/u/97225946', name: '808vita', url: 'https://github.com/808vita' }, { image: 'https://avatars.githubusercontent.com/u/3709646', name: 'Rodrigo Belfiore', url: 'https://github.com/roprgm' }, + { image: 'https://avatars.githubusercontent.com/u/31102694', name: 'Aiden Liu', url: 'https://github.com/aidenlx' }, ], }; diff --git a/package-lock.json b/package-lock.json index 0e4a0a7..e2b0087 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mediabunny", - "version": "1.17.3", + "version": "1.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mediabunny", - "version": "1.17.3", + "version": "1.18.0", "license": "MPL-2.0", "workspaces": [ "packages/*" @@ -7749,9 +7749,9 @@ } }, "node_modules/mediabunny": { - "version": "1.17.2", - "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.17.2.tgz", - "integrity": "sha512-I7l/WDv7howETh0Vhc9J8Y0XZ1Z1qUpUEcud05Eqf/MeaaOT8FGb744sTXN6U4Rk5ZxFYRz7g3JHcA1XvsFkWg==", + "version": "1.17.3", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.17.3.tgz", + "integrity": "sha512-q17acLnaDMhZidQde9ZBGTO28pXJR4xtMo4ElU5JKafxV08bvN0gZiD3D2r7YUV49qaxeYsWJE2afYc19Rarfg==", "license": "MPL-2.0", "peer": true, "workspaces": [ @@ -12242,7 +12242,7 @@ }, "packages/mp3-encoder": { "name": "@mediabunny/mp3-encoder", - "version": "1.17.3", + "version": "1.18.0", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" diff --git a/package.json b/package.json index 127eba2..e0c53f9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mediabunny", "author": "Vanilagy", - "version": "1.17.3", + "version": "1.18.0", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "type": "module", "workspaces": [ diff --git a/packages/mp3-encoder/package.json b/packages/mp3-encoder/package.json index f7281e2..221fe7e 100644 --- a/packages/mp3-encoder/package.json +++ b/packages/mp3-encoder/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/mp3-encoder", "author": "Vanilagy", - "version": "1.17.3", + "version": "1.18.0", "description": "MP3 encoder extension for Mediabunny, based on LAME.", "main": "./dist/bundles/mediabunny-mp3-encoder.mjs", "module": "./dist/bundles/mediabunny-mp3-encoder.mjs", diff --git a/shared/mp3-misc.ts b/shared/mp3-misc.ts index 9ab974a..19fd947 100644 --- a/shared/mp3-misc.ts +++ b/shared/mp3-misc.ts @@ -52,11 +52,11 @@ export const computeMp3FrameSize = ( if (layer === 0) { return 0; // Not expected that this is hit } else if (layer === 1) { - return Math.round(144 * bitrate / (sampleRate << lowSamplingFrequency)) + padding; + return Math.floor(144 * bitrate / (sampleRate << lowSamplingFrequency)) + padding; } else if (layer === 2) { - return Math.round(144 * bitrate / sampleRate) + padding; + return Math.floor(144 * bitrate / sampleRate) + padding; } else { // layer === 3 - return (Math.round(12 * bitrate / sampleRate) + padding) * 4; + return (Math.floor(12 * bitrate / sampleRate) + padding) * 4; } }; diff --git a/src/index.ts b/src/index.ts index 2dc2c2f..9e6e655 100644 --- a/src/index.ts +++ b/src/index.ts @@ -197,6 +197,7 @@ export { MetadataTags, AttachedImage, RichImageData, + AttachedFile, } from './tags'; // 🐡🦔 diff --git a/src/input-format.ts b/src/input-format.ts index 6d9c258..5cfe902 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -289,7 +289,7 @@ export class Mp3InputFormat extends InputFormat { return true; } - currentPos = firstResult.startPos += firstResult.header.totalSize; + currentPos = firstResult.startPos + firstResult.header.totalSize; // Fine, we found one frame header, but we're still not entirely sure this is MP3. Let's check if we can find // another header right after it: diff --git a/src/matroska/ebml.ts b/src/matroska/ebml.ts index 2704ff7..14dd625 100644 --- a/src/matroska/ebml.ts +++ b/src/matroska/ebml.ts @@ -16,6 +16,7 @@ export interface EBMLElement { size?: number; data: | number + | bigint | string | Uint8Array | EBMLFloat32 @@ -198,6 +199,26 @@ export const measureUnsignedInt = (value: number) => { } }; +export const measureUnsignedBigInt = (value: bigint) => { + if (value < (1n << 8n)) { + return 1; + } else if (value < (1n << 16n)) { + return 2; + } else if (value < (1n << 24n)) { + return 3; + } else if (value < (1n << 32n)) { + return 4; + } else if (value < (1n << 40n)) { + return 5; + } else if (value < (1n << 48n)) { + return 6; + } else if (value < (1n << 56n)) { + return 7; + } else { + return 8; + } +}; + export const measureSignedInt = (value: number) => { if (value >= -(1 << 6) && value < (1 << 6)) { return 1; @@ -276,16 +297,16 @@ export class EBMLWriter { // eslint-disable-next-line no-fallthrough case 5: this.helperView.setUint8(pos++, (value / 2 ** 32) | 0); - // eslint-disable-next-line no-fallthrough + // eslint-disable-next-line no-fallthrough case 4: this.helperView.setUint8(pos++, value >> 24); - // eslint-disable-next-line no-fallthrough + // eslint-disable-next-line no-fallthrough case 3: this.helperView.setUint8(pos++, value >> 16); - // eslint-disable-next-line no-fallthrough + // eslint-disable-next-line no-fallthrough case 2: this.helperView.setUint8(pos++, value >> 8); - // eslint-disable-next-line no-fallthrough + // eslint-disable-next-line no-fallthrough case 1: this.helperView.setUint8(pos++, value); break; @@ -296,6 +317,16 @@ export class EBMLWriter { this.writer.write(this.helper.subarray(0, pos)); } + writeUnsignedBigInt(value: bigint, width = measureUnsignedBigInt(value)) { + let pos = 0; + + for (let i = width - 1; i >= 0; i--) { + this.helperView.setUint8(pos++, Number((value >> BigInt(i * 8)) & 0xffn)); + } + + this.writer.write(this.helper.subarray(0, pos)); + } + writeSignedInt(value: number, width = measureSignedInt(value)) { if (value < 0) { // Two's complement stuff @@ -398,6 +429,10 @@ export class EBMLWriter { const size = data.size ?? measureUnsignedInt(data.data); this.writeVarInt(size); this.writeUnsignedInt(data.data, size); + } else if (typeof data.data === 'bigint') { + const size = data.size ?? measureUnsignedBigInt(data.data); + this.writeVarInt(size); + this.writeUnsignedBigInt(data.data, size); } else if (typeof data.data === 'string') { this.writeVarInt(data.data.length); this.writeAsciiString(data.data); @@ -491,6 +526,21 @@ export const readUnsignedInt = (slice: FileSlice, width: number) => { return value; }; +export const readUnsignedBigInt = (slice: FileSlice, width: number) => { + if (width < 1) { + throw new Error('Bad unsigned int size ' + width); + } + + let value = 0n; + + for (let i = 0; i < width; i++) { + value <<= 8n; + value += BigInt(readU8(slice)); + } + + return value; +}; + export const readSignedInt = (slice: FileSlice, width: number) => { let value = readUnsignedInt(slice, width); diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index 979475e..06ee690 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -31,7 +31,7 @@ import { InputVideoTrack, InputVideoTrackBacking, } from '../input-track'; -import { MetadataTags } from '../tags'; +import { AttachedFile, MetadataTags } from '../tags'; import { PacketRetrievalOptions } from '../media-sink'; import { assert, @@ -69,6 +69,7 @@ import { readVarInt, resync, searchForNextElementId, + readUnsignedBigInt, } from './ebml'; import { buildMatroskaMimeType } from './matroska-misc'; import { FileSlice, readBytes, Reader, readI16Be, readU8 } from '../reader'; @@ -240,6 +241,7 @@ export class MatroskaDemuxer extends Demuxer { currentTagTargetIsMovie: boolean = true; currentSimpleTagName: string | null = null; currentAttachedFile: { + fileUid: bigint | null; fileName: string | null; fileMediaType: string | null; fileData: Uint8Array | null; @@ -1478,6 +1480,7 @@ export class MatroskaDemuxer extends Demuxer { if (!this.currentSegment) break; this.currentAttachedFile = { + fileUid: null, fileName: null, fileMediaType: null, fileData: null, @@ -1486,6 +1489,19 @@ export class MatroskaDemuxer extends Demuxer { this.readContiguousElements(slice.slice(dataStartPos, size)); + const tags = this.currentSegment.metadataTags; + + if (this.currentAttachedFile.fileUid && this.currentAttachedFile.fileData) { + // All attached files get surfaced in the `raw` metadata tags + tags.raw ??= {}; + tags.raw[this.currentAttachedFile.fileUid.toString()] = new AttachedFile( + this.currentAttachedFile.fileData, + this.currentAttachedFile.fileMediaType ?? undefined, + this.currentAttachedFile.fileName ?? undefined, + this.currentAttachedFile.fileDescription ?? undefined, + ); + } + // Only process image attachments if (this.currentAttachedFile.fileMediaType?.startsWith('image/') && this.currentAttachedFile.fileData) { const fileName = this.currentAttachedFile.fileName; @@ -1500,8 +1516,8 @@ export class MatroskaDemuxer extends Demuxer { } } - this.currentSegment.metadataTags.images ??= []; - this.currentSegment.metadataTags.images.push({ + tags.images ??= []; + tags.images.push({ data: this.currentAttachedFile.fileData, mimeType: this.currentAttachedFile.fileMediaType, kind, @@ -1513,6 +1529,12 @@ export class MatroskaDemuxer extends Demuxer { this.currentAttachedFile = null; }; break; + case EBMLId.FileUID: { + if (!this.currentAttachedFile) break; + + this.currentAttachedFile.fileUid = readUnsignedBigInt(slice, size); + }; break; + case EBMLId.FileName: { if (!this.currentAttachedFile) break; diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts index 04c9a42..c839e63 100644 --- a/src/matroska/matroska-muxer.ts +++ b/src/matroska/matroska-muxer.ts @@ -22,6 +22,7 @@ import { roundToMultiple, textEncoder, toUint8Array, + uint8ArraysAreEqual, writeBits, } from '../misc'; import { @@ -62,6 +63,7 @@ import { Muxer } from '../muxer'; import { Writer } from '../writer'; import { EncodedPacket } from '../packet'; import { parseOpusIdentificationHeader } from '../codec-data'; +import { AttachedFile } from '../tags'; const MIN_CLUSTER_TIMESTAMP_MS = -(2 ** 15); const MAX_CLUSTER_TIMESTAMP_MS = 2 ** 15 - 1; @@ -537,31 +539,35 @@ export class MatroskaMuxer extends Muxer { private maybeCreateAttachments() { const metadataTags = this.output._metadataTags; - if (!metadataTags.images || metadataTags.images.length === 0) { - return; - } + const elements: EBMLElement[] = []; - const existingFileUids = new Set(); + const existingFileUids = new Set(); + const images = metadataTags.images ?? []; - this.attachmentsElement = { id: EBMLId.Attachments, data: metadataTags.images.map((image): EBMLElement => { + for (const image of images) { let imageName = image.name; if (imageName === undefined) { const baseName = image.kind === 'coverFront' ? 'cover' : image.kind === 'coverBack' ? 'back' : 'image'; imageName = baseName + (imageMimeTypeToExtension(image.mimeType) ?? ''); } - let fileUid: number; + let fileUid: bigint; while (true) { - fileUid = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); + // Generate a random 64-bit unsigned integer + fileUid = 0n; + for (let i = 0; i < 8; i++) { + fileUid <<= 8n; + fileUid |= BigInt(Math.floor(Math.random() * 256)); + } - if (fileUid !== 0 && !existingFileUids.has(fileUid)) { + if (fileUid !== 0n && !existingFileUids.has(fileUid)) { break; } } existingFileUids.add(fileUid); - return { + elements.push({ id: EBMLId.AttachedFile, data: [ image.description !== undefined @@ -572,8 +578,45 @@ export class MatroskaMuxer extends Muxer { { id: EBMLId.FileData, data: image.data }, { id: EBMLId.FileUID, data: fileUid }, ], - }; - }) }; + }); + } + + // Add all AttachedFiles from the raw metadata + for (const [key, value] of Object.entries(metadataTags.raw ?? {})) { + if (!(value instanceof AttachedFile)) { + continue; + } + + const keyIsNumeric = /^\d+$/.test(key); + if (!keyIsNumeric) { + continue; + } + + if (images.find(x => x.mimeType === value.mimeType && uint8ArraysAreEqual(x.data, value.data))) { + // This attached file has very likely already been added as an image above + // (happens when remuxing Matroska) + continue; + } + + elements.push({ + id: EBMLId.AttachedFile, + data: [ + value.description !== undefined + ? { id: EBMLId.FileDescription, data: new EBMLUnicodeString(value.description) } + : null, + { id: EBMLId.FileName, data: new EBMLUnicodeString(value.name ?? '') }, + { id: EBMLId.FileMediaType, data: value.mimeType ?? '' }, + { id: EBMLId.FileData, data: value.data }, + { id: EBMLId.FileUID, data: BigInt(key) }, + ], + }); + } + + if (elements.length === 0) { + return; + } + + this.attachmentsElement = { id: EBMLId.Attachments, data: elements }; } private createSegment() { diff --git a/src/misc.ts b/src/misc.ts index 1c842f2..9b2ae38 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -519,26 +519,59 @@ export const SECOND_TO_MICROSECOND_FACTOR = 1e6 * (1 + Number.EPSILON); */ export type SetRequired = T & Required>; -export const mergeObjectsDeeply = (a: T, b: S): T & S => { - const result = { ...a } as T & S; +/** + * Merges two RequestInit objects with special handling for headers. + * Headers are merged case-insensitively, but original casing is preserved. + * init2 headers take precedence and will override case-insensitive matches from init1. + */ +export const mergeRequestInit = (init1: RequestInit, init2: RequestInit): RequestInit => { + const merged: RequestInit = { ...init1, ...init2 }; - for (const key in b) { - if ( - typeof a[key as unknown as keyof T] === 'object' - && a[key as unknown as keyof T] !== null - && typeof b[key] === 'object' - && b[key] !== null - ) { - result[key] = mergeObjectsDeeply( - a[key as unknown as keyof T] as object, - b[key], - ) as (T & S)[Extract]; - } else { - result[key] = b[key] as (T & S)[Extract]; - } + // Special handling for headers + if (init1.headers || init2.headers) { + const headers1 = init1.headers ? normalizeHeaders(init1.headers) : {}; + const headers2 = init2.headers ? normalizeHeaders(init2.headers) : {}; + + const mergedHeaders = { ...headers1 }; + + // For each header in headers2, check if a case-insensitive match exists in mergedHeaders + Object.entries(headers2).forEach(([key2, value2]) => { + const existingKey = Object.keys(mergedHeaders).find( + key1 => key1.toLowerCase() === key2.toLowerCase(), + ); + + if (existingKey) { + delete mergedHeaders[existingKey]; + } + + mergedHeaders[key2] = value2; + }); + + merged.headers = mergedHeaders; } - return result; + return merged; +}; + +/** Normalizes HeadersInit to a Record format. */ +const normalizeHeaders = (headers: HeadersInit): Record => { + if (headers instanceof Headers) { + const result: Record = {}; + headers.forEach((value, key) => { + result[key] = value; + }); + return result; + } + + if (Array.isArray(headers)) { + const result: Record = {}; + headers.forEach(([key, value]) => { + result[key] = value; + }); + return result; + } + + return headers; }; export const retriedFetch = async ( @@ -732,3 +765,17 @@ export const bytesToBase64 = (bytes: Uint8Array) => { return btoa(string); }; + +export const uint8ArraysAreEqual = (a: Uint8Array, b: Uint8Array) => { + if (a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + + return true; +}; diff --git a/src/source.ts b/src/source.ts index 6e548ce..6f65adb 100644 --- a/src/source.ts +++ b/src/source.ts @@ -12,7 +12,7 @@ import { binarySearchLessOrEqual, closedIntervalsOverlap, MaybePromise, - mergeObjectsDeeply, + mergeRequestInit, promiseWithResolvers, retriedFetch, toDataView, @@ -326,7 +326,7 @@ export class UrlSource extends Source { const abortController = new AbortController(); const response = await retriedFetch( this._url, - mergeObjectsDeeply(this._options.requestInit ?? {}, { + mergeRequestInit(this._options.requestInit ?? {}, { headers: { // We could also send a non-range request to request the same bytes (all of them), but doing it like // this is an easy way to check if the server supports range requests in the first place @@ -393,7 +393,7 @@ export class UrlSource extends Source { abortController = new AbortController(); response = await retriedFetch( this._url, - mergeObjectsDeeply(this._options.requestInit ?? {}, { + mergeRequestInit(this._options.requestInit ?? {}, { headers: { Range: `bytes=${worker.currentPos}-`, }, diff --git a/src/tags.ts b/src/tags.ts index 3b0803e..4e53a92 100644 --- a/src/tags.ts +++ b/src/tags.ts @@ -16,9 +16,9 @@ * - For MP3 files, the metadata refers to the ID3v2 or ID3v1 tags. * - For Ogg files, there is no global metadata so instead, the metadata refers to the combined metadata of all tracks, * in Vorbis-style comment headers. - * - For FLAC files, the metadata lives in Vorbis style in the Vorbis comment block. * - For WAVE files, the metadata refers to the chunks within the RIFF INFO chunk. * - For ADTS files, there is no metadata. + * - For FLAC files, the metadata lives in Vorbis style in the Vorbis comment block. * * @group Metadata tags * @public @@ -66,6 +66,8 @@ export type MetadataTags = { * Additionally, any atoms within the `'udta'` atom are dumped into here, however with unknown internal format * (`Uint8Array`). * - Matroska: `SimpleTag` elements whose target is 50 (MOVIE), either containing string or `Uint8Array` values. + * Additionally, all attached files (such as font files) are included here, where the key corresponds to the FileUID + * and the value is an {@link AttachedFile}. * - MP3: The ID3v2 tags, or a single `'TAG'` key with the contents of the ID3v1 tag. * - Ogg: The key-value string pairs from the Vorbis-style comment header (see RFC 7845, Section 5.2). * Additionally, the `'vendor'` key refers to the vendor string within this header. @@ -73,7 +75,7 @@ export type MetadataTags = { * - FLAC: The key-value string pairs from the vorbis metadata block (see RFC 9639, Section D.2.3). * Additionally, the `'vendor'` key refers to the vendor string within this header. */ - raw?: Record; + raw?: Record; }; /** @@ -91,7 +93,7 @@ export type AttachedImage = { kind: 'coverFront' | 'coverBack' | 'unknown'; /** The name of the image file. */ name?: string; - /** A short description of the image. */ + /** A description of the image. */ description?: string; }; @@ -108,9 +110,49 @@ export class RichImageData { public data: Uint8Array, /** An RFC 6838 MIME type (e.g. image/jpeg, image/png, etc.) */ public mimeType: string, - ) {} + ) { + if (!(data instanceof Uint8Array)) { + throw new TypeError('data must be a Uint8Array.'); + } + if (typeof mimeType !== 'string') { + throw new TypeError('mimeType must be a string.'); + } + } } +/** + * A file attached to a media file. + * + * @group Metadata tags + * @public + */ +export class AttachedFile { + /** Creates a new {@link AttachedFile}. */ + constructor( + /** The raw file data. */ + public data: Uint8Array, + /** An RFC 6838 MIME type (e.g. image/jpeg, image/png, font/ttf, etc.) */ + public mimeType?: string, + /** The name of the file. */ + public name?: string, + /** A description of the file. */ + public description?: string, + ) { + if (!(data instanceof Uint8Array)) { + throw new TypeError('data must be a Uint8Array.'); + } + if (mimeType !== undefined && typeof mimeType !== 'string') { + throw new TypeError('mimeType, when provided, must be a string.'); + } + if (name !== undefined && typeof name !== 'string') { + throw new TypeError('name, when provided, must be a string.'); + } + if (description !== undefined && typeof description !== 'string') { + throw new TypeError('description, when provided, must be a string.'); + } + } +}; + export const validateMetadataTags = (tags: MetadataTags) => { if (!tags || typeof tags !== 'object') { throw new TypeError('tags must be an object.'); @@ -190,9 +232,10 @@ export const validateMetadataTags = (tags: MetadataTags) => { && typeof value !== 'string' && !(value instanceof Uint8Array) && !(value instanceof RichImageData) + && !(value instanceof AttachedFile) ) { throw new TypeError( - 'Each value in tags.raw must be a string, Uint8Array, RichImageData, or null.', + 'Each value in tags.raw must be a string, Uint8Array, RichImageData, AttachedFile, or null.', ); } } diff --git a/test/node/metadata-tags.test.ts b/test/node/metadata-tags.test.ts index 9a62663..6957ec6 100644 --- a/test/node/metadata-tags.test.ts +++ b/test/node/metadata-tags.test.ts @@ -15,7 +15,7 @@ import { EncodedPacket } from '../../src/packet.js'; import { Input } from '../../src/input.js'; import { BufferSource, FilePathSource } from '../../src/source.js'; import { ALL_FORMATS } from '../../src/input-format.js'; -import { MetadataTags } from '../../src/tags.js'; +import { AttachedFile, MetadataTags } from '../../src/tags.js'; import path from 'node:path'; import { AudioCodec, buildAudioCodecString } from '../../src/codec.js'; import { Conversion } from '../../src/conversion.js'; @@ -207,7 +207,21 @@ test('Read and write metadata, Matroska', async () => { output.setMetadataTags({ ...songMetadata, raw: { - CUSTOM: 'Levels', + 'CUSTOM': 'Levels', + // This number cannot be represented by f64, so this tests correct BigInt usage + '9007199254740993': new AttachedFile( + new Uint8Array(20), + 'font/ttf', + 'Heya', + 'A font file', + ), + // And this one we don't expect to get attached again, since it mirrors the image we're also attaching + '987654': new AttachedFile( + songMetadata.images![0]!.data, + songMetadata.images![0]!.mimeType, + songMetadata.images![0]!.name, + songMetadata.images![0]!.description, + ), }, }); @@ -246,6 +260,19 @@ test('Read and write metadata, Matroska', async () => { expect(readTags.raw!['TITLE']).toBe(songMetadata.title); expect(readTags.raw!['PART_NUMBER']).toBe('13/14'); expect(readTags.raw!['CUSTOM']).toBe('Levels'); + + const files = Object.entries(readTags.raw!).filter(x => x[1] instanceof AttachedFile); + expect(files).toHaveLength(2); + + const imageFile = files.find(x => (x[1] as AttachedFile).mimeType === 'image/jpeg'); + expect((imageFile![1] as AttachedFile).data).toEqual(songMetadata.images![0]!.data); + + const secondFile = files.find(x => (x[1] as AttachedFile).mimeType === 'font/ttf'); + expect(secondFile![0]).toBe('9007199254740993'); + expect((secondFile![1] as AttachedFile).data).toHaveLength(20); + expect((secondFile![1] as AttachedFile).mimeType).toBe('font/ttf'); + expect((secondFile![1] as AttachedFile).name).toBe('Heya'); + expect((secondFile![1] as AttachedFile).description).toBe('A font file'); }); test('Read and write metadata, MP3', async () => {