fix(metadata): keep extended m4b tags when a cover is written

Write the tags FFmpeg's iTunes mode drops (subtitle, publisher, language,
series, ASIN) as freeform atoms, and carry over the source file's own
freeform tags such as Audible's. Covered M4B and M4A writes lost them.

Ref #1744
This commit is contained in:
neon
2026-10-11 04:37:26 +00:00
parent c3abf6d9e1
commit e4843e4aa1
5 changed files with 767 additions and 0 deletions
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
sharpToBuffer: vi.fn(),
unlink: vi.fn(),
writeFile: vi.fn(),
writeMp4FreeformTags: vi.fn(),
}));
vi.mock('child_process', () => ({
@@ -36,6 +37,11 @@ vi.mock('../shared/atomic-file-replace', () => ({
replaceFileAtomically: mocks.replaceFileAtomically,
}));
vi.mock('./mp4-freeform-tags', async (importOriginal) => ({
...(await importOriginal<typeof import('./mp4-freeform-tags')>()),
writeMp4FreeformTags: mocks.writeMp4FreeformTags,
}));
import { replaceFileAtomically } from '../shared/atomic-file-replace';
import { AudioMetadataEmbedder, testing } from './audio-metadata-embedder';
@@ -62,6 +68,7 @@ describe('AudioMetadataEmbedder', () => {
writeFileMock.mockResolvedValue(undefined);
unlinkMock.mockResolvedValue(undefined);
replaceFileAtomicallyMock.mockResolvedValue(undefined);
mocks.writeMp4FreeformTags.mockResolvedValue(undefined);
});
it('writes text metadata, replaces cover when cover bytes are present, and atomically replaces the source file', async () => {
@@ -169,6 +176,14 @@ describe('AudioMetadataEmbedder', () => {
expect(ffmpegArgs).not.toContain('use_metadata_tags');
});
function mockProbedVideoStreams(stdout: string): void {
execFileMock.mockImplementation(
(bin: string, _args: string[], _options: unknown, callback: (error: Error | null, result: { stdout: string; stderr: string }) => void) => {
callback(null, { stdout: bin === 'ffprobe' ? stdout : '', stderr: '' });
},
);
}
function mapArgs(args: string[]): string[] {
return args.flatMap((arg, index) => (arg === '-map' ? [args[index + 1]!] : []));
}
@@ -226,6 +241,74 @@ describe('AudioMetadataEmbedder', () => {
expect(args).toEqual(expect.arrayContaining(['-metadata:s:a:0', 'language=eng']));
});
it('writes the tags the iTunes mode drops as freeform atoms before replacing the file', async () => {
const embedder = new AudioMetadataEmbedder();
await embedder.embedMetadata('/books/audio/book.m4b', 'm4b', {
coverBytes: Buffer.from('raw-cover'),
metadata: [
{ key: 'album', value: 'Dune' },
{ key: 'subtitle', value: 'Book One' },
{ key: 'series-part', value: '' },
{ key: 'language', value: 'eng', specifier: 's:a:0' },
],
});
expect(mocks.writeMp4FreeformTags).toHaveBeenCalledWith(
'/books/audio/.bookorbit-write-fixed-id.m4b',
[
{ name: 'SUBTITLE', value: 'Book One' },
{ name: 'SERIES-PART', value: '' },
],
{ carryOverFrom: '/books/audio/book.m4b' },
);
expect(mocks.writeMp4FreeformTags.mock.invocationCallOrder[0]).toBeLessThan(replaceFileAtomicallyMock.mock.invocationCallOrder[0]!);
});
it('adds freeform atoms when keeping existing art in an MP4', async () => {
mockProbedVideoStreams('0\n');
const embedder = new AudioMetadataEmbedder();
await embedder.embedMetadata('/books/audio/book.m4a', 'm4a', { coverBytes: null, metadata: [{ key: 'publisher', value: 'Ace' }] });
expect(mocks.writeMp4FreeformTags).toHaveBeenCalledWith('/books/audio/.bookorbit-write-fixed-id.m4a', [{ name: 'PUBLISHER', value: 'Ace' }], {
carryOverFrom: '/books/audio/book.m4a',
});
});
it('leaves extended tags to FFmpeg when it writes QuickTime metadata keys', async () => {
mockProbedVideoStreams('');
const embedder = new AudioMetadataEmbedder();
await embedder.embedMetadata('/books/audio/book.m4b', 'm4b', { coverBytes: null, metadata: [{ key: 'subtitle', value: 'Book One' }] });
expect(execFileMock.mock.calls.find(([bin]) => bin === 'ffmpeg')![1]).toContain('use_metadata_tags');
expect(mocks.writeMp4FreeformTags).not.toHaveBeenCalled();
});
it.each(['mp3', 'flac'])('never edits MP4 atoms in %s files', async (format) => {
const embedder = new AudioMetadataEmbedder();
await embedder.embedMetadata(`/books/audio/book.${format}`, format, {
coverBytes: Buffer.from('raw-cover'),
metadata: [{ key: 'subtitle', value: 'x' }],
});
expect(mocks.writeMp4FreeformTags).not.toHaveBeenCalled();
});
it('keeps the original file when the freeform tags cannot be written', async () => {
mocks.writeMp4FreeformTags.mockRejectedValue(new Error('MP4 movie box is not at the end of the file'));
const embedder = new AudioMetadataEmbedder();
await expect(
embedder.embedMetadata('/books/audio/book.m4b', 'm4b', { coverBytes: Buffer.from('raw-cover'), metadata: [{ key: 'subtitle', value: 'x' }] }),
).rejects.toThrow('MP4 movie box is not at the end of the file');
expect(replaceFileAtomicallyMock).not.toHaveBeenCalled();
expect(unlinkMock).toHaveBeenCalledWith('/books/audio/.bookorbit-write-fixed-id.m4b');
});
it('preserves the ffmpeg error when cleanup also fails', async () => {
execFileMock.mockImplementation(
(_bin: string, _args: string[], _options: unknown, callback: (error: Error | null, stdout: string, stderr: string) => void) => {
@@ -8,6 +8,7 @@ import { promisify } from 'util';
import { FORMAT_M4A, FORMAT_M4B, FORMAT_MP3 } from '../../file-write.constants';
import { replaceFileAtomically } from '../shared/atomic-file-replace';
import { MP4_NATIVE_TAG_KEYS, writeMp4FreeformTags, type Mp4FreeformTag } from './mp4-freeform-tags';
const execFile = promisify(execFileCallback);
const FFMPEG_OUTPUT_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
@@ -48,6 +49,9 @@ export class AudioMetadataEmbedder {
maxBuffer: FFMPEG_OUTPUT_MAX_BUFFER_BYTES,
timeout: FFMPEG_TIMEOUT_MS,
});
if (usesMp4MetadataTags(format) && !useMp4MetadataTags) {
await writeMp4FreeformTags(tempPath, mp4FreeformTags(input.metadata), { carryOverFrom: filePath });
}
await replaceFileAtomically(tempPath, filePath);
} catch (error) {
await removeFileIfPresent(tempPath);
@@ -113,6 +117,13 @@ function buildFfmpegArgs(
return args;
}
/** The tags FFmpeg's iTunes mode would drop, named upper case as iTunes-style taggers name freeform tags. */
function mp4FreeformTags(metadata: AudioMetadataArg[]): Mp4FreeformTag[] {
return metadata
.filter((entry) => !entry.specifier && !MP4_NATIVE_TAG_KEYS.has(entry.key))
.map((entry) => ({ name: entry.key.toUpperCase(), value: entry.value }));
}
function usesMp4MetadataTags(format: string): boolean {
const normalized = format.toLowerCase();
return normalized === FORMAT_M4B || normalized === FORMAT_M4A;
@@ -10,6 +10,7 @@ import { createRequire } from 'module';
import { createBookWriteFieldMask } from '../../file-write.constants';
import { M4aAudioFormatWriter, M4bAudioFormatWriter, Mp3AudioFormatWriter } from './audio-format-writer';
import { AudioMetadataEmbedder } from './audio-metadata-embedder';
import { writeMp4FreeformTags } from './mp4-freeform-tags';
const execFile = promisify(execFileCallback);
const requireInstaller = createRequire(__filename);
@@ -169,6 +170,101 @@ describe('audio metadata round-trip', () => {
expect(extracted.authors).toEqual([{ name: 'Author One', sortName: null }]);
});
it.each([
['m4b', M4bAudioFormatWriter],
['m4a', M4aAudioFormatWriter],
] as const)('round-trips extended metadata for %s files with cover art', async (format, Writer) => {
const filePath = join(tempDir, `covered.${format}`);
const coverPath = join(tempDir, 'cover.jpg');
await generateAudioFixture(filePath, 'aac');
await generateCoverFixture(coverPath);
const writer = new Writer(new AudioMetadataEmbedder());
await writer.write(
filePath,
{
title: 'Covered Title',
subtitle: 'Covered Subtitle',
authors: [{ name: 'Author One', sortName: null }],
publisher: 'Covered Publisher',
publishedYear: 2026,
language: 'deu',
seriesName: 'Covered Series',
seriesIndex: '2',
audibleId: 'B0COVERED',
librofmId: '9781111111111',
coverBytes: await readFile(coverPath),
},
{ dryRun: false, fieldMask: createBookWriteFieldMask() },
);
const tags = await probeFormatTags(filePath);
expect(tags).toEqual(
expect.objectContaining({
subtitle: 'Covered Subtitle',
publisher: 'Covered Publisher',
language: 'deu',
series: 'Covered Series',
'series-part': '2',
asin: 'B0COVERED',
audible_asin: 'B0COVERED',
librofm_isbn: '9781111111111',
}),
);
const coverStream = (await probeStreams(filePath)).find((stream) => stream.codec_type === 'video');
expect(coverStream?.disposition?.attached_pic).toBe(1);
vi.resetModules();
const { extractAudioMetadata } = await import('../../../metadata/extractors/audio.extractor');
const extracted = await extractAudioMetadata(filePath);
expect(extracted).toEqual(
expect.objectContaining({
title: 'Covered Title',
subtitle: 'Covered Subtitle',
publisher: 'Covered Publisher',
language: 'deu',
seriesName: 'Covered Series',
seriesIndex: '2',
audibleId: 'B0COVERED',
librofmId: '9781111111111',
}),
);
expect(extracted.coverBytes?.length).toBeGreaterThan(0);
});
it('keeps tags it does not manage and replaces stale ones when rewriting a tagged m4b', async () => {
const filePath = join(tempDir, 'audible.m4b');
const coverPath = join(tempDir, 'cover.jpg');
await generateAudioFixture(filePath, 'aac');
await generateCoverFixture(coverPath);
const writer = new M4bAudioFormatWriter(new AudioMetadataEmbedder());
await writer.write(
filePath,
{ title: 'Old Title', coverBytes: await readFile(coverPath) },
{ dryRun: false, fieldMask: createBookWriteFieldMask() },
);
await writeMp4FreeformTags(filePath, [
{ name: 'AUDIBLE_ACR', value: 'CR!ABC' },
{ name: 'SUBTITLE', value: 'Old Subtitle' },
{ name: 'PART', value: '9' },
]);
await writer.write(
filePath,
{ title: 'New Title', subtitle: 'New Subtitle', seriesIndex: '3' },
{ dryRun: false, fieldMask: new Set(['title', 'subtitle', 'seriesIndex']) },
);
const tags = await probeFormatTags(filePath);
expect(tags).toEqual(expect.objectContaining({ title: 'New Title', subtitle: 'New Subtitle', 'series-part': '3', audible_acr: 'CR!ABC' }));
expect(tags.part).toBeUndefined();
expect((await probeStreams(filePath)).find((stream) => stream.codec_type === 'video')?.disposition?.attached_pic).toBe(1);
vi.resetModules();
const { extractAudioMetadata } = await import('../../../metadata/extractors/audio.extractor');
expect(await extractAudioMetadata(filePath)).toEqual(expect.objectContaining({ title: 'New Title', subtitle: 'New Subtitle', seriesIndex: '3' }));
});
it.each([
['m4b', M4bAudioFormatWriter],
['m4a', M4aAudioFormatWriter],
@@ -0,0 +1,290 @@
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { writeMp4FreeformTags } from './mp4-freeform-tags';
const MDAT = box('mdat', Buffer.from('audio-payload-that-must-not-move'));
const FTYP = box('ftyp', Buffer.from('M4A \0\0\0\0isomM4A '));
const TRAK = box('trak', box('tkhd', Buffer.alloc(16)));
describe('writeMp4FreeformTags', () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'bookorbit-mp4-freeform-'));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it('appends freeform atoms to the iTunes metadata without moving the audio', async () => {
const target = await mp4File('target.m4b', moov(TRAK, udta(itunesMeta(stringAtom('©nam', 'Title')))));
await writeMp4FreeformTags(target, [
{ name: 'SUBTITLE', value: 'Second Book' },
{ name: 'ASIN', value: 'B0TEST' },
]);
const bytes = await readFile(target);
expect(bytes.subarray(0, FTYP.length + MDAT.length)).toEqual(Buffer.concat([FTYP, MDAT]));
expect(readTags(bytes)).toEqual([
{ type: '©nam', value: 'Title' },
{ type: '----', name: 'SUBTITLE', value: 'Second Book' },
{ type: '----', name: 'ASIN', value: 'B0TEST' },
]);
});
it('writes the atom layout FFmpeg reads: iTunes mean, name, and a UTF-8 data atom', async () => {
const target = await mp4File('target.m4b', moov(TRAK, udta(itunesMeta())));
await writeMp4FreeformTags(target, [{ name: 'PUBLISHER', value: 'Ünïcode Press' }]);
const ilst = findPath(await readFile(target), ['moov', 'udta', 'meta', 'ilst'])!;
expect(ilst.body).toEqual(freeform('PUBLISHER', 'Ünïcode Press'));
});
it('creates the user data and iTunes metadata when the file has none', async () => {
const target = await mp4File('target.m4b', moov(TRAK));
await writeMp4FreeformTags(target, [{ name: 'SERIES', value: 'Saga' }]);
const bytes = await readFile(target);
expect(readTags(bytes)).toEqual([{ type: '----', name: 'SERIES', value: 'Saga' }]);
expect(handlerType(bytes)).toBe('mdir');
});
it('adds iTunes metadata beside other user data', async () => {
const target = await mp4File('target.m4b', moov(TRAK, udta(box('chpl', Buffer.from('nero-chapters')))));
await writeMp4FreeformTags(target, [{ name: 'SERIES', value: 'Saga' }]);
const bytes = await readFile(target);
expect(findPath(bytes, ['moov', 'udta', 'chpl'])?.body.toString()).toBe('nero-chapters');
expect(readTags(bytes)).toEqual([{ type: '----', name: 'SERIES', value: 'Saga' }]);
});
it('replaces its own earlier atoms instead of piling up copies on every write', async () => {
const target = await mp4File('target.m4b', moov(TRAK, udta(itunesMeta())));
await writeMp4FreeformTags(target, [{ name: 'SERIES', value: 'First' }]);
await writeMp4FreeformTags(target, [{ name: 'SERIES', value: 'Second' }]);
expect(readTags(await readFile(target))).toEqual([{ type: '----', name: 'SERIES', value: 'Second' }]);
});
it('keeps the movie box valid when other boxes follow the user data', async () => {
const trailing = box('meta', Buffer.from('trailing-sibling'));
const target = await mp4File('target.m4b', moov(TRAK, udta(itunesMeta()), trailing));
await writeMp4FreeformTags(target, [{ name: 'SERIES', value: 'Saga' }]);
const bytes = await readFile(target);
const moovStart = FTYP.length + MDAT.length;
expect(bytes.readUInt32BE(moovStart)).toBe(bytes.length - moovStart);
expect(bytes.subarray(bytes.length - trailing.length)).toEqual(trailing);
expect(readTags(bytes)).toEqual([{ type: '----', name: 'SERIES', value: 'Saga' }]);
});
it('updates a 64-bit movie box size', async () => {
const body = Buffer.concat([TRAK, udta(itunesMeta())]);
const largeMoov = Buffer.concat([uint32(1), Buffer.from('moov'), uint64(16 + body.length), body]);
const target = await mp4File('target.m4b', largeMoov);
await writeMp4FreeformTags(target, [{ name: 'SERIES', value: 'Saga' }]);
const bytes = await readFile(target);
const moovStart = FTYP.length + MDAT.length;
expect(bytes.readUInt32BE(moovStart)).toBe(1);
expect(Number(bytes.readBigUInt64BE(moovStart + 8))).toBe(bytes.length - moovStart);
expect(readTags(bytes)).toEqual([{ type: '----', name: 'SERIES', value: 'Saga' }]);
});
it('carries over freeform tags from the source that the write does not manage', async () => {
const source = await mp4File(
'source.m4b',
moov(TRAK, udta(itunesMeta(freeform('AUDIBLE_ACR', 'CR!ABC'), freeform('SUBTITLE', 'Old'), stringAtom('©nam', 'Old Title')))),
);
const target = await mp4File('target.m4b', moov(TRAK, udta(itunesMeta(stringAtom('©nam', 'New Title')))));
await writeMp4FreeformTags(target, [{ name: 'SUBTITLE', value: 'New' }], { carryOverFrom: source });
expect(readTags(await readFile(target))).toEqual([
{ type: '©nam', value: 'New Title' },
{ type: '----', name: 'AUDIBLE_ACR', value: 'CR!ABC' },
{ type: '----', name: 'SUBTITLE', value: 'New' },
]);
});
it('drops source copies the write supersedes, whatever their case or alias', async () => {
const source = await mp4File(
'source.m4b',
moov(
TRAK,
udta(
itunesMeta(
freeform('subtitle', 'lower-case copy'),
freeform('PART', '9'),
freeform('Series_Part', '9'),
freeform('TITLE', 'shadows the native title'),
freeform('iTunSMPB', ' 00000000 00000840'),
freeform('PUBLISHER', 'Cleared Press'),
),
),
),
);
const target = await mp4File('target.m4b', moov(TRAK, udta(itunesMeta())));
await writeMp4FreeformTags(
target,
[
{ name: 'SUBTITLE', value: 'Fresh' },
{ name: 'SERIES-PART', value: '3' },
{ name: 'PUBLISHER', value: '' },
],
{ carryOverFrom: source },
);
expect(readTags(await readFile(target))).toEqual([
{ type: '----', name: 'SUBTITLE', value: 'Fresh' },
{ type: '----', name: 'SERIES-PART', value: '3' },
]);
});
it('carries nothing over from a source without readable iTunes metadata', async () => {
const quicktimeKeys = await mp4File(
'keys.m4b',
moov(TRAK, udta(fullBox('meta', box('hdlr', uint32(0), uint32(0), Buffer.from('mdta')), box('ilst')))),
);
const truncated = join(dir, 'truncated.m4b');
await writeFile(truncated, Buffer.concat([FTYP, uint32(4096), Buffer.from('moov')]));
const target = await mp4File('target.m4b', moov(TRAK, udta(itunesMeta())));
await writeMp4FreeformTags(target, [{ name: 'SERIES', value: 'Saga' }], { carryOverFrom: quicktimeKeys });
await writeMp4FreeformTags(target, [{ name: 'SERIES', value: 'Saga' }], { carryOverFrom: truncated });
expect(readTags(await readFile(target))).toEqual([{ type: '----', name: 'SERIES', value: 'Saga' }]);
});
it('refuses a file whose movie box is not last, leaving it untouched', async () => {
const target = join(dir, 'faststart.m4b');
const original = Buffer.concat([FTYP, moov(TRAK, udta(itunesMeta())), MDAT]);
await writeFile(target, original);
await expect(writeMp4FreeformTags(target, [{ name: 'SERIES', value: 'Saga' }])).rejects.toThrow('MP4 movie box is not at the end of the file');
expect(await readFile(target)).toEqual(original);
});
it('refuses to put freeform atoms into QuickTime keys metadata', async () => {
const target = await mp4File(
'target.m4b',
moov(TRAK, udta(fullBox('meta', box('hdlr', uint32(0), uint32(0), Buffer.from('mdta')), box('ilst')))),
);
await expect(writeMp4FreeformTags(target, [{ name: 'SERIES', value: 'Saga' }])).rejects.toThrow('MP4 metadata is not in iTunes form');
});
async function mp4File(name: string, moovBox: Buffer): Promise<string> {
const path = join(dir, name);
await writeFile(path, Buffer.concat([FTYP, MDAT, moovBox]));
return path;
}
});
interface ParsedBox {
type: string;
body: Buffer;
}
function parseChildren(bytes: Buffer, start: number, end: number): ParsedBox[] {
const boxes: ParsedBox[] = [];
for (let offset = start; offset + 8 <= end;) {
let size = bytes.readUInt32BE(offset);
let header = 8;
if (size === 1) {
size = Number(bytes.readBigUInt64BE(offset + 8));
header = 16;
}
boxes.push({ type: bytes.toString('latin1', offset + 4, offset + 8), body: bytes.subarray(offset + header, offset + size) });
offset += size;
}
return boxes;
}
function findPath(bytes: Buffer, path: string[]): ParsedBox | undefined {
let current: ParsedBox | undefined = { type: '', body: bytes };
for (const type of path) {
if (!current) return undefined;
// The iTunes meta box carries version and flags before its children.
const start = current.type === 'meta' ? 4 : 0;
current = parseChildren(current.body, start, current.body.length).find((child) => child.type === type);
}
return current;
}
function readTags(bytes: Buffer): Array<{ type: string; name?: string; value: string }> {
const ilst = findPath(bytes, ['moov', 'udta', 'meta', 'ilst']);
if (!ilst) return [];
return parseChildren(ilst.body, 0, ilst.body.length).map((atom) => {
const children = parseChildren(atom.body, 0, atom.body.length);
const data = children.find((child) => child.type === 'data')!;
const value = data.body.subarray(8).toString('utf8');
if (atom.type !== '----') return { type: atom.type, value };
const name = children
.find((child) => child.type === 'name')!
.body.subarray(4)
.toString('utf8');
return { type: atom.type, name, value };
});
}
function handlerType(bytes: Buffer): string {
return findPath(bytes, ['moov', 'udta', 'meta', 'hdlr'])!.body.toString('latin1', 8, 12);
}
function moov(...children: Buffer[]): Buffer {
return box('moov', ...children);
}
function udta(...children: Buffer[]): Buffer {
return box('udta', ...children);
}
function itunesMeta(...atoms: Buffer[]): Buffer {
return fullBox('meta', box('hdlr', uint32(0), uint32(0), Buffer.from('mdirappl'), Buffer.alloc(9)), box('ilst', ...atoms));
}
function stringAtom(type: string, value: string): Buffer {
return box(type, box('data', uint32(1), uint32(0), Buffer.from(value, 'utf8')));
}
function freeform(name: string, value: string): Buffer {
return box(
'----',
box('mean', uint32(0), Buffer.from('com.apple.iTunes')),
box('name', uint32(0), Buffer.from(name, 'utf8')),
box('data', uint32(1), uint32(0), Buffer.from(value, 'utf8')),
);
}
function fullBox(type: string, ...children: Buffer[]): Buffer {
return box(type, uint32(0), ...children);
}
function box(type: string, ...parts: Buffer[]): Buffer {
const body = Buffer.concat(parts);
return Buffer.concat([uint32(8 + body.length), Buffer.from(type, 'latin1'), body]);
}
function uint32(value: number): Buffer {
const bytes = Buffer.alloc(4);
bytes.writeUInt32BE(value, 0);
return bytes;
}
function uint64(value: number): Buffer {
const bytes = Buffer.alloc(8);
bytes.writeBigUInt64BE(BigInt(value), 0);
return bytes;
}
@@ -0,0 +1,287 @@
import { open, type FileHandle } from 'fs/promises';
/**
* Tags FFmpeg writes as standard iTunes atoms. Every other tag only survives in an MP4 as an iTunes
* freeform (`----`) atom, which FFmpeg cannot write: its `use_metadata_tags` mode stores them as
* QuickTime keys instead, but then drops the `covr` cover art.
*/
export const MP4_NATIVE_TAG_KEYS: ReadonlySet<string> = new Set([
'title',
'artist',
'album_artist',
'composer',
'album',
'date',
'encoder',
'encoding_tool',
'comment',
'genre',
'copyright',
'grouping',
'lyrics',
'description',
'synopsis',
'show',
'episode_id',
'network',
'keywords',
'episode_sort',
'season_number',
'media_type',
'hd_video',
'gapless_playback',
'compilation',
'track',
'disc',
'tmpo',
]);
export interface Mp4FreeformTag {
name: string;
/** An empty value clears the tag, as `-metadata key=` does for FFmpeg. */
value: string;
}
// The other names the audio extractor reads for a field: a stale copy from another tagger would
// otherwise survive beside the new value.
const SUPERSEDED_NAMES: Readonly<Record<string, readonly string[]>> = {
'series-part': ['part', 'series_part', 'seriespart'],
};
// FFmpeg turns iTunSMPB into its own encoder-delay handling on read, so a copied one could trim the
// audio a second time.
const NEVER_CARRIED_NAMES: ReadonlySet<string> = new Set(['itunsmpb']);
const MAX_USER_DATA_BYTES = 64 * 1024 * 1024;
const ITUNES_MEAN = 'com.apple.iTunes';
const DATA_TYPE_UTF8 = 1;
interface Box {
type: string;
start: number;
bodyStart: number;
end: number;
}
interface FreeformAtom {
name: string;
bytes: Buffer;
}
class MalformedMp4Error extends Error {}
/**
* Writes `tags` into `targetPath`, an MP4 FFmpeg has just written, as freeform atoms. Freeform tags in
* `carryOverFrom` that this write does not replace are copied across, so a file keeps tags such as
* Audible's that BookOrbit does not manage.
*
* Only the end of the file changes: FFmpeg writes the movie box last, so the audio and every chunk
* offset stay where they are.
*/
export async function writeMp4FreeformTags(targetPath: string, tags: Mp4FreeformTag[], options: { carryOverFrom?: string } = {}): Promise<void> {
const replaced = replacedNames(tags);
const carried = options.carryOverFrom ? await readFreeformAtoms(options.carryOverFrom) : [];
const atoms = [
...carried
.filter((atom) => !replaced.has(atom.name.toLowerCase()) && !NEVER_CARRIED_NAMES.has(atom.name.toLowerCase()))
.map((atom) => atom.bytes),
...tags.filter((tag) => tag.value.length > 0).map((tag) => encodeFreeformAtom(tag.name, tag.value)),
];
const handle = await open(targetPath, 'r+');
try {
const { size } = await handle.stat();
const moov = (await readChildBoxes(handle, 0, size)).find((child) => child.type === 'moov');
if (!moov) throw new MalformedMp4Error('MP4 has no movie box');
if (moov.end !== size) throw new MalformedMp4Error('MP4 movie box is not at the end of the file');
const udta = (await readChildBoxes(handle, moov.bodyStart, moov.end)).find((child) => child.type === 'udta');
const tailStart = udta ? udta.start : moov.end;
if (moov.end - tailStart > MAX_USER_DATA_BYTES) throw new MalformedMp4Error('MP4 user data box is too large to rewrite');
const tail = await readBytes(handle, tailStart, moov.end - tailStart);
const oldUdtaLength = udta ? udta.end - udta.start : 0;
const newUdta = udta ? rebuildUdta(tail.subarray(0, oldUdtaLength), atoms, replaced) : box('udta', newMeta(atoms));
const newTail = Buffer.concat([newUdta, tail.subarray(oldUdtaLength)]);
const header = Buffer.alloc(moov.bodyStart - moov.start);
header.write('moov', 4, 'latin1');
setBoxSize(header, 0, header.length, moov.end - moov.start + newTail.length - tail.length);
await handle.write(header, 0, header.length, moov.start);
await handle.write(newTail, 0, newTail.length, tailStart);
await handle.truncate(tailStart + newTail.length);
} finally {
await handle.close();
}
}
/** Every freeform name a write of `tags` replaces, lower-cased. */
function replacedNames(tags: Mp4FreeformTag[]): Set<string> {
// A freeform copy of a native tag would shadow the value FFmpeg just wrote.
const names = new Set<string>(MP4_NATIVE_TAG_KEYS);
for (const tag of tags) {
const name = tag.name.toLowerCase();
names.add(name);
for (const alias of SUPERSEDED_NAMES[name] ?? []) names.add(alias);
}
return names;
}
/** The freeform atoms of an MP4, raw. A file without readable iTunes metadata has none. */
async function readFreeformAtoms(filePath: string): Promise<FreeformAtom[]> {
const handle = await open(filePath, 'r');
try {
const { size } = await handle.stat();
const moov = (await readChildBoxes(handle, 0, size)).find((child) => child.type === 'moov');
const udta = moov ? (await readChildBoxes(handle, moov.bodyStart, moov.end)).find((child) => child.type === 'udta') : undefined;
if (!udta) return [];
if (udta.end - udta.start > MAX_USER_DATA_BYTES) throw new Error('MP4 user data box is too large to read');
const bytes = await readBytes(handle, udta.start, udta.end - udta.start);
const meta = childBoxes(bytes, parseBox(bytes, 0, bytes.length).bodyStart, bytes.length).find((child) => child.type === 'meta');
const ilst = meta ? findItunesIlst(bytes, meta) : null;
if (!ilst) return [];
return childBoxes(bytes, ilst.bodyStart, ilst.end).flatMap((child) => {
const name = child.type === '----' ? freeformName(bytes, child) : null;
return name ? [{ name, bytes: Buffer.from(bytes.subarray(child.start, child.end)) }] : [];
});
} catch (error) {
if (error instanceof MalformedMp4Error) return [];
throw error;
} finally {
await handle.close();
}
}
function rebuildUdta(udtaBytes: Buffer, atoms: Buffer[], replaced: Set<string>): Buffer {
const udta = parseBox(udtaBytes, 0, udtaBytes.length);
const children = childBoxes(udtaBytes, udta.bodyStart, udta.end);
const meta = children.find((child) => child.type === 'meta');
if (!meta) return box('udta', ...children.map((child) => udtaBytes.subarray(child.start, child.end)), newMeta(atoms));
const ilst = findItunesIlst(udtaBytes, meta);
if (!ilst) throw new MalformedMp4Error('MP4 metadata is not in iTunes form');
const kept = childBoxes(udtaBytes, ilst.bodyStart, ilst.end)
.filter((child) => {
const name = child.type === '----' ? freeformName(udtaBytes, child) : null;
return !name || !replaced.has(name.toLowerCase());
})
.map((child) => udtaBytes.subarray(child.start, child.end));
const newIlst = box('ilst', ...kept, ...atoms);
const delta = newIlst.length - (ilst.end - ilst.start);
// Splice the new ilst in place and grow its two ancestors by the same amount.
const rebuilt = Buffer.concat([udtaBytes.subarray(0, ilst.start), newIlst, udtaBytes.subarray(ilst.end)]);
setBoxSize(rebuilt, meta.start, meta.bodyStart - meta.start, meta.end - meta.start + delta);
setBoxSize(rebuilt, udta.start, udta.bodyStart - udta.start, udta.end - udta.start + delta);
return rebuilt;
}
function newMeta(atoms: Buffer[]): Buffer {
const hdlr = fullBox('hdlr', Buffer.concat([uint32(0), Buffer.from('mdirappl', 'latin1'), Buffer.alloc(9)]));
return fullBox('meta', Buffer.concat([hdlr, box('ilst', ...atoms)]));
}
function encodeFreeformAtom(name: string, value: string): Buffer {
return box(
'----',
fullBox('mean', Buffer.from(ITUNES_MEAN, 'utf8')),
fullBox('name', Buffer.from(name, 'utf8')),
box('data', uint32(DATA_TYPE_UTF8), uint32(0), Buffer.from(value, 'utf8')),
);
}
/** The ilst of an iTunes (`mdir`) meta box; a QuickTime keys (`mdta`) one holds no freeform atoms. */
function findItunesIlst(bytes: Buffer, meta: Box): Box | null {
// iTunes writes meta as a full box; QuickTime writes it without version and flags.
const plain = meta.bodyStart + 8 <= meta.end && bytes.toString('latin1', meta.bodyStart + 4, meta.bodyStart + 8) === 'hdlr';
const children = childBoxes(bytes, plain ? meta.bodyStart : meta.bodyStart + 4, meta.end);
const hdlr = children.find((child) => child.type === 'hdlr');
if (!hdlr || hdlr.bodyStart + 12 > hdlr.end || bytes.toString('latin1', hdlr.bodyStart + 8, hdlr.bodyStart + 12) !== 'mdir') return null;
return children.find((child) => child.type === 'ilst') ?? null;
}
function freeformName(bytes: Buffer, freeform: Box): string | null {
const name = childBoxes(bytes, freeform.bodyStart, freeform.end).find((child) => child.type === 'name');
return name && name.end - name.bodyStart > 4 ? bytes.toString('utf8', name.bodyStart + 4, name.end) : null;
}
/** Decodes the header at `at`, where `remaining` bytes are left in its parent. */
function decodeHeader(bytes: Buffer, at: number, remaining: number): { type: string; headerSize: number; size: number } {
if (remaining < 8 || bytes.length < at + 8) throw new MalformedMp4Error('MP4 box header runs past its parent');
let size = bytes.readUInt32BE(at);
let headerSize = 8;
if (size === 1) {
if (remaining < 16 || bytes.length < at + 16) throw new MalformedMp4Error('MP4 box header runs past its parent');
size = Number(bytes.readBigUInt64BE(at + 8));
headerSize = 16;
} else if (size === 0) {
size = remaining;
}
if (size < headerSize || size > remaining) throw new MalformedMp4Error('MP4 box size runs past its parent');
return { type: bytes.toString('latin1', at + 4, at + 8), headerSize, size };
}
function parseBox(bytes: Buffer, offset: number, limit: number): Box {
const { type, headerSize, size } = decodeHeader(bytes, offset, limit - offset);
return { type, start: offset, bodyStart: offset + headerSize, end: offset + size };
}
// Fewer than 8 trailing bytes are padding: QuickTime may end a user data list with a zero terminator.
function childBoxes(bytes: Buffer, start: number, end: number): Box[] {
const boxes: Box[] = [];
for (let offset = start; offset + 8 <= end;) {
const child = parseBox(bytes, offset, end);
boxes.push(child);
offset = child.end;
}
return boxes;
}
/** Walks the box headers of a file region without reading bodies, so a large mdat costs nothing. */
async function readChildBoxes(handle: FileHandle, start: number, end: number): Promise<Box[]> {
const boxes: Box[] = [];
for (let offset = start; offset + 8 <= end;) {
const header = await readBytes(handle, offset, Math.min(16, end - offset));
const { type, headerSize, size } = decodeHeader(header, 0, end - offset);
boxes.push({ type, start: offset, bodyStart: offset + headerSize, end: offset + size });
offset += size;
}
return boxes;
}
async function readBytes(handle: FileHandle, position: number, length: number): Promise<Buffer> {
const buffer = Buffer.alloc(length);
const { bytesRead } = await handle.read(buffer, 0, length, position);
if (bytesRead !== length) throw new MalformedMp4Error('MP4 ended inside a box');
return buffer;
}
function setBoxSize(bytes: Buffer, at: number, headerSize: number, size: number): void {
if (headerSize === 16) {
bytes.writeUInt32BE(1, at);
bytes.writeBigUInt64BE(BigInt(size), at + 8);
return;
}
if (size > 0xffffffff) throw new MalformedMp4Error('MP4 box grew past the 32-bit size limit');
bytes.writeUInt32BE(size, at);
}
function box(type: string, ...parts: Buffer[]): Buffer {
const body = Buffer.concat(parts);
const header = Buffer.alloc(8);
header.writeUInt32BE(8 + body.length, 0);
header.write(type, 4, 'latin1');
return Buffer.concat([header, body]);
}
function fullBox(type: string, body: Buffer): Buffer {
return box(type, uint32(0), body);
}
function uint32(value: number): Buffer {
const bytes = Buffer.alloc(4);
bytes.writeUInt32BE(value, 0);
return bytes;
}