Skip to content

Save series information into ebook files so readers group them correctly - #59

Open
JordanFromIT wants to merge 1 commit into
Chaptarr:developfrom
JordanFromIT:fix/write-ebook-tags-without-calibre
Open

Save series information into ebook files so readers group them correctly#59
JordanFromIT wants to merge 1 commit into
Chaptarr:developfrom
JordanFromIT:fix/write-ebook-tags-without-calibre

Conversation

@JordanFromIT

@JordanFromIT JordanFromIT commented Aug 17, 2026

Copy link
Copy Markdown

Description

The problem. If you read your Chaptarr library in something like Kavita, books that belong to a series often show up as unrelated standalone titles instead of being grouped together. A reader works out which series a book belongs to by looking inside the ebook file. Chaptarr knows perfectly well what series a book is in and where it falls in the order — it just never writes any of that into the file, so the reader never sees it. Today the only ebooks that group correctly are the ones whose original packager happened to fill the field in already; Chaptarr itself contributes nothing either way.

Why it happens. Chaptarr already has a setting for this, Write Book Tags, and a whole ebook tag-writing subsystem behind it. But that writer only ever runs for books that have been registered with a Calibre content server, because it's gated on an id that only Calibre hands out. If you don't run Calibre — which is most people — the setting silently does nothing at all. It isn't broken so much as unreachable.

What this does. Makes that existing setting actually work without Calibre, by writing the series name and position directly into the epub. Anyone already using Calibre keeps the current behaviour, unchanged.

So this is less a new feature than making an existing setting, and an existing subsystem, do something for the majority of users.

Technical detail

Ebook tag writing is reachable only for files that carry a BookFiles.CalibreId, and that column is populated exclusively by cdb/add-book/. MetadataTagService.WriteTags gates the ebook branch on bookFile.CalibreId > 0, and SyncTags / RetagFiles / RetagAuthor each filter on CalibreId != 0. With no Calibre server there is no id, so nothing downstream ever runs. (EBookTagService.WriteTagsInternal also logs "No calibre id … skipping writing tags" without actually returning, so the message is misleading; that's fixed here too.)

The specific fields that matter are calibre:series and calibre:series_index in the epub's OPF package document — Kavita decides series membership solely from those. ReadAllEpubTags already parses both on the way in, so only the write side was missing.

This adds an OPF writer for .epub / .kepub, plus routing to it when a file's root folder is not a Calibre library (RootFolder.IsCalibreLibrary). Calibre libraries keep their existing CalibreProxy.SetFields path untouched.

Deliberate choices worth flagging for review:

  • No new setting. It honours the existing writeBookTags enum. The default is NewFiles, so an existing library is never touched until the user either downloads a new book or explicitly asks for a re-tag. There is no scheduled task that triggers re-tagging.
  • This does change behaviour on defaults, in that a newly imported epub now gets modified where previously it silently didn't. That seemed right given the setting is named "Write Book Tags" and already defaults to "for new files" — but it's the judgement call most worth challenging here.
  • Never edits in place. The archive is rewritten to a temporary file and moved into place, so an interrupted write can't truncate a book. mimetype stays first and stored.
  • Idempotent. An epub whose tags already match is left byte for byte alone.
  • Non-numeric positions. Real SeriesPosition values include things like 2 - Heavy Metal. Those record the series without a position rather than writing a value readers can't parse — the book still groups correctly, it just loses its ordering. Fractional positions like 11.5 are kept, and the index always uses an invariant decimal separator.
  • Series source. Uses the denormalised Book.SeriesName / Book.SeriesPosition, falling back to the relational series links only when those are empty. Happy to switch to the links if you'd rather be consistent with SetFields.
  • XmlDocument, not XDocument. XDocument re-derives namespace prefixes on save. An OPF package commonly binds a prefix to the same namespace URI it already uses by default, and in that case a round trip rewrote <metadata> as <opf:metadata> and every <meta> as <opf:meta>. Namespace-equivalent, but it rewrites much more of the user's book than intended and risks tripping prefix-sensitive OPF parsers. XmlDocument with PreserveWhitespace writes each node back with the prefix it was read with and emits no BOM.
  • Bulk re-tagging no longer aborts on the first unwritable file, matching how ImportApprovedBooks.TryWriteTags already treats tag writing as optional post-processing.

Known gap, deliberately not addressed: retag previews still go through Calibre only, so for a non-Calibre library the preview lists no changes while the re-tag itself now does work. Closing that needs a non-Calibre "read the current tags back and diff them" implementation, which felt like it belonged in its own PR rather than doubling the size of this one. Happy to fold it in if you'd prefer.

Database Migration

NO. No schema changes and no new columns — this reads Books.SeriesName / Books.SeriesPosition and RootFolders.IsCalibreLibrary, all of which already exist.

How was this tested?

Unit tests — 23 new tests, all written before the code and watched failing first. develop (537eb64): 2834 → this branch: 2857 (+23), 0 failing.

Run against this repo's own CI commands rather than just locally convenient ones, all clean:

step result
Guard — merge-conflict markers, package.json parse clean
version_guard.py sync OK 0.9.929
version_guard.py monotonic --compare-ref origin/develop OK 0.9.929
version_guard.py commit-hygiene --compare-ref origin/develop OK for origin/develop..HEAD
dotnet build src/Chaptarr.NoTests.sln --configuration Release 0 warnings, 0 errors
dotnet test src/Chaptarr.Core.Test/Chaptarr.Core.Test.csproj --configuration Release 2857 passed, 0 failed
dotnet publish src/NzbDrone.Console/Chaptarr.Console.csproj -c Release exit 0

No frontend files are touched (8 files changed, all .cs), so the yarn steps that live outside build.yml aren't applicable here.

  • EpubSeriesTagWriterFixture — writes when absent, updates when stale, reports unchanged and leaves the file byte-identical when tags already match, omits and clears the index when the position is unknown, keeps mimetype first and stored, invariant decimal separator under a comma-decimal culture, doesn't re-prefix elements it didn't need to touch, leaves unrelated dc: metadata alone, and a round trip proving the bundled VersOne.Epub reader can read back what was written.
  • EbookTagServiceDirectWriteFixture — routing by IsCalibreLibrary (with a Calibre proxy that throws on any call, so reaching Calibre fails the test), non-numeric and fractional position handling, no series and unsupported-format cases, config gating, backfill via RetagAuthor, sync, and continuing past a file that throws.
  • MetadataTagServiceEbookDispatchFixture — an epub with no Calibre id now dispatches; a Calibre-managed file of any format still dispatches; an unsupported format with no Calibre id still doesn't.

Test epubs are generated by the fixture from text written for the purpose; nothing is committed as a binary.

End to end, Docker on Linux: this branch's Chaptarr.Core.dll bind-mounted over the published chaptarr/chaptarr:0.9.929 image (the assembly version matches, so no full image rebuild), against an empty config and a scratch library, with three generated epubs and a non-Calibre root folder. Ran RetagAuthor from the API and checksummed before and after:

epub series data in Chaptarr result
no existing tags An Example Series / 3 both meta elements written
no existing tags An Example Series / 2 - Heavy Metal series written, index correctly omitted
already correctly tagged matching values byte identical — not rewritten

The resulting OPF keeps its original prefixes, indentation and unrelated metadata, with mimetype still first and stored. This also confirms the new interface resolves through DryIoc's auto-registration at runtime, which the unit tests can't prove.

Screenshots (UI changes only)

None — no UI changes.

If you read your Chaptarr library in something like Kavita, books that
belong to a series often show up as unrelated standalone titles instead of
being grouped together. A reader works out which series a book belongs to
by looking inside the ebook file. Chaptarr knows what series a book is in
and where it falls in the order, but never writes any of that into the
file, so the reader never sees it.

Chaptarr already has a setting for this, Write Book Tags, and an ebook
tag-writing subsystem behind it. That writer only ever runs for books
registered with a Calibre content server, because it is gated on an id only
Calibre hands out. Without Calibre the setting silently does nothing. This
makes it work by writing the series name and position straight into the
epub; Calibre users keep the existing path unchanged.

In code terms, ebook tag writing was reachable only for files carrying a
BookFiles.CalibreId, populated exclusively by cdb/add-book/.
MetadataTagService.WriteTags gated the ebook branch on CalibreId > 0, and
SyncTags, RetagFiles and RetagAuthor each filtered on CalibreId != 0. The
fields that matter are calibre:series and calibre:series_index in the OPF,
which ReadAllEpubTags already parses on the way in, so only the write side
was missing.

Behaviour:

- Writes to a temporary file and moves it into place, so an interrupted
  write cannot truncate a book, and keeps mimetype first and stored.
- Idempotent: an epub whose tags already match is left byte for byte alone.
- Series positions that are not numeric, such as "2 - Heavy Metal", record
  the series without a position rather than a value readers cannot parse.
  Fractional positions such as 11.5 are preserved, and the index is always
  written with an invariant decimal separator.
- Series data comes from the denormalised Book.SeriesName and
  SeriesPosition columns, falling back to the relational series links only
  when those are empty.
- Bulk re-tagging no longer stops at the first unwritable file, matching how
  the import path already treats tag writing as optional post-processing.
- Honours the existing writeBookTags setting rather than adding one. The
  default, NewFiles, still means an existing library is never touched until
  the user either downloads a new book or asks for a re-tag.
- EBookTagService.WriteTagsInternal logged "No calibre id, skipping writing
  tags" without returning, so the message was misleading. Now it returns.

The package document is edited with XmlDocument and PreserveWhitespace
rather than XDocument. XDocument re-derives namespace prefixes on save, and
an OPF package commonly binds a prefix to the namespace it already uses by
default, so a round trip rewrote <metadata> as <opf:metadata> and every
<meta> as <opf:meta>. That is namespace-equivalent, but it changes far more
of the reader's book than asked and risks tripping prefix-sensitive OPF
parsers. XmlDocument writes each node back with the prefix it was read
with, preserves the existing formatting, and emits no byte order mark.

Retag previews are unchanged and remain Calibre-only, so for a non-Calibre
library the preview lists no changes while the re-tag itself now does work.
Closing that gap needs a non-Calibre "read current tags" implementation and
is left for a follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4m3BKXM9ENfugXvVCbt5G
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant