Files
MangaCircus/docs/import.md
MaashkinoozheandClaude Opus 5.5 f53b92a7dc Prepare the repository for a public release
- docs/deployment.md: step-by-step Docker deployment for anyone (setup,
  configuration, LAN/HTTPS/VPN access, adding manga, operation, prebuilt
  images, backups, peering, troubleshooting, rules for agents).
- deploy/circus.sh: host helper without Node (status, up/down with queue
  pause, logs, pause/resume, update, rescan, restore-test); deploy/compose.https.yml
  + Caddyfile for automatic HTTPS.
- README rewritten for public readers (Docker first, sources/copyright note,
  docs index); AGENTS.md + CLAUDE.md for contributors and coding agents.
- Removed notes about one particular installation (Windows host, handoffs,
  audits of a private library, benchmarks with local paths, wishlist, reading
  map) and the unused ops/proxmox templates; generalized remaining mentions of
  hosts, VPN and Windows-only commands.
- Defaults: TZ and reading time zone UTC; .gitattributes keeps shell/YAML LF.
- Personal agent coordination files are git-ignored (CLAUDE.local.md,
  .claude/skills/manga-org/).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:09:00 +02:00

295 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# How the import works
MangaCircus does not copy or convert anything. "Import" means **indexing the library folder in place**. The scanner (`library.js`) walks the folder tree described in [library-format.md](library-format.md) and builds an in-memory index. The server (`server.js`) swaps that index in, compares it with what it has seen before, and notifies followers of new chapters. The library itself is only ever read.
```
┌───────────── triggers ─────────────┐
│ start-up · shallow interval · daily full │
│ admin "Rescan" · admin "Full rescan"│
└────────────────┬───────────────────┘
▼
data/library-index.json ──▶ immediate in-memory index at start (if valid)
library/ ──read──▶ shallow changed-manga check or full scan library.js
├─ scanManga (per manga folder)
│ ├─ book folders → parseKey, book.json, cover
│ └─ chapter folders → scanVersion
│ ├─ chapter.json (always read)
│ └─ page list + image sizes (only if the folder changed)
└─ returns { index, cache }
▼
announce(index) new chapter ids → notifications, webhook server.js
▼
lib = index (atomic swap, requests see old or new, never half)
▼
data/scan-cache.json + data/library-index.json (atomic)
```
## 1. Triggers
| Trigger | When | Cache |
|---------|------|-------|
| Start-up | Restore the saved index immediately if its library mount is present, then check changed manga. Without a valid snapshot, scan the whole library; the page shows "Scanning…" until done | used |
| Interval | Shallow change check every `SCAN_INTERVAL` minutes (default 10, `0` = off); full reconciliation every `FULL_SCAN_INTERVAL` minutes (default 1440, `0` = off) | used |
| **Rescan** (Settings → Admin, or `POST /api/admin/rescan {}`) | On demand; the request returns when the scan is done | used |
| **Full rescan** (`POST /api/admin/rescan {"full":true}`) | On demand | ignored: every image header is read again |
The saved index records when its last full reconciliation finished. A warm restart
does a shallow check but does not reset the daily full-scan clock; if that scan is
overdue, the reader schedules it within a minute. Set `FULL_SCAN_INTERVAL=0` to
disable automatic full reconciliations, while retaining admin Rescan.
Scans are **single-flight**. A trigger during a scan queues one coalesced follow-up,
with a full reconciliation taking precedence over a shallow check. Publication
revisions trigger a shallow check; if no changed directory is visible, the reader
falls back to a full scan. In-place edits to `chapter.json` are found by the full
reconciliation or an admin Rescan, not by shallow parent-directory stamps.
## 2. Walking the tree
All directory listings go through one helper:
- It skips every entry whose name starts with `.`. That makes hidden folders the safe place for half-written chapters (see §7).
- It follows symbolic links to directories.
- It sorts names with a natural, case-insensitive collator (`c2` before `c10`).
### 2.1 Manga folders
For each folder in the library root:
1. List it and read `manga.json`.
- A missing file becomes `{}`.
- Broken JSON is logged and becomes `{}`.
- A UTF-8 BOM is stripped.
2. `id` = folder name. `title` = `manga.json.title`, else the folder name. `direction` = `manga.json.direction`.
3. The cover is the first file matching `cover.(jpg|jpeg|png|webp|gif|avif|bmp)`.
4. Scan its book folders (2.2).
5. Drop the manga (with a log line) if it ends up without chapters. An exception inside one manga skips only that manga.
### 2.2 Book folders
For each sub-folder:
1. `parseKey(name)` with `^([a-z]*) ?(\d+(?:\.\d+)?)(?=$|[\s_\-[(])[\s_-]*(.*)$` (case-insensitive).
- No match → ignored and logged.
- Letters exactly `x` → extra book.
- key = `x?` + `parseFloat(number)`.
- A trailing `[…]` is split off as the source name. The rest is the title.
2. A **duplicate book key** within the manga is ignored and logged. The first folder in sort order wins.
3. Read `book.json`:
- `title` overrides the folder title.
- `extra: true` marks the book as extra.
- `direction` is used for inheritance.
4. Cover: `cover.*` in the folder.
5. Scan its chapter folders (2.3). A book without chapters is dropped.
### 2.3 Chapter version folders
Each chapter folder is one **version**:
1. `parseKey(name)` → the chapter key; no match → ignored and logged.
2. `stat` the folder and **always** read `chapter.json`. It is small, and edits to it must show up even though they don't change the folder's mtime.
3. **Cache check.** The cache key is `Manga/Book/Chapter` (always `/`). If the cached folder mtime equals the current one, the cached page list is reused. Otherwise:
- List the files and keep those matching `\.(jpe?g|png|webp|gif|avif|bmp)$`.
- Read the pixel size of each (§3).
- Store `{ m: mtimeMs, p: [[file, width, height], …] }`.
4. Per page, derive:
- **tags**: the name without extension, lower-cased, split on `_`, without the first part (`0012_double_rot90.jpg` → `double, rot90`),
- **rotation**: the first `rot90|rot180|rot270` tag.
5. Version fields:
| Field | Source |
|-------|--------|
| `id` | folder name |
| `source`, `sourceUrl` | `chapter.json.source` (`{name,url}` or a string), else the `[…]` part of the folder name |
| `added` | `Date.parse(chapter.json.added)`, else the folder mtime |
| `direction` | manual chapter → book → manga; else coherent tall-strip dimensions → `ttb`; else prior generated vertical series direction; else fallback `rtl`. Aliases: `vertical`, `webtoon` → `ttb`. Legacy plain values remain manual |
6. A version without images is dropped silently.
### 2.4 Grouping versions into chapters
Versions in the same book with the same chapter key form one chapter:
| Field | Rule |
|-------|------|
| `id` | `<book key>/<chapter key>`, e.g. `1/2.5`, `x1/x1` |
| `label` | `Ch. <n>` or `Extra <n>` |
| `extra` | book is extra, or letters `x`, or any version's `chapter.json` has `extra: true` |
| `title` | first non-empty `chapter.json.title` / folder title, in folder order |
| `versions` | in folder order. Ranking by votes happens later, per request (§6) |
The manga's `updated` is the newest `added` of all versions.
### 2.5 Sorting and fallbacks
- Books, and chapters within a book, sort by `(extra-by-x-prefix, number)`. `extra: true` from JSON does **not** move anything.
- The manga's reading order is the concatenation of its books' chapters.
- Mangas sort by title.
- Covers fall back as follows:
- book → first `cover`-tagged page (else the first page) of the first chapter's first version,
- manga → first book's cover.
### 2.6 The result
```js
index = {
mangas: [{
id, title, meta /* manga.json as is */, direction, cover /* relative path */, updated,
books: [{ key, num, x, extra, label, title, cover, chapters: [...] }],
chapters: [{ id, book, key, num, x, extra, label, title,
versions: [{ id, rel, meta, source, sourceUrl, added, direction,
pages: [{ f, w, h, t: [tags], r }] }] }],
chapterById: Map,
}],
byId: Map,
}
```
Everything is plain data, and the whole index lives in memory.
## 3. Image sizes
The reader lays pages out before they load, so it needs every image's size. Instead of decoding images, the scanner reads the **first 32 KB** and parses the header; only if the size isn't there yet does it read on to 256 KB (and, for JPEG, the whole file). Parsing a prefix either finds nothing or the same size as the whole file, so the small first read never changes a result.
Uncached chapter folders and their images are probed **`SCAN_CONCURRENCY` at a time** (default 8, 1–64; chapters of a book in parallel, then assembled in folder order). A first scan over a NAS is bound by per-file latency, not bandwidth: on a VM reading a Synology NAS over NFS the old sequential 256 KB probe took about 20 ms per image; see §3.1 for the measurements. `SCAN_CONCURRENCY=1` restores the sequential order.
| Format | Where the size is |
|--------|-------------------|
| PNG | IHDR (bytes 16–23) |
| GIF | logical screen (bytes 6–9) |
| BMP | DIB header (bytes 18–25) |
| WebP | `VP8 ` / `VP8L` / `VP8X` chunk |
| JPEG | first SOFn marker (the scan skips APPn/EXIF/ICC segments). If it isn't within 32 KB, then 256 KB, the whole file is read once |
| AVIF / HEIF | first `ispe` box |
### 3.1 Measured first-scan speed
A Debian VM (4 vCPU) reading a Synology NAS over NFS 4.0, page cache dropped before each run, production
running alongside. Each variant scanned a different manga of ~5,000 pages (the NAS caches what it has just read).
| Variant | ms per image (round 1 / round 2) |
|---|---|
| sequential, 256 KB (before) | 7.4 / 9.7 |
| 32 KB first read, `SCAN_CONCURRENCY=1` | 3.6 / 6.0 |
| 32 KB first read, `SCAN_CONCURRENCY=8` (default) | 2.9 / 2.1 |
| 32 KB first read, `SCAN_CONCURRENCY=16` | 2.4 / 3.3 |
Whole library without cache (56 manga, 10,766 chapters, 299,467 pages) with the default: **825 s (13.8 min)**,
against an estimated 37–48 min before (about 4 h while the NAS was busy with a large copy). The page lists and
sizes of all 10,775 cached chapter folders were identical to those of the previous code.
- If the size can't be read, it is stored as `0×0`. The reader then assumes 1000×1414 (2000×1414 for `double`) and corrects the layout when the image arrives.
- EXIF orientation is **not** applied. Use `rot…` tags instead.
## 4. The scan cache
- Stored in `DATA/scan-cache.json`. It is rewritten atomically after every successful scan and holds only the folders seen in that scan, so deleted chapters drop out.
- `DATA/library-index.json` also stores the complete rebuildable index and shallow
manga/book directory stamps. It lets a restarted reader serve the dashboard and
manga pages before the background filesystem check completes. Both files can be
deleted safely; without the index the next startup walks the library, and without
the size cache it also rereads image headers.
- Invalidation is **per chapter folder, by its mtime**. Adding, removing or renaming a file changes the folder mtime, so that folder is re-read on the next scan.
- **Blind spot:** overwriting an image *in place* (same name) usually doesn't change the folder's mtime, so the old size stays cached. Run a **Full rescan** after such edits, or write new files and rename them (§7).
- Deleting the cache file is harmless. The next scan is simply slower.
Measured on the demo library (5 manga, 22 chapters, 213 pages):
- ~100 ms without cache on a local disk,
- 1.4 s without cache and 0.11 s with cache through a Docker Desktop bind mount on Windows.
The cost per chapter version is:
- unchanged: one `stat` + one small JSON read,
- changed: + one directory listing + ≤ 256 KB read per image.
The scan uses asynchronous I/O one file at a time, so it never blocks request handling.
## 5. Change detection and notifications
`db.json` keeps `known[mangaId]`, the list of chapter ids ever seen for a manga. After every scan, `announce(index)` runs:
1. **First sight of a manga** (no `known` entry): record all its chapter ids and **don't notify**. A newly added series must not flood followers.
2. Otherwise, a chapter id not in `known` is new. It is appended; the list **only grows**, so a temporarily missing folder or an unmounted disk never causes a second round of notifications when it comes back.
3. For every user following the manga:
- one in-app notification per new chapter (text `Title · Ch. 7: Thaw`; at most 200 kept per user),
- if the user has a webhook URL, **one** `POST` per manga per scan, with a plain-text body (`New in Moon Garden: Ch. 7 Thaw, Ch. 8 Bloom`), header `Title: MangaCircus`, and a 10 s timeout. Failures are logged, not retried.
4. Opening a chapter marks its notifications as read.
What counts as "new" follows from the chapter id (`book key/chapter key`):
| Change | Notifies? |
|--------|-----------|
| A new chapter number appears | yes |
| Another **version** of an existing chapter appears | no |
| A chapter is moved to another book or renumbered | yes (new id) |
| A manga folder is renamed | no (it is a new manga) |
| A chapter folder is created before its images are complete | yes, immediately. Hence §7 |
## 6. From index to API
The index is read by the API at request time. Nothing is precomputed per user.
| Endpoint | Built from |
|----------|------------|
| `GET /api/library` | manga list + searchable metadata (`altTitles`, `authors`, `tags`, `status`, normalized first release), the user's progress, follows and unread notifications |
| `GET /api/manga?m=` | manga incl. `meta` (the raw `manga.json`), books, chapters with ranked versions and the user's progress, "Continue" target |
| `GET /api/chapter?m=&c=&v=` | pages of the chosen version (`v`, else the best ranked), direction, prev/next chapter (skipping extras if the user wants that), the user's progress |
| `GET /lib/<path>` | the image file itself. Logged-in users only, image extensions only, path must stay inside `LIBRARY`. ETag + `Cache-Control: private, max-age=86400` |
**Version ranking** happens per request:
1. the sum of all users' votes,
2. then `chapter.json.priority` (higher first),
3. then folder order.
Votes, progress and follows are keyed by manga id, chapter id and version id. They live in `db.json`, never in the library.
## 7. Consistency: writing into a live library
The scanner can run at any moment, including while a tool is still writing a chapter. Without care, it indexes a half-copied chapter, and followers get notified about it. The rules that keep this safe:
1. **Stage in a hidden folder, publish with one rename.**
- Write the chapter into a sibling folder whose name starts with `.` (for example `v02 Summer/.incoming-c0007/`). The scanner never looks at it.
- Rename it to its final name when it is complete.
- A rename within one file system is atomic: the next scan sees all of the chapter or none of it.
2. **Replace versions the same way**: stage the new folder, move the old one out (into a hidden name), move the new one in, then delete the old one.
3. **Don't overwrite files in place** (see §4). If you must, trigger a Full rescan afterwards.
On the server side:
- The new index replaces the old one in a single assignment, so requests never see a half-built index.
- If the whole scan fails (for example, the library folder is missing), the previous index stays active and the error is shown under Settings → Admin.
The full rule set for tools is in [scraper-guide.md](scraper-guide.md).
## 8. Operations
**Trigger a rescan from a script:**
```bash
curl -s -c jar.txt -H 'Content-Type: application/json' -d '{"name":"admin","password":"…"}' http://localhost:8080/api/login
curl -s -b jar.txt -H 'Content-Type: application/json' -d '{}' http://localhost:8080/api/admin/rescan
```
The response contains `scan: { at, ms, mangas, chapters, pages, error }`. Use `{"full":true}` for a full rescan. POST requests must send `Content-Type: application/json` (part of the CSRF protection).
**Log lines** (stdout, `docker logs`):
| Message | Meaning / fix |
|---------|---------------|
| `[scan] 5 manga, 22 chapters, 213 pages in 103 ms` | Scan finished |
| `[scan] ignoring "M/B": book folders must be named <letters><number>[ title]…` | Folder name has no valid key: letters, optional space, number, then space/end. Rename it |
| `[scan] ignoring "M/B/C": chapter folders must be named …` | Same for a chapter (typical: `c0012a`, `Ch.12`) |
| `[scan] ignoring "M/B": another book folder already uses number N` | Two book folders with the same number. Merge them or renumber one |
| `[scan] ignoring broken …/chapter.json: …` | Invalid JSON. The folder still works with name-based defaults |
| `[scan] "M" has no chapters (expected <manga>/<book>/<chapter>/<images>)` | Usually a missing book level (images or chapters directly in the manga folder) |
| `[scan] skipped "M": …` | Unexpected I/O error in that manga (permissions?) |
| `[scan] failed: …` | The library root can't be read. The old index stays active |
| `[notify] webhook of <user> failed: …` | The webhook URL is unreachable or returned an error |
**Troubleshooting:**
| Symptom | Likely cause |
|---------|--------------|
| New chapter doesn't show up | Next interval hasn't come yet (rescan manually), hidden or invalid folder name, or no image files |
| Pages in the wrong order | Numbers not zero-padded, or a mix of padding widths |
| A page looks squashed or stretched after being replaced | Image overwritten in place with other dimensions. Run a Full rescan |
| Followers were notified about an empty or partial chapter | The chapter was written in place instead of staged and renamed |
| Reading progress "lost" | Manga folder renamed, or chapter moved or renumbered (new id) |