[Enhancement]: Write metadata back into epub file #2501

Open
opened 2026-02-20 02:18:28 -05:00 by deekerman · 6 comments
Owner

Originally created by @mrburns-42 on GitHub (Oct 22, 2024).

Type of Enhancement

Server Backend

Describe the Feature/Enhancement

Write updated metadata back into epub files.

Why would this be helpful?

As soon as I download the epub from abs (say on an ebook reader) it still gets the old embedded metadata as the embedded metadata isn't updated.

Future Implementation (Screenshot)

"Embed Metadata" like the one used for audiobooks.

Audiobookshelf Server Version

2.15.1

Current Implementation (Screenshot)

No response

Originally created by @mrburns-42 on GitHub (Oct 22, 2024). ### Type of Enhancement Server Backend ### Describe the Feature/Enhancement Write updated metadata back into epub files. ### Why would this be helpful? As soon as I download the epub from abs (say on an ebook reader) it still gets the old embedded metadata as the embedded metadata isn't updated. ### Future Implementation (Screenshot) "Embed Metadata" like the one used for audiobooks. ### Audiobookshelf Server Version 2.15.1 ### Current Implementation (Screenshot) _No response_
Author
Owner

@nichwall commented on GitHub (Oct 22, 2024):

Yeah, ABS doesn't do any modifications to any ebook files. Not sure if that is planned due to there being so many weird edge cases with ebook formats and there already being other tools which have good ebook support like Calibre and Kavita.

@nichwall commented on GitHub (Oct 22, 2024): Yeah, ABS doesn't do any modifications to any ebook files. Not sure if that is planned due to there being so many weird edge cases with ebook formats and there already being other tools which have good ebook support like Calibre and Kavita.
Author
Owner

@kuldan5853 commented on GitHub (Dec 8, 2024):

there already being other tools which have good ebook support like Calibre and Kavita.

From my personal experience, ABS is already better for ebooks than Kavita (and Kavita also has no metadata management at all, you need to do the metadata tagging outside for it to work - and even then it does not really).

Calibre is a nice tool but their metadata scrapers for online are atrociously bad - it can't scrape a single book I have correctly, vs. ABS doing it pretty competently against google books.

I also see a lot of value in adding this (or well, at least I'd be a happy camper if you guys eventually get around to it).

@kuldan5853 commented on GitHub (Dec 8, 2024): > there already being other tools which have good ebook support like Calibre and Kavita. From my personal experience, ABS is already better for ebooks than Kavita (and Kavita also has no metadata management at all, you need to do the metadata tagging outside for it to work - and even then it does not really). Calibre is a nice tool but their metadata scrapers for online are atrociously bad - it can't scrape a single book I have correctly, vs. ABS doing it pretty competently against google books. I also see a lot of value in adding this (or well, at least I'd be a happy camper if you guys eventually get around to it).
Author
Owner

@leleogere commented on GitHub (Feb 20, 2025):

About weird ebook edge cases, it might be interesting to see how Calibre-Web Automatic manages them. They write back the cover and metadata to the epub when modified, so that when downloaded to an ereader, they are displayed exactly as they are displayed in the WebUI.

Currently, it is quite frustrating to spend so much time polishing all the metadata in the UI, and not being able to enjoy them on my reader.

@leleogere commented on GitHub (Feb 20, 2025): About weird ebook edge cases, it might be interesting to see how Calibre-Web Automatic manages them. They write back the [cover and metadata to the epub when modified](https://github.com/crocodilestick/Calibre-Web-Automated?tab=readme-ov-file#automatic-enforcement-of-changes-made-to-covers--metadata-through-the-calibre-web-ui-), so that when downloaded to an ereader, they are displayed exactly as they are displayed in the WebUI. Currently, it is quite frustrating to spend so much time polishing all the metadata in the UI, and not being able to enjoy them on my reader.
Author
Owner

@hbilbo commented on GitHub (May 6, 2025):

If this is not something the team is interested in implementing right now would it be possible to have ABS utilize the calibre binaries to embed the metadata? Or potentially even a custom user script to run on your ebooks when downloading to ensure metadata is embedded (not sure how difficult this would be and is maybe less ideal than using calibre)?

This is the one feature in my opinion that is missing and is holding me back from using ABS for ebooks. Like @leleogere said not really any point in curating metadata if you can't see that info in your ereaders. I wouldn't mind having calibre installing/running alongside ABS if it meant making embedding metadata possible until it's a native feature.

@hbilbo commented on GitHub (May 6, 2025): If this is not something the team is interested in implementing right now would it be possible to have ABS utilize the calibre binaries to embed the metadata? Or potentially even a custom user script to run on your ebooks when downloading to ensure metadata is embedded (not sure how difficult this would be and is maybe less ideal than using calibre)? This is the one feature in my opinion that is missing and is holding me back from using ABS for ebooks. Like @leleogere said not really any point in curating metadata if you can't see that info in your ereaders. I wouldn't mind having calibre installing/running alongside ABS if it meant making embedding metadata possible until it's a native feature.
Author
Owner

@BlazeWits commented on GitHub (Jun 13, 2025):

I second this. Adding a 'Quick embed Metadata' option to ebooks (right now only supports audiobooks) would be really nice.

@BlazeWits commented on GitHub (Jun 13, 2025): I second this. Adding a 'Quick embed Metadata' option to ebooks (right now only supports audiobooks) would be really nice.
Author
Owner

@GinSoakedBoy commented on GitHub (Jul 10, 2025):

If embedding metadata is tricky, it would be great to have a button that replaces the cover within the epub file.

For what it's worth I was able to have Copilot create this .js script that replaces a cover image with a cover.jpg file within the same directory as an epub file (checking if the image file is the same or not). Would something like this work?

const fs = require('fs-extra');
const path = require('path');
const fg = require('fast-glob');
const unzipper = require('unzipper');
const yazl = require('yazl');
const xml2js = require('xml2js');

const EBOOKS_DIR = 'ebooks';
const LOG_FILE = 'cover_replacer.log';

function logMessage(message) {
    fs.appendFileSync(LOG_FILE, message + '\n');
    console.log(message);
}

async function findEpubFiles(rootDir) {
    return fg([`${rootDir}/**/*.epub`]);
}

// Extract EPUB contents to a directory
async function extractEpub(epubPath, targetDir) {
    return fs.createReadStream(epubPath)
        .pipe(unzipper.Extract({ path: targetDir }))
        .promise();
}

// Find OPF path from container.xml
async function getOpfPath(extractDir) {
    const containerPath = path.join(extractDir, 'META-INF', 'container.xml');
    const xml = await fs.readFile(containerPath, 'utf8');
    const result = await xml2js.parseStringPromise(xml);
    const rootfile = result.container.rootfiles[0].rootfile[0].$['full-path'];
    return path.join(extractDir, rootfile);
}

// Find cover image path in OPF
async function getCoverImagePath(opfPath) {
    const xml = await fs.readFile(opfPath, 'utf8');
    const result = await xml2js.parseStringPromise(xml);
    const metadata = result.package.metadata[0];
    const manifest = result.package.manifest[0].item;
    let coverId = null;
    if (metadata.meta) {
        for (const meta of metadata.meta) {
            if (meta.$ && meta.$.name === 'cover') {
                coverId = meta.$.content;
                break;
            }
        }
    }
    if (!coverId) return null;
    for (const item of manifest) {
        if (item.$.id === coverId) {
            return path.join(path.dirname(opfPath), item.$.href);
        }
    }
    return null;
}

// Replace the cover image file in the extracted EPUB folder
async function replaceCoverImage(coverImgPath, newCoverPath) {
    if (!await fs.pathExists(coverImgPath)) {
        throw new Error(`Cover image file not found in EPUB: ${coverImgPath}`);
    }
    await fs.copyFile(newCoverPath, coverImgPath);
}

// Re-zip the EPUB, preserving mimetype as the first file and uncompressed
async function rezipEpub(extractDir, epubPath) {
    const tmpEpub = epubPath + '.tmp';
    const zipfile = new yazl.ZipFile();
    // Write mimetype first, uncompressed
    const mimetypePath = path.join(extractDir, 'mimetype');
    zipfile.addFile(mimetypePath, 'mimetype', { compress: false });
    // Add the rest
    const files = await fg(['**/*'], { cwd: extractDir, dot: true, onlyFiles: true });
    for (const file of files) {
        if (file === 'mimetype') continue;
        zipfile.addFile(path.join(extractDir, file), file);
    }
    zipfile.outputStream.pipe(fs.createWriteStream(tmpEpub)).on('close', async () => {
        await fs.move(tmpEpub, epubPath, { overwrite: true });
    });
    zipfile.end();
    // Wait for the zip to finish
    return new Promise(resolve => zipfile.outputStream.on('finish', resolve));
}

// Compare two files by content
async function filesAreIdentical(file1, file2) {
    try {
        const [buf1, buf2] = await Promise.all([
            fs.readFile(file1),
            fs.readFile(file2)
        ]);
        if (buf1.length !== buf2.length) return false;
        for (let i = 0; i < buf1.length; i++) {
            if (buf1[i] !== buf2[i]) return false;
        }
        return true;
    } catch (e) {
        return false;
    }
}

// Main function to process one EPUB
async function robustReplaceCover(epubPath, newCoverPath) {
    const tmpdir = await fs.mkdtemp(path.join(require('os').tmpdir(), 'epubfix-'));
    try {
        await extractEpub(epubPath, tmpdir);
        const opfPath = await getOpfPath(tmpdir);
        const coverImgPath = await getCoverImagePath(opfPath);
        if (!coverImgPath) throw new Error(`No cover image found in OPF for ${epubPath}`);
        // Compare cover images
        if (await filesAreIdentical(coverImgPath, newCoverPath)) {
            logMessage(`Cover already matches for: ${epubPath}, skipping.`);
        } else {
            await replaceCoverImage(coverImgPath, newCoverPath);
            await rezipEpub(tmpdir, epubPath);
            logMessage(`Replaced cover in: ${epubPath}`);
        }
    } finally {
        await fs.remove(tmpdir);
    }
}

async function processEbooks() {
    await fs.writeFile(LOG_FILE, '');
    const epubFiles = await findEpubFiles(EBOOKS_DIR);
    for (const epubFile of epubFiles) {
        const folder = path.dirname(epubFile);
        const coverPath = path.join(folder, 'cover.jpg');
        if (await fs.pathExists(coverPath)) {
            try {
                await robustReplaceCover(epubFile, coverPath);
            } catch (e) {
                logMessage(`Failed to update ${epubFile}: ${e}`);
            }
        } else {
            logMessage(`No cover.jpg found for ${epubFile}, skipping cover replacement.`);
        }
    }
    logMessage('Done.');
}

processEbooks();
@GinSoakedBoy commented on GitHub (Jul 10, 2025): If embedding metadata is tricky, it would be great to have a button that replaces the cover within the epub file. For what it's worth I was able to have Copilot create this .js script that replaces a cover image with a cover.jpg file within the same directory as an epub file (checking if the image file is the same or not). Would something like this work? ``` const fs = require('fs-extra'); const path = require('path'); const fg = require('fast-glob'); const unzipper = require('unzipper'); const yazl = require('yazl'); const xml2js = require('xml2js'); const EBOOKS_DIR = 'ebooks'; const LOG_FILE = 'cover_replacer.log'; function logMessage(message) { fs.appendFileSync(LOG_FILE, message + '\n'); console.log(message); } async function findEpubFiles(rootDir) { return fg([`${rootDir}/**/*.epub`]); } // Extract EPUB contents to a directory async function extractEpub(epubPath, targetDir) { return fs.createReadStream(epubPath) .pipe(unzipper.Extract({ path: targetDir })) .promise(); } // Find OPF path from container.xml async function getOpfPath(extractDir) { const containerPath = path.join(extractDir, 'META-INF', 'container.xml'); const xml = await fs.readFile(containerPath, 'utf8'); const result = await xml2js.parseStringPromise(xml); const rootfile = result.container.rootfiles[0].rootfile[0].$['full-path']; return path.join(extractDir, rootfile); } // Find cover image path in OPF async function getCoverImagePath(opfPath) { const xml = await fs.readFile(opfPath, 'utf8'); const result = await xml2js.parseStringPromise(xml); const metadata = result.package.metadata[0]; const manifest = result.package.manifest[0].item; let coverId = null; if (metadata.meta) { for (const meta of metadata.meta) { if (meta.$ && meta.$.name === 'cover') { coverId = meta.$.content; break; } } } if (!coverId) return null; for (const item of manifest) { if (item.$.id === coverId) { return path.join(path.dirname(opfPath), item.$.href); } } return null; } // Replace the cover image file in the extracted EPUB folder async function replaceCoverImage(coverImgPath, newCoverPath) { if (!await fs.pathExists(coverImgPath)) { throw new Error(`Cover image file not found in EPUB: ${coverImgPath}`); } await fs.copyFile(newCoverPath, coverImgPath); } // Re-zip the EPUB, preserving mimetype as the first file and uncompressed async function rezipEpub(extractDir, epubPath) { const tmpEpub = epubPath + '.tmp'; const zipfile = new yazl.ZipFile(); // Write mimetype first, uncompressed const mimetypePath = path.join(extractDir, 'mimetype'); zipfile.addFile(mimetypePath, 'mimetype', { compress: false }); // Add the rest const files = await fg(['**/*'], { cwd: extractDir, dot: true, onlyFiles: true }); for (const file of files) { if (file === 'mimetype') continue; zipfile.addFile(path.join(extractDir, file), file); } zipfile.outputStream.pipe(fs.createWriteStream(tmpEpub)).on('close', async () => { await fs.move(tmpEpub, epubPath, { overwrite: true }); }); zipfile.end(); // Wait for the zip to finish return new Promise(resolve => zipfile.outputStream.on('finish', resolve)); } // Compare two files by content async function filesAreIdentical(file1, file2) { try { const [buf1, buf2] = await Promise.all([ fs.readFile(file1), fs.readFile(file2) ]); if (buf1.length !== buf2.length) return false; for (let i = 0; i < buf1.length; i++) { if (buf1[i] !== buf2[i]) return false; } return true; } catch (e) { return false; } } // Main function to process one EPUB async function robustReplaceCover(epubPath, newCoverPath) { const tmpdir = await fs.mkdtemp(path.join(require('os').tmpdir(), 'epubfix-')); try { await extractEpub(epubPath, tmpdir); const opfPath = await getOpfPath(tmpdir); const coverImgPath = await getCoverImagePath(opfPath); if (!coverImgPath) throw new Error(`No cover image found in OPF for ${epubPath}`); // Compare cover images if (await filesAreIdentical(coverImgPath, newCoverPath)) { logMessage(`Cover already matches for: ${epubPath}, skipping.`); } else { await replaceCoverImage(coverImgPath, newCoverPath); await rezipEpub(tmpdir, epubPath); logMessage(`Replaced cover in: ${epubPath}`); } } finally { await fs.remove(tmpdir); } } async function processEbooks() { await fs.writeFile(LOG_FILE, ''); const epubFiles = await findEpubFiles(EBOOKS_DIR); for (const epubFile of epubFiles) { const folder = path.dirname(epubFile); const coverPath = path.join(folder, 'cover.jpg'); if (await fs.pathExists(coverPath)) { try { await robustReplaceCover(epubFile, coverPath); } catch (e) { logMessage(`Failed to update ${epubFile}: ${e}`); } } else { logMessage(`No cover.jpg found for ${epubFile}, skipping cover replacement.`); } } logMessage('Done.'); } processEbooks(); ```
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/audiobookshelf#2501
No description provided.