Skip to content

Latest commit

 

History

93 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

epub-reader

License: MIT Python 3.13+ Tests Lint: ruff Security policy

A browser-based e-book library and reader for your own collection. Browse, search and read from a Mac, an iPad or a phone, with your place in each book kept on the server so every device agrees.

Built as a sibling to a personal DLNA gateway: FastAPI, SQLite and a vanilla front end with no build step. Rendering is foliate-js (MIT), pinned as a submodule.

Status: works and is in daily use on a library of ~2,400 books in seven formats, but it is a personal project rather than a product.

What it reads

EPUB, MOBI, AZW3, PRC, PDF, CBZ and CBR — including EPUBs that were never zipped and are stored as directories, which are zipped on the way out so nothing downstream needs to know.

Comics packed with RAR are read through libarchive — which macOS ships as /usr/bin/bsdtar — and repacked as a zip on the way out, the same trick an unpacked EPUB gets. That is 205 more comics. libarchive refuses ten of them, one run of 2003-vintage archives, and those are reported and skipped rather than taking a scan down.

Three rendering models sit behind those seven: reflowed text, rasterised pages (PDF, via pdf.js), and images page by page (CBZ, CBR) — so the typography controls apply to the first and not the other two.

Books the shop locked are excluded, not listed as titles that fail. An Apple Books purchase has every document encrypted and no reader outside Apple Books can open it. There are none left in this library — it held 50 once — but the rule stays, because encrypted fonts are a different thing entirely: 8 books here obfuscate their fonts, the Earthsea novels among them, and excluding on the encryption file alone would lose all 8 books that read perfectly.

What it does

  • Browse by Titles, Authors, Series, Genres, Formats, Years or Shelves, with a letter bar and drill-down. Formats groups what a book is rather than what it is packed in: EPUB, MOBI (with AZW3 and PRC, the same Palm container), PDF, and Comics — where a CBZ and a CBR are one shelf, because the difference is how the pictures are packed.
  • Search across titles, authors, series and genres (SQLite FTS5, with diacritics folded — le carre finds le Carré).
  • Read with four themes, adjustable type, paginated or scrolled flow, and a table of contents. On a phone or an iPad the reader takes the whole screen, and an Aa button brings the panels in over the page.
  • Highlight and annotate — four colours, notes, and a Highlights panel that jumps to the passage. Shared across devices, like your place.
  • Shelve from the list — make a shelf, throw one away, and put a book on or take it off without opening it.
  • Mark a book read — from the list or from its detail page, and it moves from Reading now to Finished, keeping your place. Books you finished years ago on something else can say so too.
  • Remembers your place server-side, keyed by content hash, so renaming or moving a file does not lose it.
  • Shelves you make, plus Reading now and Finished, which are derived from reading state rather than maintained by hand — so nothing puts a book on one, and the way off is to say you have finished it, or stopped.
  • Indexes ~2,400 books in about 8 seconds, and re-scans in under a second.

Tidying a library nobody catalogued

A collection assembled over thirty years carries other people's filing. Every pass below is dry-run by default, database-only, and reversible. No book file is ever renamed, moved, written to or deleted — the library is opened read-only.

./run.sh repair             # check authors and titles, change nothing
./run.sh repair --apply     # ...and fix what it can prove
./run.sh dedupe [--apply]   # one row per work, spares kept and restorable
./run.sh series [--apply]   # read series from filenames
./run.sh names [--apply]    # author, series and title from one filename
./run.sh coauthors [--apply]  # "(with Roberto Fuentes)" is a second author
./run.sh tags [--apply]     # "(v2.1)", "(not ocr)", underscores-for-spaces
./run.sh fragments [--apply]  # finish a title that was cut off, via a provider
./run.sh covers [--apply]   # a cover for books showing a lettered tile
./run.sh covers --online    # ...and ask a provider for the rest, slowly
./run.sh forget [--apply]   # remove books whose files are gone
./run.sh refresh [--max N]  # re-ask about the oldest author data

You do not have to run the free ones. A scan reads the names it finds — author, series and title out of the filename, a co-author freed from a title, the scanner's notes taken off — so dropping books into LIBRARY_ROOT and restarting is the whole workflow. Set TIDY_ON_SCAN=false in .env to stop it. What it will not do unasked is reach a provider: fragments and REPAIR_ON_SCAN are opt-in, because a request costs somebody something.

names and fragments take --author "Name" and --format pdf so a run can be aimed and then readcoauthors and tags are cheap, local and library-wide. Aiming matters: every one of these passes was improved by looking at what a dry run proposed before applying it, and the library-wide names run was caught proposing four wrong authors that way.

./run.sh names --author "Piers Anthony" --format pdf --apply

One filename can hold three facts. Anthony, Piers - Xanth 08 - Crewel Lye.pdf is an author, a series with a number, and a title, and until names read it the book browsed as one run-on line credited to nobody. The rules are narrow on purpose: the author must lead in Surname, First form, a series is believed only where two books by that author agree on it, and where nothing corroborates a series the words are left whole rather than cut at a guess.

A provider can finish a title the filesystem cut off. The Caterpillars Questi matches nothing anywhere, for ever; trimmed to the last whole word it finds The Caterpillar's Question. The answer must complete the fragment word for word, so the rule can only ever lengthen a name — it cannot swap in a different book.

Covers come from the book's own first page, but only where that page is a cover — one image covering it, with no text over it. A title page is a worse tile than the letter it would replace. That is 144 of this library's PDFs; the audit that followed found 14 title pages among 159, and DELETE /api/books/{id}/cover is how you say so. What you drop stays dropped.

For the books whose files hold no image at all, --online asks Open Library — one request a minute, so a full sweep is an overnight job rather than a ten-minute one, and it is resumable. Each book is asked about once ever, whether or not there was an answer. About 70% get an offer and roughly three in four of those are the right book, so this trades a few wrong covers for several hundred right ones; a wrong one goes with the same endpoint.

A scan notices a deletion but never acts on one: the row is marked missing and kept, so a vanished book leaves the shelves at once while your place in it survives an unplugged drive. forget is the deliberate step, and it refuses when most of the library is missing — that is a disk that is not mounted, not a library you emptied.

Where nothing automatic can help, you can say so yourself. PUT /api/books/{id}/authors fixes a file that is confidently wrong about its own author, and PUT /api/books/{id}/series files a book no rule could place — The Light of All That Falls is book three of the Licanius Trilogy, and neither its file nor its name says so. What you say is locked against every future scan.

What that found here: 607 authors became 577 (misspellings and spacing); 193 books filed under their own title got a real author; 731 titles that were really filenames were corrected, and 303 books that were credited to nobody — or to harry, whoever ran the scanner — now name their author; 288 spare copies are one row each; and 235 series exist where there had been 39.

Every one of those passes was run as a dry run and read before it was applied, which is the only reason the numbers are worth quoting: the library-wide author run proposed four names that were not names, and the last duplicate audit caught a book about to be hidden behind its own sibling.

Everything a provider says is cached, so an author is looked up once, ever. A weekly launchd job re-asks about the ten oldest, which walks the whole library in about forty weeks without ever looking like a bulk sweep — Open Library allows 3 requests/second to a client that identifies itself, and asks that its APIs not be used as a bulk backend.

Requirements

Python 3.13 or newer — the SQLite it bundles must be 3.44+, which is checked on the way in and reported plainly if not
Node for the UI tests only; the app itself has no build step and no JavaScript dependencies to install
A folder of books anywhere. Nothing is ever written to it — it is opened read-only
Optional: bsdtar for comics packed with RAR (.cbr). It is libarchive, and it ships with macOS

Everything else is three Python packages — FastAPI, hypercorn, Pillow — plus a vendored copy of foliate-js. No database server, no message queue, no container, no cloud account.

Setup

git clone --recurse-submodules https://github.com/YOUR-NAME/epub-reader.git
cd epub-reader

python3 -m venv .venv
.venv/bin/pip install -r requirements-dev.txt   # or requirements.txt to just run it

cp .env.example .env          # then set LIBRARY_ROOT to your books folder
./run.sh start                # scans the library, then serves on :8770

Open http://localhost:8770. That is the whole install.

The three platforms

The service is plain Python and behaves the same everywhere; only the shell wrapper and the "start it at login" step differ.

macOS — as above. ./run.sh is launchd-aware: copy com.example.epub-reader.plist to ~/Library/LaunchAgents/, edit the paths inside it, and launchctl load it to have the service start at login. Set LAUNCHD_LABEL in .env to match if you rename it. bsdtar is already installed, so .cbr comics work out of the box.

Linux — identical, including ./run.sh. For start-at-login use a systemd user unit instead of the plist:

# ~/.config/systemd/user/epub-reader.service
[Service]
WorkingDirectory=/path/to/epub-reader
ExecStart=/path/to/epub-reader/.venv/bin/python epub_gateway.py --env .env
Restart=on-failure
[Install]
WantedBy=default.target

systemctl --user enable --now epub-reader. For .cbr comics install libarchive's tools (apt install libarchive-tools, dnf install bsdtar); everything else needs nothing.

Windowsrun.sh is a bash script, so either use WSL (where the Linux instructions apply unchanged) or run the service directly, which is all run.sh does anyway:

py -m venv .venv
.venv\Scripts\pip install -r requirements.txt
copy .env.example .env        # then set LIBRARY_ROOT
.venv\Scripts\python epub_gateway.py --env .env

Paths in .env take Windows form (LIBRARY_ROOT=C:\Users\you\Books). For start-at-login, Task Scheduler with "run whether user is logged on or not". .cbr comics need bsdtar on PATH — it comes with Git for Windows and with libarchive.

./run.sh also takes stop, restart, status and scan, and start --foreground to watch it run. Every start rescans, so books dropped into the library folder are picked up without a separate step.

All configuration lives in .env — ports, paths, TLS certificates, request pacing and any API keys. Nothing is hardcoded and there is no second config file. .env is gitignored; .env.example documents every key.

Trying it without a library

.venv/bin/python tools/make_test_library.py   # writes .test-library/ and .env.test
./run.sh --env .env.test start

That builds a handful of synthetic books — one of each format, and deliberately awkward ones: a book whose whole text is a single huge section, one with a comma in its internal filenames, one stored as a directory, and an EPUB carrying hostile JavaScript. Every one of those broke something real.

Serving over TLS

Set TAILSCALE_CERT_HOST, and either put <host>.crt and <host>.key next to the .env file or point TLS_CERT_FILE / TLS_KEY_FILE at them. A missing certificate is reported plainly rather than guessed at.

Code quality

Python modules 39
Tests 1,181 pytest across 45 files, 428 Playwright across 15 specs
Linting ruff, 0 findings — enforced by a test, not by hope
Security threat model and reporting in SECURITY.md

The badges at the top are generated from the suites, not typed intools/badges.py --write rewrites them and tests/test_badges.py fails when they have drifted. A number nobody verifies is worse than no number, because a reader believes it.

.venv/bin/python -m pytest    # code
npx playwright test           # UI, against Chromium and WebKit
.venv/bin/ruff check .        # linting — also run by tests/test_lint.py

The linter was added late and found 116 things and no bugs, which is the point twice over: none of it was wrong, and nothing had been checking. The tell was eleven unused # noqa comments — suppressions written for a linter that had never existed. ruff.toml selects rules rather than taking a default, and the two genuine false positives are suppressed with the reason written next to them rather than blankly. tests/test_lint.py runs ruff and fails on anything, because a tidy-up nobody enforces lasts until the next commit.

The UI suite runs against both engines on purpose: this is mostly read in Safari, and a Chromium-only suite once stayed green while books failed to open in Safari entirely. It also runs serially — reading position and highlights are shared server-side by design, so parallel tests would race over real application state. No test touches the network: the fetcher is injected and refuses.

Security

EPUBs can contain JavaScript, and foliate-js states plainly that iframe sandboxing cannot contain it. A Content Security Policy blocking all non-self scripts is therefore mandatory and ships by default. The test suite proves the containment by rendering a hostile EPUB carrying inline and external scripts and asserting neither runs — and that test is itself verified by weakening the policy and watching it fail.

PDF support needed no relaxation of that policy: pdf.js runs its parser in a Web Worker, and a same-origin worker is already allowed. A test watches the console for refusals while a PDF opens, so if that stops being true it is a failing test rather than a blank page.

There is no authentication, and that is the shape of the thing: it is a personal service for one household, so it acts for whoever can reach the port. Two consequences are handled rather than hoped about.

EPUB_BIND defaults to loopback, and widening it onto a tailnet or a LAN is a deliberate edit with the reason written next to it.

And a request from somebody else's page is refused. Nothing here uses cookies, so a cross-site request cannot be authenticated — but it does not need to be: POST /api/books/forget-missing?apply=true&force=true drops reading positions and highlights for whoever can reach the port. A bodiless POST is a CORS "simple request", sent with no preflight, and CORS withholds only the reply — so it never prevents the act. Every state-changing method therefore checks Origin, and a foreign one is a 403. Requests with no Origin at all are allowed, because curl, run.sh and the test suite send none and only a browser can be made to forge one.

Design notes

CLAUDE.md and design.html carry the full design, the decisions behind it, and what a real 2,200-book library taught us — including several defects in the rendering engine, and a few of our own, that no synthetic fixture would have produced.

Built on other people's work

All of it is open source, and all of it is free — as in speech and as in beer. There is nothing to buy, no account to open, no tier to upgrade, no telemetry and no service that can be withdrawn. It runs on a machine you own, against files you already have.

The reading engine

foliate-js — MIT Parsing, pagination, tables of contents, in-book search, and CFI generation and resolution: the genuinely hard part of annotating a book. Vendored as a pinned submodule and never edited — differences live in static/reader.js, the only file that imports it.
zip.js — BSD-3 Reads the zip an EPUB is. Vendored by foliate-js.
fflate — MIT Inflate, small and fast. Vendored by foliate-js.
PDF.js — Apache 2.0 Draws PDF pages onto a canvas. Vendored by foliate-js.

The service

FastAPI — MIT The routes, the request models and the validation.
hypercorn — MIT The ASGI server, and the TLS.
pydantic — MIT Request bodies that refuse what they should refuse.
SQLite — public domain The whole database, including FTS5 for search. It ships inside Python; there is no server to run.
Pillow — MIT-CMU Cover thumbnails.
pypdfium2 — BSD-3 / Apache 2.0 Renders page one of a PDF when that page is the cover. Ships as a wheel with the PDFium binary in it — no build step, no network.
libarchive — BSD-2, as bsdtar Reads comics packed with RAR. It ships with macOS; nothing is installed.
python-dotenv — BSD-3 Reads the one .env that configures everything.

The metadata

Open Library — Internet Archive, free API, CC0 data Authors, bibliographies, covers and the works lists behind Also published. Their limits are published and respected: one request a second anonymously, three for a client that identifies itself, and every answer cached so nothing is ever asked twice. They ask that bulk work use their monthly data dumps instead, which is fair.
BISAC subject headings — Book Industry Study Group The ~40-term genre vocabulary in data/genres.tsv.
Google Books — free tier, optional Broader synopses and categories. Unauthenticated requests are rate-limited to nothing useful, so it is off unless you set a key.

The tools

pytest (MIT), Playwright (Apache 2.0), ruff (MIT), coverage.py (Apache 2.0) — and Python itself (PSF).

Thank you to all of them. This is a few thousand lines of glue on top of a great deal of somebody else's careful work.

Licence

MIT — see LICENSE. Do what you like with it.

About

Browser-based e-book library and reader for EPUB, MOBI, PDF and comics. Metadata repair, and your place kept across devices.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages