When a MOBI or AZW3 file names no cover (no EXTH 201 or 202), the cover is the record at the first image index if it is a JPEG, PNG or GIF, and nothing otherwise, as calibre does (metadata/mobi.py:524-532). The walk to the next image is gone: a later record may be an inline illustration, and kindlegen puts a font or RESC record first. EXTH 121 is no longer read, which also stops a stale KF8 boundary below first_image from hiding the cover.
bookmeta
The metadata a file already carries.
Part of catena.
What it does
Every ebook format has somewhere to put the title, and almost every file in a library has something written there — by the publisher, by the converter, or by whoever last touched it. Reading it costs nothing, needs no network, and is the first thing a catalogue wants.
What comes back is what the file says, not what is true. A PDF titled
Microsoft Word - ch3.doc is reported with that title and a note saying where
it came from. Deciding that is rubbish is bibclean's
job; finding out what is actually true is bibfetch's.
When you would use it
- Cataloguing a directory you have just been given, before anything else.
- Filling a library without shelling out to calibre for every file.
- Finding the books that say nothing about themselves, which are the ones worth a person's attention.
Install
cargo install --git https://git.pumpin.online/funkpower/catena bookmeta
Example
$ bookmeta library/*.epub | jq -r '[.title, (.authors|join(", "))] | @tsv'
Hagakure - The Way of the Samurai Yamamoto Tsunetomo
The Complete Stories Flannery O'Connor
A Collection of Essays George Orwell
The whole record:
$ bookmeta philokalia.epub
{"v":1,"path":"philokalia.epub","source":"opf","title":"The Philokalia, Volume 3","authors":["G. E. H. Palmer","Kallistos Ware"],"publisher":"Faber & Faber","date":"1984-09-17","language":"en","series":"The Philokalia","series_index":"3","identifiers":{"isbn":"9780571175256"},"subjects":["Orthodox Christianity"]}
The ones that said nothing:
bookmeta library/* | jq -r 'select(.title == null) | .path'
Feeding a catalogue:
bookmeta library/*.epub \
| jq -c '{title, author: .authors[0], isbn: .identifiers.isbn}' \
| bibclean
Options
| Option | Effect |
|---|---|
--prompt |
Write a question about what the file did not say, instead of the record |
--merge |
Read a model's reply on stdin and fill in what was missing |
--excerpt <FILE> |
Text to show the model alongside the metadata |
--man |
Write this page as a manual page, in roff |
Exit codes
| Code | Meaning |
|---|---|
0 |
Every file said something |
1 |
At least one said nothing |
64 |
The command line was wrong |
65 |
A model's reply could not be read |
74 |
A file could not be read |
Where it reads from
| Format | Where |
|---|---|
| EPUB | The package document named by META-INF/container.xml — Dublin Core, plus the <meta> entries Calibre adds for a series |
| The trailer's information dictionary | |
| MOBI, AZW, AZW3 | The MOBI header in the first record, and the EXTH records after it |
An EPUB whose container is malformed falls back to the first .opf in the
archive, because a converter that wrote a bad container often still wrote a
usable package.
Identifiers come back under the scheme they are: urn:isbn:… and a bare ISBN
both become isbn, a UUID becomes uuid, anything else other. PDF dates
are reduced to the day where they parse and kept verbatim where they do not,
because a date nobody can read is still evidence.
A Kindle book's title is EXTH 503 where it is there, then the MOBI header's
full name, then the Palm database name (cut to 31 bytes, with a note). Authors
(100) and subjects (105) are every record of their kind, in order; publisher
(101), description (103), date (106) and language (524) the first. EXTH 104 is
an isbn when it is shaped like one and other when not, and 113 is an
asin. A contributor (108) is not an author, the same as an EPUB's
dc:contributor, and is not reported. With no EXTH language, the language
comes from the header's locale. Strings are decoded as the header says,
UTF-8 or Windows-1252. A book whose text is encrypted still has its header
read, and carries the note drm.
Every offset in a Kindle book is checked before it is followed: headers that are cut short or point outside the file give an empty record with a note, and an EXTH block that breaks off keeps the records before the break.
Covers
Metadata::cover is the cover image a Kindle book carries (EXTH 201, or the
thumbnail, 202, when no cover is named), with its media type read off its
first bytes. It is for library callers and is not part of the JSON record,
which is a line of text.
Asking a model
Some files say nothing useful. --prompt writes the question and sends
nothing — where it goes is your business, your model, your machine:
$ bookmeta --prompt --excerpt opening.txt scan.pdf
You are cataloguing a book. Below is what its file says about itself, and the
opening of the text where one was available.
Known:
path: scan.pdf
title: Microsoft Word - ch3.doc
Missing: authors, publisher, date, language
…
Reply with one JSON object and nothing else. … Use null for anything the text
does not settle — a guess is worse than an absence, because nobody downstream
can tell them apart.
Then put it wherever you like and hand the answer back:
bookmeta --prompt scan.pdf | ollama run qwen3.5:9b | bookmeta --merge scan.pdf
Four things make this safe to depend on:
- Only what was missing is filled. A model that volunteers a better title for a book whose title was already known is overruled: what the file says is evidence, what a model says is a guess.
- A null is an absence, not a value. So is an empty string.
- The prompt is versioned and printed where you can read it. A record filled this way carries a note saying which prompt version did it.
- A reply that is not one JSON object is refused, not half-applied. Prose and code fences around the object are fine.
Nothing here reaches the network, so this tool keeps the contract whole: same input, same output, every run and every machine.
As a library
let meta = bookmeta::read(std::path::Path::new("philokalia.epub"));
assert_eq!(meta.title.as_deref(), Some("The Philokalia, Volume 3"));
if let Some(prompt) = bookmeta::prompt::compose(&meta, Some(opening)) {
// send it wherever you like, then:
// bookmeta::prompt::merge(&mut meta, &reply)?;
}
bookmeta::epub, bookmeta::pdf and bookmeta::mobi read one format each, and
Metadata::missing says which fields a record still wants.