From 8dd776b7f4671ddf423775c50bd160ca49561e5c Mon Sep 17 00:00:00 2001 From: maashkinoozhe Date: Thu, 1 Oct 2026 09:35:05 +0200 Subject: [PATCH] MangaParty: self-hosted manga/comic/webtoon reader Zero-dependency Node server + plain HTML/JS frontend. - Library is a folder tree (Manga/Book/Chapter/pages), indexed in place; page roles (cover, double, extra, rotation) are file name tags - Accounts with per-user progress, reading settings, follows and new-chapter notifications (in-app + optional webhook) - Reader with Flow, Page and Book modes, pointer-anchored page zoom, tap zones, auto-hiding controls, chapter picker, page preloading - Multiple versions per chapter with community votes - Docker image and compose file - Demo library generator (tools/make-dummy.js) - Docs: architecture, library format, import, scraper guide --- .claude/launch.json | 11 + .dockerignore | 5 + .gitignore | 46 +++ Dockerfile | 12 + README.md | 105 +++++++ docker-compose.yml | 15 + docs/architecture.md | 312 +++++++++++++++++++++ docs/import.md | 261 +++++++++++++++++ docs/library-format.md | 248 +++++++++++++++++ docs/scraper-guide.md | 369 +++++++++++++++++++++++++ library.js | 182 ++++++++++++ package.json | 14 + public/app.css | 175 ++++++++++++ public/common.js | 94 +++++++ public/icon.svg | 1 + public/index.html | 17 ++ public/library.js | 45 +++ public/login.html | 26 ++ public/login.js | 26 ++ public/manga.html | 17 ++ public/manga.js | 95 +++++++ public/manifest.webmanifest | 10 + public/read.html | 47 ++++ public/reader.js | 538 ++++++++++++++++++++++++++++++++++++ public/settings.html | 17 ++ public/settings.js | 105 +++++++ server.js | 377 +++++++++++++++++++++++++ tools/make-dummy.js | 215 ++++++++++++++ tools/png.js | 114 ++++++++ 29 files changed, 3499 insertions(+) create mode 100644 .claude/launch.json create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 docs/architecture.md create mode 100644 docs/import.md create mode 100644 docs/library-format.md create mode 100644 docs/scraper-guide.md create mode 100644 library.js create mode 100644 package.json create mode 100644 public/app.css create mode 100644 public/common.js create mode 100644 public/icon.svg create mode 100644 public/index.html create mode 100644 public/library.js create mode 100644 public/login.html create mode 100644 public/login.js create mode 100644 public/manga.html create mode 100644 public/manga.js create mode 100644 public/manifest.webmanifest create mode 100644 public/read.html create mode 100644 public/reader.js create mode 100644 public/settings.html create mode 100644 public/settings.js create mode 100644 server.js create mode 100644 tools/make-dummy.js create mode 100644 tools/png.js diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..95ef42c --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "mangaparty", + "runtimeExecutable": "node", + "runtimeArgs": ["server.js"], + "port": 8080 + } + ] +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..00582b0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +data +library +.claude +.git +node_modules diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f05199 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# --- MangaParty runtime data (never commit) --- +# user accounts (password hashes), sessions, progress, scan cache +/data/ +# the manga library: demo content is generated by `node tools/make-dummy.js`, a real one is yours alone +/library/ + +# --- Node --- +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.npm/ +*.tgz + +# --- secrets / local config --- +.env +.env.* +!.env.example +*.pem +*.key + +# --- temp files, also those of tools writing into a library --- +*.tmp +*.log +.staging-*/ +.old-*/ + +# --- Claude Code: keep the shared launch config, drop personal settings --- +.claude/settings.local.json +.claude/worktrees/ + +# --- OS --- +.DS_Store +Thumbs.db +desktop.ini +$RECYCLE.BIN/ + +# --- editors / IDEs --- +.vscode/* +!.vscode/launch.json +!.vscode/extensions.json +.idea/ +*.swp +*.swo +*~ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9b64d72 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:24-alpine +WORKDIR /app +COPY package.json server.js library.js ./ +COPY public ./public +COPY tools ./tools +RUN mkdir -p /library /data && chown node:node /data +ENV PORT=8080 LIBRARY=/library DATA=/data +VOLUME ["/data"] +EXPOSE 8080 +USER node +HEALTHCHECK --interval=1m --timeout=5s CMD wget -qO- http://127.0.0.1:8080/api/auth >/dev/null || exit 1 +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..13f2f92 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# MangaParty + +A no-frills, self-hosted manga / comic / webtoon reader. + +- **Zero dependencies.** A Node.js server (`server.js`, `library.js`) and plain HTML/CSS/JS pages. No build step, no framework, no database server. +- Reads the manga straight from a folder tree. The same folders stay readable in any normal picture viewer. +- Accounts with per-user reading progress, view settings and "new chapter" notifications. +- Reader with three view modes (**Flow**, **Page**, **Book**), a page zoom that leaves the controls alone, and tap zones for mobile. + +## Run it + +```bash +node server.js +``` + +Open http://localhost:8080. The first account you create becomes the admin. Try it with demo content: + +```bash +node tools/make-dummy.js library +``` + +### Docker + +```bash +docker compose up -d --build +``` + +`docker-compose.yml` mounts `./library` read-only at `/library` and keeps accounts/progress in the `mangaparty-data` volume (`/data`). + +### Settings (environment variables) + +| Variable | Default | Meaning | +|-----------------|-------------|---------| +| `PORT` | `8080` | HTTP port | +| `LIBRARY` | `./library` | Library folder (read-only access is enough) | +| `DATA` | `./data` | Where `db.json` (users, progress, votes, notifications) and `scan-cache.json` live | +| `SIGNUP` | `closed` | `open` lets anyone create an account. Otherwise the admin adds users under Settings | +| `SCAN_INTERVAL` | `10` | Minutes between automatic library rescans (`0` = only at start and on "Rescan") | +| `COOKIE_SECURE` | – | `1` marks the login cookie `Secure` (use it when you serve over HTTPS) | + +Serve it behind a reverse proxy with HTTPS if it is reachable from the internet. All pages use relative URLs, so a sub-path like `/manga/` works. + +--- + +## Library format + +Always three folder levels, **Manga / Book / Chapter**, with plain page images in the chapter folder. Page roles are tags in the file name: + +``` +library/ + Moon Garden/ <- manga folder (its name is the manga's id) + manga.json cover.jpg <- optional + v01 Spring/ <- book (volume / season) + c0001 The Seed/ <- chapter + chapter.json <- recommended + 0001_cover.jpg 0002.jpg 0003_double.jpg 0004_rot90.jpg 0005_extra.jpg + c0002 Roots [MangaDex]/ <- same number twice = two versions of one chapter + c0002 Roots [Official]/ + c0002.5 Tea Time/ <- in-between chapter + x01 Character Sheets/ <- extra chapter, outside the numbering + x01 Specials/ <- extra book +``` + +## Documentation + +| Document | For | +|----------|-----| +| [docs/library-format.md](docs/library-format.md) | The folder format: naming rules, page tags, metadata fields, versions, extras, what survives a rename | +| [docs/import.md](docs/import.md) | How the library is scanned and indexed, caching, change detection and notifications, troubleshooting | +| [docs/architecture.md](docs/architecture.md) | Components, data model, API, security, the reader's layout engine, deployment, design decisions | +| [docs/scraper-guide.md](docs/scraper-guide.md) | Rule book (agent-readable) for scrapers and other tools that write into the library | + +--- + +## Reading + +| | | +|---|---| +| **Flow** | All pages below each other at the same width. Double pages keep the same height and are twice as wide, but never wider than the screen. Webtoon slices join without gaps | +| **Page** | One page (or one double page) at a time | +| **Book** | Two facing pages, in reading order (right page first for `rtl`). Covers and double pages stand alone. Pairing restarts after each of them, so one odd page never shifts the whole chapter. If pairs still look wrong, **⇄ Shift** moves the pairs of the current section by one page (remembered per chapter) | + +- **Which mode opens:** Settings → *Open chapters in* = a fixed mode or *the mode I used last*. With *Use the fitting mode…* on, content that isn't made for your choice opens in the mode that fits (webtoons always open in Flow). That automatic switch never changes your remembered mode. Only clicking a mode in the reader does. +- **Page zoom:** 100 % means the page fits the screen (in Flow: a normal single page fits the screen; for webtoons, the strip is as wide as the screen but never wider than its own width). Pages may end up larger than the screen, exactly fitting, or smaller. Zoom with Ctrl + mouse wheel, touchpad pinch or two-finger pinch on the pages, or with `+` / `-` / `0`. This zooms only the pages; zooming over the toolbar zooms the whole page as usual. When a page is larger than the screen, drag it with the mouse (hand cursor) or pan with a finger. The zoom is remembered per mode for your account. Click the `100%` button to fit again. +- **Tap / click zones:** + - Tapping **beside the pages** always shows or hides the controls. + - On a page, its left and right part turn the page, following the reading direction. For `rtl`, the left part goes forward. + - In Flow: the bottom part of the screen goes forward, the top part goes back. + - The middle of the page also toggles the controls (useful when a zoomed page fills the screen). + - A horizontal swipe also turns pages on touch screens. + - Keys: arrows, Space / PageDown, `m` = controls, `f` = fullscreen. +- **Controls stay out of the way:** they hide as soon as you turn a page or scroll, and come back only with a tap (middle of the page or beside it). Scrolling up never brings them back. The first time they hide, a short hint says so. +- **Controls:** the top bar has a chapter picker with every chapter of the manga, grouped by book, with ✓ for read ones. The bottom bar has the page slider, the mode switch, Shift, zoom, the version picker and previous/next chapter. +- **Loading:** the next pages (next 3 in Page mode, next 2 spreads in Book mode) are downloaded and decoded in advance, so page turns don't flash. A page that still isn't there shows a spinner. On phones, "Add to home screen" opens the app without browser bars, and the ⛶ button (where supported) goes fullscreen. +- **Progress** is saved per user and chapter version: the last page, whether the chapter is done, and book-mode shifts. The manga page offers *Continue*. Tap a chapter's status to mark it read/unread. +- **Notifications:** press *Notify me* on a manga. When a rescan finds new chapter numbers, they show up under the bell. Optionally, the server also POSTs a short text to a webhook URL of your choice (e.g. an ntfy.sh or Gotify topic, set in Settings). + +## Files + +``` +server.js HTTP server, accounts, progress, votes, notifications (JSON file storage) +library.js folder scanner + image size reader +public/ the web pages (library, manga, reader, settings, login) +tools/ demo library generator (pure Node, draws PNGs itself) +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..789752b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + mangaparty: + build: . + ports: + - "8080:8080" + volumes: + - ./library:/library:ro # your manga folders (read-only is fine) + - mangaparty-data:/data # users, progress, votes, scan cache + environment: + SIGNUP: closed # "open" lets anyone create an account + SCAN_INTERVAL: 10 # minutes between automatic rescans (0 = off) + restart: unless-stopped + +volumes: + mangaparty-data: diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..481dbcf --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,312 @@ +# MangaParty architecture + +MangaParty is a self-hosted reader for manga, comics and webtoons. One Node.js process serves a handful of static pages and a small JSON API. The content is a folder tree on disk, and everything user-related lives in one JSON file. + +Related documents: +- [library-format.md](library-format.md): the folder format. +- [import.md](import.md): how the folder tree is indexed. +- [scraper-guide.md](scraper-guide.md): rules for tools that write the library. + +## 1. Principles + +| Principle | Consequence | +|-----------|-------------| +| **The file system is the source of truth for content** | No import step, no copies, no content database. The library can be mounted read-only. Anything that writes files can feed it | +| **As dumb as possible** | No dependencies (`package.json` has none), no framework, no build step, no transpiler. Plain ES2019+ JavaScript in the browser, plain Node on the server | +| **One process, one data file** | In-memory library index, user data in `data/db.json`. Back up one file, run one container | +| **Readable without the app** | The folder format works in any picture viewer. The app adds progress, layout and convenience, not lock-in | + +## 2. System context + +``` + ┌──────────────┐ writes folders (staged + renamed) + scraper / ───▶│ library/ │◀──────────────────────────────────┐ + person │ (read-only │ │ + │ for the app)│ │ + └──────┬───────┘ │ + │ readdir / stat / small reads │ + ┌──────▼─────────────────────────────┐ │ + browser ─────▶│ node server.js │ webhook │ + (desktop, │ ├ library.js scanner → index │────────────▶ ntfy / Gotify / … + phone, PWA) │ ├ HTTP: / /lib/… /api/… │ (optional) + │ └ data/db.json data/scan-cache.json│ + └─────────────────────────────────────┘ +``` + +## 3. Components + +| Path | Lines | Responsibility | +|------|------:|----------------| +| `server.js` | ~350 | HTTP server and router, auth and sessions, JSON storage, API handlers, scan scheduling, notifications and webhooks | +| `library.js` | ~170 | Folder scanner, name parsing, image-size reader, scan cache | +| `public/common.js` | ~85 | Browser helpers: `h()` element builder, `api()` fetch wrapper, top navigation with the notification bell | +| `public/index.html` + `library.js` | ~60 | Library overview (cover grid, filter, sort) | +| `public/manga.html` + `manga.js` | ~110 | Manga page: info, follow, chapter list with book separators, versions and votes, read/unread toggle | +| `public/read.html` + `reader.js` | ~470 | The reader (§7.2) | +| `public/settings.html` + `settings.js` | ~115 | Reading preferences, webhook, password, admin (rescan, users) | +| `public/login.html` + `login.js` | ~50 | Sign-in, first-run admin creation, optional sign-up | +| `public/app.css` | ~150 | All styles (dark theme, responsive) | +| `tools/png.js`, `tools/make-dummy.js` | ~310 | Demo library generator: a tiny PNG encoder with a bitmap font, no image libraries | +| `Dockerfile`, `docker-compose.yml` | | `node:24-alpine`, non-root, `/library` + `/data` | + +## 4. Runtime model + +- **One process, event loop only.** All disk I/O in the scanner is asynchronous and sequential, so a scan never blocks requests. +- **In-memory state:** + - `lib` is the current library index, replaced as a whole after each scan. + - `db` is all user data, loaded from `db.json` at start. +- **Timers:** + - a library scan at start and every `SCAN_INTERVAL` minutes, + - a debounced `db.json` write, 500 ms after the last change. +- **Shutdown:** `SIGINT` / `SIGTERM` flush `db.json` synchronously, then exit. That matters for `docker stop`. +- **Durability:** every file write goes to `*.tmp` first and is then renamed over the target, so a file is never half-written. A hard kill loses at most the changes of the last ~0.5 s. A corrupt `db.json` makes the server **refuse to start** rather than start empty and overwrite it. + +## 5. Data model + +### 5.1 Content: the library index + +The index is built by `library.js` from the folder tree (see [import.md §2.6](import.md#26-the-result)). It is never written anywhere, except for the scan cache. + +### 5.2 Identity + +Every reference from user data to content uses ids derived from names on disk: + +| Entity | Id | Example | Stable under | +|--------|----|---------|--------------| +| Manga | manga folder name | `Moon Garden` | JSON edits, anything inside the folder | +| Book | normalised book number (`x` prefix for extras) | `1`, `x1` | title changes | +| Chapter | `/` | `1/2.5` | title changes, adding/removing versions | +| Version | chapter folder name | `c0002 Roots [MangaDex]` | nothing: renaming the folder creates a new version | + +This keeps ids human-readable and derivable without a database. The price: renaming a manga folder, renumbering a chapter or moving it to another book creates a new entity (see [library-format.md → Renaming](library-format.md#renaming-what-survives)). + +### 5.3 User data: `data/db.json` + +```jsonc +{ + "users": { + "admin": { + "name": "admin", "admin": true, "created": 1790796794000, + "hash": ":", + "settings": { + "startMode": "last", // "last" | "flow" | "page" | "book" + "lastMode": "book", // the user's intended mode; changed only by clicking a mode + "adapt": true, // use a fitting mode for incompatible content (webtoon → flow) + "skipExtras": false, "skipCovers": false, + "zoom": { "flow": 1, "page": 1.5, "book": 1, "strip": 0.8 }, // page zoom per mode + "webhook": "" + } + } + }, + "sessions": { "": { "u": "admin", "t": 1790796794000 } }, + "progress": { "admin": { "Moon Garden": { "1/2": { + "v": "c0002 Roots [MangaDex]", // version read + "p": 4, "n": 14, // page index in that version (all pages), page count + "t": 1790796800000, // last activity + "done": false, // sticky once true (reset via "mark unread") + "shift": [] // book mode: run starts shown alone + } } } }, + "follows": { "admin": { "Moon Garden": true } }, + "notes": { "admin": [ { "id": "a1b2c3d4e5f6", "t": 1790797300000, "m": "Moon Garden", "c": "2/6", + "text": "Moon Garden · Ch. 6: Frost", "read": false } ] }, + "votes": { "Moon Garden": { "1/2": { "c0002 Roots [Official]": { "admin": 1, "reader": -1 } } } }, + "known": { "Moon Garden": ["1/1", "1/2", "1/2.5", "..."] } // every chapter id ever seen (append-only) +} +``` + +- `data/scan-cache.json` holds `{ "//": { m: , p: [[file, w, h], …] } }`. It is disposable. +- **Backups:** copy `data/db.json`. The atomic writes make a copy at any time consistent. + +## 6. Server + +### 6.1 Request routing + +| Path | Handling | +|------|----------| +| `/api/*` | `routes[" "]`. Handlers get `{ u, b, q, req, res }` and return an object, which is sent as JSON. Throwing `HttpError` sends `{ error }` with that status | +| `/lib/*` | Image files from `LIBRARY`. Requires a session, allows image extensions only, and the path must resolve inside `LIBRARY`. `ETag` + `private, max-age=86400` | +| everything else | Static files from `public/` (`/` → `index.html`). `ETag` + `no-cache` | + +Pages are static. Each page's script calls the API and redirects to `login.html?next=…` on `401`. All URLs are relative, so the app also works under a sub-path behind a reverse proxy. + +### 6.2 API + +All requests and responses are JSON. POSTs require `Content-Type: application/json`. + +| Method & path | Auth | Purpose | +|---------------|------|---------| +| `GET /api/auth` | public | `{ firstRun, signup }` for the login page | +| `POST /api/signup` `{name, password}` | public | First account (becomes admin), or anyone when `SIGNUP=open` | +| `POST /api/login` `{name, password}` | public | Starts a session (cookie `sid`) | +| `POST /api/logout` | user | Ends the session | +| `GET /api/me` | user | Name, admin flag, settings, unread count | +| `POST /api/settings` `{…partial}` | user | Validated update of the settings keys above | +| `POST /api/password` `{old, new}` | user | Change password | +| `GET /api/library` | user | Manga list with covers, counts, the user's read count, unread notifications, last read | +| `GET /api/manga?m=` | user | Manga details, books, chapters with ranked versions and the user's progress, `continue` target | +| `GET /api/chapter?m=&c=[&v=]` | user | Pages (`u, w, h, t, r`), direction, versions, prev/next, progress, plus all books and chapters of the manga (with the user's read state) for the chapter picker. Also marks that chapter's notifications read | +| `POST /api/progress` `{m, c, v, p, n, done, shift}` | user | Save the reading position | +| `POST /api/unread` `{m, c}` | user | Forget progress for a chapter | +| `POST /api/vote` `{m, c, v, value: 1\|-1\|0}` | user | Vote a version; returns the re-ranked versions | +| `POST /api/follow` `{m, on}` | user | Follow a manga for notifications | +| `GET /api/notifications` · `POST /api/notifications/read` `{id \| all}` | user | Notification list and read state | +| `GET /api/admin` | admin | Users, last scan result, library path | +| `POST /api/admin/rescan` `{full}` | admin | Scan now, and wait for the result | +| `POST /api/admin/user` `{name, password, admin}` | admin | Create a user or reset a password | +| `POST /api/admin/user/delete` `{name}` | admin | Delete a user and their data | + +### 6.3 Authentication and security + +| Concern | Measure | +|---------|---------| +| Passwords | `scrypt` (32-byte key, 16-byte random salt), constant-time compare. Unknown users are checked against a dummy hash to keep timing similar | +| Sessions | 256-bit random token in cookie `sid` (`HttpOnly`, `SameSite=Lax`, 1 year, `Secure` with `COOKIE_SECURE=1`). Only its SHA-256 is stored. Pruned at start after a year or when the user is gone | +| CSRF | POSTs must be `application/json` (plain cross-site forms can't send that) and must not carry `Sec-Fetch-Site: cross-site` | +| Path traversal | Paths are decoded, resolved and checked against the root directory. `/lib/` serves only image extensions, never the JSON files | +| XSS | The UI builds DOM nodes and inserts all data as text (`h()` helper). The only markup inserted is the app's own constant SVG icons. External links are rendered only for `http(s)` URLs | +| Request size | JSON bodies are limited to 1 MB | +| Authorisation | `admin` flag checked per admin route. Users only ever see their own progress, follows and notifications. Votes are shown as totals plus the user's own | +| Not included | Login rate limiting, a CSP header, TLS. Run behind a reverse proxy if exposed. Webhook URLs are user-chosen, so any user can make the server send a POST to an address they choose. Fine for a household; consider it before `SIGNUP=open` | + +### 6.4 Version ranking + +At request time: the sum of all users' votes, then `chapter.json.priority`, then folder order. The top version is what "open chapter" shows. An explicit `v=` (version picker, "Continue" of a manually chosen version) overrides it. + +### 6.5 Notifications + +In-app notifications and optional webhooks are produced by `announce()` after each scan, from the append-only `known` list. See [import.md §5](import.md#5-change-detection-and-notifications). + +## 7. Frontend + +### 7.1 Pages + +Every page is a static HTML file plus one script, loaded with `defer`. All pages share `common.js` and `app.css`. There is no client-side router, no state library and no component system. Each script fetches what it needs, builds DOM with `h()`, and re-renders small parts on change. + +A web app manifest (`display: standalone`) makes "Add to home screen" open the app without browser bars on phones. + +### 7.2 The reader (`reader.js`) + +**Inputs:** +- the chapter payload (pages with pixel sizes, tags and rotation; direction; versions; neighbours; progress), +- the user's settings. + +**Page model.** Each page's size is known before it loads, so the reader computes the whole layout itself and positions every page absolutely: + +``` +all pages ──filter (skipExtras / skipCovers)──▶ list +list ──mode──▶ build(): create page elements (flow: all, lazy · page/book: current + preload next) + └──▶ layout(): compute a box {x, y, w, h} per element from the viewport, zoom and page aspect + └─▶ place(): apply the boxes; rotated images are sized swapped and turned with CSS +``` + +`layout()` repeats up to three times until the viewport size is stable, because scrollbars that appear or disappear change the viewport. + +**Layout rules** (z = page zoom of the current mode): + +| Mode | Box sizes | +|------|-----------| +| Flow (paged content) | `W = min(vw, vh · refAspect) · z`: a normal single page fits the screen at 100 %. Wide pages keep the single page height, capped at `max(vw, W)`. 8 px gap | +| Flow (webtoon, zoom key `strip`) | `W = min(vw, median image width) · z`, no gaps, so slices join seamlessly | +| Page | The page fits the viewport (`contain`) · z, centred. The canvas grows beyond the viewport when zoomed in | +| Book | The pages of the spread share one height `H = min(vh, vw / Σaspect) · z`. Order is right-to-left for `rtl` | + +**Anchoring.** Before any re-layout (zoom, resize, pinch), `anchor(x, y)` records which page is under that point and where inside it, as a fraction. `restore()` scrolls so the same point is under the finger or cursor again. That is why zooming feels pinned to the cursor and a resize keeps the reading position. + +**Book pairing:** +- Covers and wide pages stand alone. +- The single pages between them form *runs*, and each run pairs from its start. +- "⇄ Shift" toggles a run to start with a lone page. It is stored as the run's first page index in `progress.shift`. +- One broken spot can therefore never shift the pairing of the whole chapter. + +**Mode resolution:** + +``` +intended = startMode == "last" ? lastMode : startMode +compatible = direction == "ttb" ? [flow] : [flow, page, book] +mode = adapt && intended ∉ compatible ? compatible[0] : intended +``` + +`lastMode` is written only when the user clicks a mode, never on an automatic switch. + +**Input:** + +| Input | Action | +|-------|--------| +| Click or tap outside the pages | Toggle the bars (the end card uses the whole screen as zones instead) | +| Click or tap on the page(s), outer 35 % of the visible page area (paged) | Previous/next, following the reading direction (`rtl`: left = next) | +| Click or tap on a page, top/bottom 35 % of the screen (flow) | Scroll to the previous/next page top, at most 90 % of a screen (85 % steps for webtoons). At the very bottom → next chapter | +| Click or tap on a page, middle band | Toggle the bars | +| Horizontal swipe ≥ 50 px within 800 ms (paged, not zoomed in) | Previous/next | +| Ctrl + wheel, touchpad pinch, Safari gesture events, two-finger pinch | Page zoom 20–600 %, snapping to 100 % within ±3 %, anchored at the pointer or finger midpoint. Browser zoom is suppressed only over the pages | +| Mouse drag (moved ≥ 6 px) | Pan; the following click is swallowed. Hand cursor when the content is larger than the screen | +| Keys | Arrows follow the direction; Space/PageDown/PageUp (paged); `+` `-` `0` zoom; `m`/Esc bars; `f` fullscreen | + +**Bars:** +- Shown on open. +- Hidden on the first page turn or after 100 px of scrolling. The first automatic hide (per device, once for Flow and once for Page/Book) shows a hint on how to get them back. +- **Never shown by scrolling**, only by a tap beside the pages or in the middle of a page (or `m`). Showing them stops a running smooth scroll, so it can't hide them again right away. +- The top bar has the chapter picker (every chapter, grouped by book, ✓ = read). + +**Loading:** +- Flow mode loads images through an `IntersectionObserver` on the scroll container, 1.5 screens ahead. +- Page and Book mode keep a small pool (12) of `Image` objects: + - Once the pages on screen have loaded, the next 3 pages (Book: next 2 spreads) and the previous one are fetched at low priority and **decoded** (`img.decode()`). + - A page turn then inserts those same, already decoded elements (`decoding="sync"`), so the old and new page swap in one frame. A fresh `` would be painted empty while it decodes, which is what caused the flicker on the first view of each page. +- A page that is still loading shows a spinner (only after 150 ms, so fast loads never flash it). A page that fails shows a message. +- A page with unknown size is laid out at 1000×1414 and re-laid out when it loads. + +**Progress:** +- Saved 700 ms after the position settles, and on `pagehide` (a `fetch` with `keepalive`). +- Stores the page index in the full page list, so skipping extras doesn't corrupt it. +- On open, the saved page is used for the same version, and mapped proportionally for another version. A finished chapter reopens at the start. + +**Zoom persistence:** per mode key (`flow`, `page`, `book`, `strip`), saved 800 ms after the last change. + +## 8. Deployment + +```bash +docker compose up -d --build +``` + +- **Image:** `node:24-alpine` plus the app files. It runs as user `node` (uid 1000) and has a health check against `/api/auth`. +- **Volumes:** + - `/library`: read-only is enough. Files must be readable by uid 1000. + - `/data`: must be writable. +- **Configuration:** only environment variables (`PORT`, `LIBRARY`, `DATA`, `SIGNUP`, `SCAN_INTERVAL`, `COOKIE_SECURE`). +- **Without Docker:** `node server.js` with Node ≥ 18. +- **Behind a reverse proxy:** terminate TLS, set `COOKIE_SECURE=1`. A sub-path works. +- **Upgrades:** replace the code and restart. There are no migrations: unknown fields in `db.json` are kept, and missing settings fall back to defaults. + +## 9. Capacity and limits + +| Aspect | Behaviour | +|--------|-----------| +| Library size | The index keeps a small object per page (file name, size, tags), in the order of a few hundred bytes. Hundreds of thousands of pages fit in a modest container. Unchanged chapters cost one `stat` + one small read per scan | +| First scan | Reads ≤ 256 KB per image once. Later scans use the cache | +| User data | `db.json` is rewritten as a whole, at most about twice per second while people read. Comfortable up to a few MB (a household or small group, thousands of chapters read) | +| Concurrency | One process, one writer. No clustering or horizontal scaling by design | +| Images | Served as stored. No resizing or thumbnails, so large covers make the library page heavy | + +## 10. Design decisions + +| Decision | Alternatives | Why | +|----------|--------------|-----| +| Index the folder tree in place | Import into a DB / managed storage | Stays readable by any viewer and writable by any tool. Nothing to keep in sync | +| No dependencies, no framework, no build | Express, React, bundler | Minimal code, maximum longevity, runs anywhere Node runs | +| JSON file for user data | SQLite (`node:sqlite` is still experimental in Node 22/24), Postgres | Zero dependencies, trivial backup, enough for the target size. Easy to swap: all access goes through the `db` object | +| Page roles as file-name tags | Page lists in JSON | Visible in any file browser, survives copying, no second source of truth. Unknown tags are harmless | +| Image sizes read on the server | Measure in the browser after load | Layout before load: exact scroll restore, zoom anchoring, auto-detected double pages, no layout jumps | +| Absolute layout engine in the reader | CSS flow/flex layout | Deterministic geometry, needed for pointer-anchored zoom and exact position restore | +| Chapter id = book number + chapter number | Random ids stored in JSON | Human-readable and needs no writes into the library. Downside: moving a chapter between books changes its identity | +| Global votes, per-visit version choice | Per-user preferred version | "The better version is shown to everyone", while any user can still pick another one explicitly | +| Bars never reappear on scroll | Show on scroll-up (common pattern) | Explicit requirement: readers scroll up and down constantly | + +## 11. Known limitations and extension points + +- **Identity is name-based.** An optional stable id in `manga.json` (with a migration of the user data keys) would make manga renames safe. +- **No thumbnails.** Would need an image library (e.g. `sharp`) or pre-generated `cover` files. +- **Notifications:** in-app plus webhook only. Web Push would need VAPID keys and a service worker. +- **No login rate limiting and no CSP header.** Both are small additions in `server.js`. +- **In-place image overwrites** are only detected by a full rescan. +- **No automated test suite.** Verification so far was done with the demo library (`tools/make-dummy.js`), API smoke scripts and manual browser testing on desktop and mobile viewports. `parseKey` and `sizeFromBuffer` are pure functions, and `scanLibrary` only needs a folder. Together with the book pairing they are the natural first targets for unit tests. +- **Adding content by API** (instead of files) would be a new route plus the staging and renaming rules from [import.md §7](import.md#7-consistency-writing-into-a-live-library). diff --git a/docs/import.md b/docs/import.md new file mode 100644 index 0000000..90dffca --- /dev/null +++ b/docs/import.md @@ -0,0 +1,261 @@ +# How the import works + +MangaParty 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 · every SCAN_INTERVAL min │ + │ admin "Rescan" · admin "Full rescan"│ + └────────────────┬───────────────────┘ + ▼ + library/ ──read──▶ scanLibrary(root, oldCache) 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 (written atomically) +``` + +## 1. Triggers + +| Trigger | When | Cache | +|---------|------|-------| +| Start-up | Right after the HTTP server starts listening. Requests are served meanwhile; the library page shows "Scanning…" until the first scan is done | used | +| Interval | Every `SCAN_INTERVAL` minutes (default 10, `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 | + +Scans are **single-flight**. A trigger that fires while a scan is running joins that scan instead of starting a second one. + +## 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` | first valid of chapter.json → book.json → manga.json → `rtl`. Aliases: `vertical`, `webtoon` → `ttb` | + +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` | `/`, e.g. `1/2.5`, `x1/x1` | +| `label` | `Ch. ` or `Extra ` | +| `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 256 KB** and parses the header: + +| 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 256 KB, the whole file is read once | +| AVIF / HEIF | first `ispe` box | + +- 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. +- 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: MangaParty`, 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 + 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/` | 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 [ 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 ///)` | 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 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) | diff --git a/docs/library-format.md b/docs/library-format.md new file mode 100644 index 0000000..14495ec --- /dev/null +++ b/docs/library-format.md @@ -0,0 +1,248 @@ +# The file system library + +MangaParty has no import database for content. The **library folder is the source of truth**. The app reads it in place (read-only access is enough), and anything that can write files can add manga: a scraper, a sync tool, or a person with a file browser. + +This document is the format specification. [import.md](import.md) describes how the app reads it, and [scraper-guide.md](scraper-guide.md) is a rule book for tools that write it. + +## Goals of the format + +| Goal | How the format meets it | +|------|-------------------------| +| Readable without the app | Plain image files in plain folders. Zero-padded numbers make alphabetical order equal reading order in every file browser and picture viewer | +| Fast, simple indexing | Everything the app needs is in folder names, file names and a few small JSON files. Image contents are never decoded. Only the first bytes of each image are read once to learn its size | +| Self-describing | Page roles (cover, double page, extra, rotated) are visible in the file name, not hidden in a database | +| Several sources | One manga can list several upstream sources. One chapter can exist in several versions side by side | +| Room for irregular releases | In-between chapters (`10.5`), extra chapters and extra books outside the numbering | + +## Layout + +Always three folder levels, **Manga / Book / Chapter**, with the pages inside the chapter folder: + +``` +/ + / manga folder; its name is the manga's id + manga.json optional + cover. optional + / e.g. "v01 Spring" + book.json optional + cover. optional + / e.g. "c0001 The Seed" (one folder per chapter version) + chapter.json recommended + 0001_cover. + 0002. + ... +``` + +Series without volumes (most webtoons, ongoing magazine series) still need the book level. Use a single `v01`, or seasons (`s01 Season 1`). + +Anything else is ignored: +- files at the library root, +- files other than `manga.json` / `cover.*` in a manga folder, +- files other than `book.json` / `cover.*` in a book folder, +- non-image files and sub-folders inside a chapter folder, +- every file or folder whose name starts with `.`. + +## Manga folders + +- Any name that does not start with `.`. The folder name is the manga's **id**: URLs, reading progress, follows, votes and the "already seen chapters" list all refer to it. **Renaming the folder turns it into a new manga for the app.** +- The displayed title comes from `manga.json` → `title`, and falls back to the folder name. +- Manga without a single readable chapter are left out (and logged). + +## Book and chapter folder names + +``` +[ ][][ [<source>]] +``` + +| Part | Rule | Examples | +|------|------|----------| +| letters | Only letters (any case), optionally followed by **one** space. Free choice and just a hint for humans, **except `x`, which means "extra"** | `v`, `c`, `s`, `vol`, `Ch `, `Chapter `, `x` | +| number | Digits, optionally one decimal part. It decides the order and the identity. Zero-pad it so file browsers sort it right (4 digits for chapters, 2 for books) | `0001`, `12`, `0010.5` | +| separator | The number must be followed by the end of the name, a space, `_`, `-`, `[` or `(`. **Use a space**: `_` sorts after `.`, which breaks the order of decimal chapters in file browsers | ` `, ` - ` | +| title | Anything. Used as the title when the JSON file has none. Leading `-`, `:` or `.` are trimmed | `The Seed` | +| `[source]` | Optional, at the very end. Names the version (scan group, site) when `chapter.json` has no `source` | `[MangaDex]` | + +Valid: `v01`, `v01 Spring`, `c0001 The Seed`, `c0002 Roots [MangaDex]`, `c0002.5 Tea Time`, `x01 Character Sheets`, `Vol 3`, `Chapter 12 - Title`, `c0010.5[Official]`. + +Ignored, with a warning in the log: +- `c0012a`: a letter right after the number. +- `Ch.12`: a dot is not a letter. +- `Specials`: no number. +- `- c0001`: the name must start with the key. + +### The key + +The number is normalised: leading zeros and trailing decimal zeros are dropped (`0010.50` → `10.5`), and an `x` prefix is kept. The result is the **key**: + +| Folder | Key | +|--------|-----| +| `v01 Spring` | `1` | +| `c0002.5 Tea Time` | `2.5` | +| `x01 Artbook` | `x1` | + +- **Book keys must be unique within a manga.** A second folder with the same book number is ignored and logged. +- **Chapter id = `<book key>/<chapter key>`**, for example `1/2.5` or `x1/x1`. Moving a chapter to another book, or renumbering it, therefore makes it a different chapter: reading progress and votes stay behind, and followers are told about a "new" chapter. +- Because the key is a number, `c0010.1` and `c0010.10` are the **same** key (`10.1`). Avoid trailing zeros in decimals. + +### Order + +Inside a manga, books sort by (regular before extra, number). Inside a book, chapters sort the same way. The reading order of the whole manga is books in order, each with its chapters in order: + +``` +v01: c0001, c0002, c0002.5, x01, x02 <- extras of book 1 come after its regular chapters +v02: c0003, c0004 +x01: x01 <- extra books come after all regular books +``` + +### Extras + +Something "extra" can be skipped while reading (user setting *Skip extra pages and extra chapters*) and gets an "extra" badge in the chapter list: + +- A chapter whose letters are exactly `x` (`x01 Character Sheets`) is extra. It sits outside the continuous numbering and is listed after the regular chapters of its book. +- Every chapter in an extra book (`x01 Specials`) is extra. +- `"extra": true` in `chapter.json` or `book.json` marks something as extra **without moving it**. Use it for an in-order omake such as `c0010.5`. + +## Chapter versions + +Several chapter folders **in the same book** with the **same chapter key** are versions of one chapter, for example two scan groups or an official release plus a fan translation: + +``` +c0002 Roots [MangaDex]/ +c0002 Roots [Official]/ +``` + +- The **version id is the folder name**. Votes and reading progress refer to it, so don't rename version folders. +- Users vote versions up or down. The chapter opens in the version with the highest total. Ties go to the higher `"priority"` in `chapter.json` (default `0`), then to the first folder name in sort order. +- Users can still open any other version from the chapter list or the reader's version picker. +- Each version has its own `chapter.json`, page count and page tags. When versions have different titles, the chapter title comes from the first version (in folder order) that has one. +- Versions without any image are ignored. + +## Pages + +A chapter folder contains its pages as plain images: `jpg`, `jpeg`, `png`, `webp`, `gif`, `avif` or `bmp` (extension case doesn't matter). + +``` +<number>[_<tag>]...<.ext> +``` + +- **Order** is the natural sort order of the file names. Zero-pad the number (`0001` … `9999`) so every picture viewer agrees. For more than 9999 pages, use 5 digits throughout. +- **Tags** follow the number, each introduced by `_`, and are case-insensitive: `0012_double.jpg`, `0030_extra.png`, `0007_double_rot90.jpg`. + +| Tag | Meaning | Effect in the reader | +|-----|---------|----------------------| +| `cover` | Chapter cover / title page. It is also **the chapter's image**: the fallback cover for the book or manga when those have none | Stands alone in Book mode. Can be skipped (*Skip chapter cover pages*) | +| `double` | One image showing two facing pages (a spread) | Stands alone in Book mode. In Flow mode it has the height of a single page, so it is twice as wide, but never wider than the screen | +| `extra` | Extra content: translator notes, credits, recruitment pages, ads | Can be skipped (*Skip extra pages…*) | +| `rot90`, `rot180`, `rot270` | The image is stored turned. The viewer turns it **clockwise** by this angle | Width and height swap for `rot90` / `rot270` | + +- A page whose (rotated) image is **wider than tall** counts as a double page even without the tag. Tag it anyway; it documents the intent. +- Unknown tags are ignored, so tools may add their own (`0005_color.jpg`) without breaking anything. +- A file name without a leading number (`page-a.jpg`) is still a page. It is just sorted by name, which you rarely want. + +### Covers + +| Level | Cover | Fallback | +|-------|-------|----------| +| Manga (library tile, manga page) | `cover.<ext>` in the manga folder | cover of the first book | +| Book | `cover.<ext>` in the book folder | first `cover`-tagged page (else the first page) of the first chapter's first version | +| Chapter | first page tagged `cover` | first page | + +Covers are served at their original size. A cover around 600×900 px keeps the library page light. + +## Metadata files + +UTF-8 JSON (a BOM is tolerated). All fields are optional, and unknown fields are kept and ignored. A broken file is logged and treated as empty, so the folder still works with its name-based defaults. + +### `manga.json` + +| Field | Type | Meaning | +|-------|------|---------| +| `title` | string | Display title (default: folder name) | +| `altTitles` | string[] | Other titles | +| `authors` | string[] | Authors / artists | +| `status` | string | Free text, e.g. `ongoing`, `completed`, `hiatus` | +| `year` | number | First release year | +| `tags` | string[] | Genres / tags | +| `description` | string | Synopsis (line breaks are kept) | +| `direction` | `"rtl"` \| `"ltr"` \| `"ttb"` | How the pages are read. `rtl` (the default): manga, right to left. `ltr`: comics, printed manhwa. `ttb`: webtoon, one vertical strip cut into slices (aliases `vertical`, `webtoon`) | +| `sources` | `{name, url}[]` | Upstream sources the manga was scraped from. Several are allowed | + +The whole file is sent to logged-in users as part of the manga page. **Don't put secrets in it** (scraper tokens, cookies). + +### `book.json` + +| Field | Type | Meaning | +|-------|------|---------| +| `title` | string | Book title (default: title part of the folder name) | +| `direction` | as above | Overrides the manga's direction for this book | +| `extra` | boolean | Marks all its chapters as extra without renaming the folder | + +### `chapter.json` (one per version folder, recommended) + +| Field | Type | Meaning | +|-------|------|---------| +| `title` | string | Chapter title without "Chapter N" (default: title part of the folder name) | +| `source` | `{name, url}` or string | Where this version came from (default: `[source]` in the folder name) | +| `added` | ISO date/time string | Release (or download) date, used for "recently updated" (default: the folder's modification time) | +| `extra` | boolean | Marks the chapter as extra without moving it | +| `priority` | number | Tie-breaker between versions with equal votes. Higher wins (default `0`) | +| `direction` | as above | Overrides book/manga direction, e.g. a colour webtoon special inside a printed series | + +`direction` is inherited **chapter → book → manga → `rtl`**, and each version can carry its own. + +## Picture-viewer compatibility + +- Alphabetical order of chapter folders and page files equals reading order, as long as numbers are zero-padded and separated from titles by a space. +- In-between chapters sort correctly: `c0010 Title` < `c0010.5 Omake` < `c0011` (a space sorts before `.`). +- Extras (`x…`) sort after the numbered folders. That is also where the app lists them. +- The one thing a plain viewer can't do is apply `rot…` tags. + +## Examples + +**Manga with everything** + +``` +Moon Garden/ + manga.json cover.png + v01 Spring/ + book.json cover.png + c0001 The Seed/ chapter.json 0001_cover.png 0002.png … 0006_double.png … 0012.png + c0002 Roots [MangaDex]/ chapter.json 0001_cover.png … 0014.png + c0002 Roots [Official]/ chapter.json 0001_cover.png … 0011.png <- second version of ch. 2 + c0002.5 Tea Time/ chapter.json 0001.png … 0004.png <- in-between chapter + x01 Character Sheets/ chapter.json 0001_extra.png … <- extra chapter + v02 Summer/ + c0003 The Tide/ … 0009_rot90.png … 0014_extra.png 0015_extra.png + x01 Specials/ + x01 Artbook/ … <- extra book +``` + +**Webtoon** (`manga.json`: `"direction": "ttb"`) + +``` +Skyline Tower/ + v01 Season 1/ + c0001 Episode 1/ 0001.png … 0008.png <- slices of one strip, same width, no gaps +``` + +**Bare minimum** (no JSON at all: titles and ids come from the folder names) + +``` +Blank Slate/ + v01/ + c0001/ 0001.png 0002.png … +``` + +**One-shot**: a manga with one book and one chapter (`Oneshot Title/v01/c0001/…`), or an extra book inside the related series (`x01 Oneshots/x01 Title/`). + +## Renaming: what survives + +| Change | Effect | +|--------|--------| +| Title part of a **book** folder (`v01 Spring` → `v01 Early Spring`) | Nothing lost | +| Title part of a **chapter** folder (`c0003 Tide` → `c0003 The Tide`) | Same chapter (the key is unchanged), so progress and notifications are fine. The full folder name is the version id, though, so votes for that version are lost, and saved progress is re-mapped proportionally | +| `manga.json` / `chapter.json` content | Picked up on the next scan. Nothing lost | +| Manga folder name | New manga: progress, follows, votes and seen-chapters are gone | +| Book number, chapter number, or moving a chapter to another book | New chapter id: progress/votes stay with the old id, and followers are notified again | +| Version folder name | Votes for that version are lost. Progress falls back to the best version at a proportional page | +| Page files replaced **in place** (same file names) | Cached image sizes may be stale until an admin runs **Full rescan** | diff --git a/docs/scraper-guide.md b/docs/scraper-guide.md new file mode 100644 index 0000000..cf24dd3 --- /dev/null +++ b/docs/scraper-guide.md @@ -0,0 +1,369 @@ +# Scraper guide: supplying the MangaParty library + +**Audience:** an automated agent or program that downloads manga, comics or webtoons from upstream sources and writes them into a MangaParty library folder. + +**Normative words:** MUST, MUST NOT, SHOULD, SHOULD NOT and MAY as in RFC 2119. Rules have ids (`R-…`) so you can cite them in logs and reviews. + +**Background reading** (optional; this guide is self-contained): +- [library-format.md](library-format.md): the format. +- [import.md](import.md): how MangaParty reads it. + +--- + +## 0. The contract in short + +- You write **plain folders and image files**. There is no upload API. +- MangaParty scans the library folder every `SCAN_INTERVAL` minutes (default 10), and on demand (§9). +- The layout is `<library>/<Manga>/<Book>/<Chapter>/<pages>`, always three levels. +- Names carry meaning: + - the manga folder name is the manga's id, + - the book/chapter **numbers** in folder names are ids and order, + - the chapter folder name is the version id, + - page file names carry order and page roles. +- MangaParty notifies followers as soon as a new chapter number becomes visible. So a chapter MUST become visible **complete and in one step**: stage it in a hidden folder, then rename it (§10). +- MangaParty never writes to the library. You own it; MangaParty only reads it. + +## 1. Inputs you need + +| Input | Required | Notes | +|-------|----------|-------| +| Library root path | yes | The folder MangaParty mounts as `LIBRARY` (`/library` in Docker) | +| Upstream manga metadata | yes | Title, reading direction/type, chapter list with numbers | +| Per chapter: ordered page images | yes | Plus, where available: which pages are covers, spreads, credits/notes | +| MangaParty URL + an admin account | no | Only to trigger an immediate rescan (§9) | + +## 2. Target layout (template) + +``` +<library>/ + <Manga folder>/ R-M* + manga.json R-J1 + cover.jpg R-M5 + v01[ <Book title>]/ R-B* + book.json optional + c0001[ <Chapter title>] [<Source>]/ R-C*, R-V* + chapter.json R-J2 + 0001_cover.jpg R-P* + 0002.jpg + … +``` + +## 3. Manga folders + +- **R-M1** The manga folder name MUST NOT start with `.` and MUST be a valid name on Windows, macOS and Linux: + - no `< > : " / \ | ? *` and no control characters, + - no trailing space or dot, + - not a reserved Windows name (`CON`, `PRN`, `AUX`, `NUL`, `COM1`–`COM9`, `LPT1`–`LPT9`), + - Unicode normalised to NFC, + - SHOULD be ≤ 120 characters. +- **R-M2** Derive the name from the title once (e.g. the English or romanised title, sanitised per R-M1). After that you MUST NOT rename it: the folder name is the manga's id, and renaming loses every user's progress, follows and votes. Put title changes into `manga.json` → `title`. +- **R-M3** Before creating a manga, you MUST look for an existing folder for the same work, so one work never ends up in two folders. Match on `manga.json` → `sources[].url` (or your own id field, R-J1). Only if nothing matches, create a new folder. +- **R-M4** A new manga MUST be published as a whole: stage it as `<library>/.staging-<random>/` with at least one complete chapter, then rename it to its final name (§10). +- **R-M5** Put the series cover in the manga folder as `cover.<ext>` (`jpg`, `jpeg`, `png`, `webp`, `gif`, `avif`, `bmp`). It SHOULD be at most about 1000 px tall, because it is shown in the library grid at original size. If you have no cover, omit it: the first chapter's cover page is used instead. +- **R-M6** You MUST NOT put page images directly into the manga folder. Only `manga.json`, `cover.*` and book folders belong there. + +## 4. Books (middle level) + +- **R-B1** Book folder name pattern: + - `v<NN>[ <title>]` for volumes, or `s<NN>[ <title>]` for seasons, + - `x<NN>[ <title>]` for extra books (specials, art books, one-shot collections) that sit outside the numbering. + - Example: `v01 Spring`, `s02 Season 2`, `x01 Specials`. +- **R-B2** Pad numbers to 2 digits, or to 3 digits for series that can exceed 99 volumes, and keep the width the same within a manga. Re-padding a book folder later (`v01` → `v001`) is safe: the number, not the text, is the id. +- **R-B3** Book numbers MUST be unique within a manga. MangaParty ignores a second folder with the same number. +- **R-B4** A chapter's id includes its book number (`<book>/<chapter>`). Moving a chapter to another book later creates a "new" chapter: progress and votes stay behind, and followers get notified again. Therefore you MUST choose a book strategy per manga that never needs to move chapters: + - **Flat (default):** every chapter goes into `v01` (for example `v01 Chapters`). Use it for ongoing series and for sources without reliable volume data. + - **Volumes:** chapters go into their volume, **only** if the source gives a final volume number at the time you first write the chapter, and for every chapter. + - **Seasons (webtoons):** only if the source states the season. + Record the strategy for yourself (e.g. in your own field in `manga.json`, R-J1) so later runs follow it. +- **R-B5** The book folder MUST contain only `book.json`, `cover.*` and chapter folders. + +## 5. Chapters + +- **R-C1** Chapter folder name: + ``` + c<NNNN>[.<d>][ <title>][ [<Source>]] regular chapter + x<NN>[ <title>][ [<Source>]] extra chapter outside the numbering + ``` + - A **space** MUST follow the number when anything follows. `c0012_Title` sorts wrongly in file browsers; `c0012a` and `c0012Title` are not recognised at all. + - Recommended check: `^(c\d{4}(\.\d*[1-9])?|x\d{2})( [^\[\]]+?)?( \[[^\[\]]+\])?$` +- **R-C2** Pad the chapter number's integer part to 4 digits. Never change the padding of an existing manga: chapter folder names are version ids. +- **R-C3** Map upstream numbering like this: + +| Upstream | Folder key | Notes | +|----------|------------|-------| +| `12` | `c0012` | | +| `12.5` | `c0012.5` | In-between chapter, stays in reading order | +| `12.50` | `c0012.5` | MUST drop trailing decimal zeros. `12.10` would collide with `12.1` | +| `12a`, `12b` | `c0012.1`, `c0012.2` | Keep "12a" in `chapter.json` → `title` if it matters. Check for collisions with real `12.1` | +| `12 part 1/2` | `c0012.1`, `c0012.2` | As above | +| `0`, `Prologue` | `c0000` | | +| `Omake`, `Extra`, `Side story` placed after chapter 12 | `c0012.5` + `"extra": true` in chapter.json | In order, but skippable | +| `Special`, `Bonus`, `Character profiles`, not tied to a position | `x01`, `x02`, … in the book they belong to | Listed after the book's regular chapters, skippable | +| Volume extras (afterword, sketches) | `x<NN>` in that volume's book | | +| One-shot | its own manga (`v01/c0001`), or `x<NN>` inside an `x01 Oneshots` book of the related series | | +| Negative or non-numeric only | `x<NN>` | Never invent a number that collides with a real one | + +- **R-C4** The title part of the folder name is for humans only. + - The authoritative title is `chapter.json` → `title`, without the "Chapter 12:" prefix. + - Remove `[` and `]` from titles in folder names (a trailing `[…]` is read as the source). +- **R-C5** Chapter keys MUST be unique within a book, **except** for deliberate versions (§6). +- **R-C6** Use the upstream's canonical chapter numbering (preferably the official one). If two sources number the same content differently, map both to one numbering; otherwise they won't be recognised as versions of each other. + +## 6. Versions + +- **R-V1** Each source's copy of a chapter is its own **version folder** in the same book, with the same chapter number and a source suffix: + ``` + c0012 The Storm [MangaDex]/ + c0012 The Storm [Official]/ + ``` + The suffix SHOULD always be present when a manga has, or may get, more than one source. +- **R-V2** The version folder name is the version's id, and user votes attach to it. You MUST NOT rename an existing version folder. To update its content, replace it under the **same name** (R-W4). +- **R-V3** Set `chapter.json` → `source` = `{ "name", "url" }` for every version. +- **R-V4** You MAY set `chapter.json` → `priority` (number, default `0`) to decide which version wins while users haven't voted, e.g. `1` for official releases. Votes always beat priority. +- **R-V5** You MUST NOT delete another source's version to "replace" it. Add your version next to it. + +## 7. Pages + +- **R-P1** Page file name: + ``` + <NNNN>[_<tag>]…<.ext> + ``` + - Numbers are 4 digits, contiguous, starting at `0001`, in reading order. + - Use 5 digits for the whole chapter if it has more than 9999 files. + - Recommended check: `^\d{4,5}(_[a-z0-9]+)*\.(jpg|jpeg|png|webp|gif|avif|bmp)$` +- **R-P2** Allowed formats: JPEG, PNG, WebP, GIF, AVIF, BMP. The extension MUST match the actual format (it decides the `Content-Type`). + - SHOULD keep the upstream bytes as they are; don't re-encode. + - Convert only formats that are not allowed. +- **R-P3** Tags: add only what you know. When unsure, add no tag. + +| Tag | Add when | Don't add when | +|-----|----------|----------------| +| `_cover` | The page is a chapter cover or title splash (usually the first page, often in colour, with the chapter title) | A normal first story page | +| `_double` | One image shows two facing pages (a spread). Usually the width is > 1.2 × the height of normal pages | The source split the spread into two images: keep those as two normal pages, untagged, in order | +| `_extra` | Credits, recruitment or "support us" pages, translator notes, scanlator ads, previews of other works | Story content | +| `_rot90` / `_rot180` / `_rot270` | The source says, or you can detect with certainty, that the stored image is turned. The value is the **clockwise** angle needed to show it upright | You only guess | + +- **R-P4** Webtoons (`direction: "ttb"`): + - Keep the slices as delivered, in order, preferably all the same width. Don't tag spreads. + - SHOULD NOT stitch slices into one huge image; very tall images fail on some devices. + - SHOULD NOT cut slices smaller. +- **R-P5** The chapter folder MUST contain only page images and `chapter.json`. Other files are ignored, and sub-folders are not read. +- **R-P6** You SHOULD keep extra pages (tagged `_extra`) instead of deleting them; readers can skip them. You MAY drop pure advertising. + +## 8. Metadata files + +All are UTF-8 JSON **without** BOM, pretty-printing optional. + +- **R-J0** Every JSON file MUST be valid JSON. A broken file is ignored with a warning; the folder then falls back to name-based defaults. +- **R-J1** `manga.json` + + ```json + { + "title": "Moon Garden", + "altTitles": ["Tsuki no Niwa"], + "authors": ["Aki Tanaka"], + "status": "ongoing", + "year": 2021, + "tags": ["Fantasy", "Slice of Life"], + "description": "Synopsis, line breaks allowed.", + "direction": "rtl", + "sources": [ + { "name": "MangaDex", "url": "https://mangadex.org/title/…" } + ], + "x-scraper": { "strategy": "flat", "ids": { "mangadex": "…" } } + } + ``` + + - `direction`: + + | Upstream type | Value | + |---------------|-------| + | Japanese manga | `rtl` | + | Western comic, printed manhwa or manhua | `ltr` | + | Webtoon / vertical scroll (also most digital manhwa and manhua) | `ttb` | + + - `sources`: **merge**. Add your entry if its `url` is missing, update your own entry, and never remove others. + - Unknown fields are kept and ignored by the app. Keep your own state in one namespaced field such as `"x-scraper"`. + - The whole file is shown to logged-in users. It MUST NOT contain secrets (tokens, cookies, credentials). + - **Read-modify-write:** keep fields you don't own, because people may have edited them. Write atomically (R-W5). + +- **R-J2** `chapter.json`, one per version folder: + + ```json + { + "title": "The Storm", + "source": { "name": "MangaDex", "url": "https://mangadex.org/chapter/…" }, + "added": "2024-03-15T10:00:00Z", + "priority": 0, + "extra": false + } + ``` + + | Field | Meaning | + |-------|---------| + | `added` | Upstream release date, else the download date (ISO 8601). It drives "recently updated" | + | `extra` | Only for in-order omake (see R-C3) | + | `direction` | Only if this chapter differs from the manga (e.g. a vertical colour special) | + +- **R-J3** `book.json`: `{ "title": "Spring" }`. Optional; only write it if the source has volume titles. + +## 9. Triggering the import (optional) + +New content shows up by itself at the next scheduled scan. For immediate pickup, trigger **one** rescan after a batch, not per chapter: + +```bash +curl -s -c jar.txt -H 'Content-Type: application/json' -d '{"name":"<admin>","password":"<password>"}' "$MANGAPARTY/api/login" +curl -s -b jar.txt -H 'Content-Type: application/json' -d '{}' "$MANGAPARTY/api/admin/rescan" +``` + +- The response contains `scan.error` (null on success) and counts. +- Use a dedicated admin account for the scraper, and keep its password out of the library. + +## 10. Writing safely + +MangaParty can scan at any moment. Everything it can see must always be complete. + +- **R-W1** All staging paths MUST start with `.` and MUST be on the **same file system** as their final location; only then is `rename` atomic. Put staging folders next to the target: + + | Target | Staging location | + |--------|------------------| + | new chapter or version | `<book>/.staging-<random>/` | + | new book | the book folder itself may be created early (an empty book is ignored), then stage chapters inside it | + | new manga | `<library>/.staging-<random>/` | + +- **R-W2** Publish = one `rename` of the finished staging folder to its final name. Write every image and `chapter.json` before that rename. +- **R-W3** You MUST NOT overwrite image files in place. MangaParty caches image sizes per folder mtime, and an in-place overwrite goes unnoticed. Replace whole version folders instead (R-W4). +- **R-W4** Replacing an existing version (same folder name): + 1. stage the new copy, + 2. `rename(final → .old-<random>)`, + 3. `rename(staging → final)`, + 4. delete `.old-<random>`. + The chapter is briefly absent, which is harmless: it won't be re-announced, and progress is kept. +- **R-W5** Updating a JSON file: write `.<name>.tmp` in the same folder, then `rename` it over the target. +- **R-W6** At start-up, delete your own leftover `.staging-*` / `.old-*` folders older than a few hours (from crashed runs). +- **R-W7** On Windows, `rename` can fail with `EPERM`/`EBUSY` while another process reads the folder. Retry a few times with a short delay. +- **R-W8** Files MUST be readable by the MangaParty process. In Docker that is uid 1000, so use mode `0644` for files and `0755` for folders. +- **R-W9** You MUST NOT write anything into MangaParty's `DATA` folder (`db.json`, `scan-cache.json`). + +### 10.1 Reference implementation (Node.js, no dependencies) + +```js +const fs = require('fs'), path = require('path'), crypto = require('crypto'); +const rnd = () => crypto.randomBytes(4).toString('hex'); + +function renameRetry(from, to, tries = 5) { + for (let i = 0; ; i++) { + try { return fs.renameSync(from, to); } + catch (e) { + if (i >= tries || !['EPERM', 'EBUSY', 'EACCES'].includes(e.code)) throw e; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200 * (i + 1)); // sleep + } + } +} + +// pages: [{ name: '0001_cover.jpg', data: Buffer }, …] in reading order +function publishChapter(bookDir, folderName, pages, chapterJson) { + fs.mkdirSync(bookDir, { recursive: true }); + const stage = path.join(bookDir, '.staging-' + rnd()); + fs.mkdirSync(stage); + for (const p of pages) fs.writeFileSync(path.join(stage, p.name), p.data); + fs.writeFileSync(path.join(stage, 'chapter.json'), JSON.stringify(chapterJson, null, 2) + '\n'); + const target = path.join(bookDir, folderName); + let old = null; + if (fs.existsSync(target)) { old = path.join(bookDir, '.old-' + rnd()); renameRetry(target, old); } + renameRetry(stage, target); + if (old) fs.rmSync(old, { recursive: true, force: true }); +} + +function writeJsonAtomic(file, obj) { + const tmp = path.join(path.dirname(file), '.' + path.basename(file) + '.tmp'); + fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n'); + renameRetry(tmp, file); +} +``` + +## 11. Procedure per run + +``` +for each manga to scrape: + folder = find existing manga folder whose manga.json.sources[].url (or x-scraper id) matches # R-M3 + if none: folder = sanitize(title) # R-M1/2 + make sure it does not exist yet; if it does and belongs to another work, add a suffix + strategy = folder's x-scraper.strategy or choose one (flat unless final volumes are known) # R-B4 + upstream_chapters = fetch chapter list + + for each upstream chapter: + book = strategy == flat ? "v01" : "v%02d" % volume # R-B1/2 + key = map_number(upstream number) # R-C3 + name = key + (" " + sanitize_title(title) if title) + " [" + source_name + "]" # R-C1/C4/V1 + if <book>/<name> exists and is complete and unchanged upstream: skip # idempotent + pages = download in reading order; tag cover/double/extra/rot only when certain # R-P* + validate(pages, name) # §12 + publishChapter(<manga>/<book>, name, pages, chapter.json) # §10 + + merge + write manga.json atomically, update cover.* if missing # R-J1, R-M5 + (new manga: do all of the above inside <library>/.staging-<rnd>/, then rename once) # R-M4 + +after the batch: optionally trigger one rescan # §9 +``` + +## 12. Validation checklist (before every publish) + +- [ ] Manga folder name passes R-M1 and is the existing folder for this work (R-M3). +- [ ] Book folder matches `^(v|s|x)\d{2,3}( [^\[\]]+)?$`, and its number is unique in the manga. +- [ ] Chapter folder matches R-C1, has no trailing decimal zeros, and its number follows R-C3. +- [ ] The same chapter number exists in this book only as deliberate versions with distinct `[Source]` suffixes. +- [ ] ≥ 1 page. Names `0001…N` are contiguous and match R-P1. Extensions match the real formats. +- [ ] Tags come only from `cover`, `double`, `extra`, `rot90`, `rot180`, `rot270` (plus your own on purpose). +- [ ] At most one or two `_cover` pages, and they are at the start. +- [ ] `chapter.json` parses, and `title` has no "Chapter N" prefix. +- [ ] `source` is `{name, url}`, and `added` is ISO 8601. +- [ ] No other files in the chapter folder. +- [ ] Staging folder starts with `.` and lives next to the target. +- [ ] Publishing is a single rename. +- [ ] No secrets in `manga.json`. + +## 13. What MangaParty does with your output + +| You do | MangaParty does | +|--------|-----------------| +| Publish a manga folder for the first time | Adds it to the library. **No** notifications (first sight) | +| Publish a new chapter number in a known manga | Lists it, and notifies followers in the app and by webhook within one scan | +| Publish another version of an existing chapter | Adds it to the version picker. No notification. The best-voted (then highest `priority`) version is the default | +| Make a chapter visible before it is complete | Indexes it as it is, notifies followers, and readers see missing pages | +| Rename a manga folder | Treats it as a new manga. User data stays with the old name | +| Move or renumber a chapter | Treats it as a new chapter and notifies again. Old progress and votes are orphaned | +| Rename a version folder | Votes for it are lost | +| Remove a chapter | It disappears from the list. It is not re-announced if it comes back | +| Write invalid names or JSON | Skips the folder or ignores the JSON, and logs a `[scan]` warning in MangaParty's output | + +## 14. Worked example + +Upstream (simplified): + +```json +{ "title": "Moon Garden", "type": "manga", "url": "https://src.example/t/42", + "chapters": [ + { "num": "1", "title": "Chapter 1: The Seed", "vol": 1, "date": "2024-01-10", "pages": ["a.jpg", "b.jpg", "c.jpg"] }, + { "num": "2", "title": "Roots", "vol": 1, "date": "2024-01-24", "pages": ["…"] }, + { "num": "2.50", "title": "Omake: Tea Time", "vol": 1, "date": "2024-02-01", "pages": ["…"] }, + { "num": "Special", "title": "Character Sheets", "vol": 1, "date": "2024-02-02", "pages": ["…"] } + ] } +``` + +The volume numbers are final, so the **volumes** strategy applies. Result: + +``` +Moon Garden/ + manga.json {"title":"Moon Garden","direction":"rtl","sources":[{"name":"SrcExample","url":"https://src.example/t/42"}],"x-scraper":{"strategy":"volumes"}} + cover.jpg + v01/ + c0001 The Seed [SrcExample]/ chapter.json {"title":"The Seed", …} 0001_cover.jpg 0002.jpg 0003.jpg + c0002 Roots [SrcExample]/ chapter.json {"title":"Roots", …} 0001.jpg … + c0002.5 Tea Time [SrcExample]/ chapter.json {"title":"Omake: Tea Time","extra":true, …} + x01 Character Sheets [SrcExample]/ chapter.json {"title":"Character Sheets", …} 0001_extra.jpg … +``` + +- `a.jpg` became `0001_cover.jpg` only because the upstream marks it as a cover, or it was verified to be one. +- `2.50` became `c0002.5` (R-C3). +- "Special" without a position became `x01` in its volume (R-C3). diff --git a/library.js b/library.js new file mode 100644 index 0000000..c96006c --- /dev/null +++ b/library.js @@ -0,0 +1,182 @@ +'use strict'; +/* + * Library scanner: turns <library>/<Manga>/<Book>/<Chapter>/<page images> + * into an in-memory index. The folder format is documented in README.md. + * + * Only folder names, small JSON files and the first bytes of each image (for its size) are read. + * Image sizes are cached per chapter folder and only re-read when the folder's mtime changes. + */ +const fsp = require('fs').promises, path = require('path'); + +const IMG = /\.(jpe?g|png|webp|gif|avif|bmp)$/i; +const COVER = /^cover\.(jpe?g|png|webp|gif|avif|bmp)$/i; +const KEY = /^([a-z]*) ?(\d+(?:\.\d+)?)(?=$|[\s_\-[(])[\s_-]*(.*)$/i; +const DIRECTIONS = { rtl: 'rtl', ltr: 'ltr', ttb: 'ttb', vertical: 'ttb', webtoon: 'ttb' }; +const cmp = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }).compare; +const str = x => (typeof x === 'string' ? x.trim() : ''); +const order = (a, b) => a.x - b.x || a.num - b.num; + +// "c0002.5 Tea Time [MangaDex]" -> { key: '2.5', x: false, num: 2.5, title: 'Tea Time', src: 'MangaDex' } +function parseKey(name) { + const m = KEY.exec(name); + if (!m) return null; + const x = m[1].toLowerCase() === 'x', num = parseFloat(m[2]); + let title = m[3].trim(), src = ''; + const b = /\s*\[([^\]]*)\]$/.exec(title); + if (b) { src = b[1].trim(); title = title.slice(0, b.index).trim(); } + return { key: (x ? 'x' : '') + num, x, num, title: title.replace(/^[-:.]\s*/, ''), src }; +} + +function direction(...metas) { + for (const m of metas) if (DIRECTIONS[str(m.direction).toLowerCase()]) return DIRECTIONS[str(m.direction).toLowerCase()]; + return 'rtl'; +} + +async function readJson(file) { + try { return JSON.parse((await fsp.readFile(file, 'utf8')).replace(/^/, '')); } + catch (e) { if (e.code !== 'ENOENT') console.warn(`[scan] ignoring broken ${file}: ${e.message}`); return {}; } +} + +async function list(dir) { + const dirs = [], files = []; + for (const e of await fsp.readdir(dir, { withFileTypes: true })) { + if (e.name.startsWith('.')) continue; + let isDir = e.isDirectory(); + if (e.isSymbolicLink()) isDir = await fsp.stat(path.join(dir, e.name)).then(s => s.isDirectory(), () => false); + (isDir ? dirs : files).push(e.name); + } + return { dirs: dirs.sort(cmp), files: files.sort(cmp) }; +} + +// Width/height from the image header (PNG, GIF, BMP, WebP, JPEG, AVIF/HEIF). +function sizeFromBuffer(b) { + try { + if (b.readUInt32BE(0) === 0x89504e47) return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) }; + if (b.toString('latin1', 0, 3) === 'GIF') return { w: b.readUInt16LE(6), h: b.readUInt16LE(8) }; + if (b.toString('latin1', 0, 2) === 'BM') return { w: b.readInt32LE(18), h: Math.abs(b.readInt32LE(22)) }; + if (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') { + const f = b.toString('latin1', 12, 16); + if (f === 'VP8 ') return { w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff }; + if (f === 'VP8L') { const v = b.readUInt32LE(21); return { w: (v & 0x3fff) + 1, h: ((v >> 14) & 0x3fff) + 1 }; } + if (f === 'VP8X') return { w: b.readUIntLE(24, 3) + 1, h: b.readUIntLE(27, 3) + 1 }; + } + if (b[0] === 0xff && b[1] === 0xd8) { + for (let i = 2; i + 9 < b.length;) { + if (b[i] !== 0xff) { i++; continue; } + const mk = b[i + 1]; + if (mk === 0xff) { i++; continue; } + if (mk === 0x01 || (mk >= 0xd0 && mk <= 0xd8)) { i += 2; continue; } + if (mk >= 0xc0 && mk <= 0xcf && mk !== 0xc4 && mk !== 0xc8 && mk !== 0xcc) return { w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5) }; + i += 2 + b.readUInt16BE(i + 2); + } + } + const k = b.indexOf('ispe'); + if (k > 0 && b.toString('latin1', 4, 8) === 'ftyp') return { w: b.readUInt32BE(k + 8), h: b.readUInt32BE(k + 12) }; + } catch { /* truncated or unknown header */ } + return null; +} + +async function imageSize(file) { + const fh = await fsp.open(file, 'r'); + let head; + try { + const buf = Buffer.alloc(256 * 1024); + const { bytesRead } = await fh.read(buf, 0, buf.length, 0); + head = buf.subarray(0, bytesRead); + } finally { await fh.close(); } + // JPEGs with big EXIF/ICC blocks can have their size marker further in. + return sizeFromBuffer(head) || (head.length === 256 * 1024 ? sizeFromBuffer(await fsp.readFile(file)) : null); +} + +function pageInfo([f, w, h]) { + const t = f.slice(0, f.lastIndexOf('.')).toLowerCase().split('_').slice(1); + const rot = t.find(x => /^rot(90|180|270)$/.test(x)); + return { f, w, h, t, r: rot ? +rot.slice(3) : 0 }; +} + +async function scanVersion(root, rel, ck, oldCache, cache) { + const dir = path.join(root, rel), st = await fsp.stat(dir); + const meta = await readJson(path.join(dir, 'chapter.json')); + let c = oldCache[rel]; + if (!c || c.m !== st.mtimeMs) { + const p = []; + for (const f of (await list(dir)).files) { + if (!IMG.test(f)) continue; + const s = await imageSize(path.join(dir, f)).catch(() => null); + p.push([f, s ? s.w : 0, s ? s.h : 0]); + } + c = { m: st.mtimeMs, p }; + } + cache[rel] = c; + const src = meta.source && typeof meta.source === 'object' ? meta.source : { name: meta.source }; + return { + id: rel.slice(rel.lastIndexOf('/') + 1), rel, meta, + source: str(src.name) || ck.src, sourceUrl: str(src.url), + added: Date.parse(meta.added) || st.mtimeMs, + pages: c.p.map(pageInfo), + }; +} + +const thumbOf = v => { const p = v.pages.find(p => p.t.includes('cover')) || v.pages[0]; return v.rel + '/' + p.f; }; +const coverIn = (files, rel) => { const f = files.find(f => COVER.test(f)); return f ? rel + '/' + f : null; }; + +async function scanManga(root, name, oldCache, cache) { + const { dirs, files } = await list(path.join(root, name)); + const meta = await readJson(path.join(root, name, 'manga.json')); + const m = { id: name, title: str(meta.title) || name, meta, direction: direction(meta), cover: coverIn(files, name), books: [], chapters: [], chapterById: new Map(), updated: 0 }; + const bookKeys = new Set(); + for (const bName of dirs) { + const bk = parseKey(bName); + if (!bk) { console.warn(`[scan] ignoring "${name}/${bName}": book folders must be named <letters><number>[ title], e.g. "v01 Spring"`); continue; } + if (bookKeys.has(bk.key)) { console.warn(`[scan] ignoring "${name}/${bName}": another book folder already uses number ${bk.key}`); continue; } + bookKeys.add(bk.key); + const bRel = name + '/' + bName, bl = await list(path.join(root, bRel)); + const bMeta = await readJson(path.join(root, bRel, 'book.json')); + const book = { + key: bk.key, num: bk.num, x: bk.x, extra: bk.x || bMeta.extra === true, + label: (bk.x ? 'Extra book ' : 'Book ') + bk.num, title: str(bMeta.title) || bk.title, cover: coverIn(bl.files, bRel), chapters: [], + }; + const byKey = new Map(); + for (const cName of bl.dirs) { + const ck = parseKey(cName); + if (!ck) { console.warn(`[scan] ignoring "${bRel}/${cName}": chapter folders must be named <letters><number>[ title], e.g. "c0001 The Seed"`); continue; } + const v = await scanVersion(root, bRel + '/' + cName, ck, oldCache, cache); + if (!v.pages.length) continue; + v.direction = direction(v.meta, bMeta, meta); + let ch = byKey.get(ck.key); + if (!ch) { + ch = { id: book.key + '/' + ck.key, book: book.key, key: ck.key, num: ck.num, x: ck.x, extra: book.extra || ck.x, label: (ck.x ? 'Extra ' : 'Ch. ') + ck.num, title: '', versions: [] }; + byKey.set(ck.key, ch); + } + if (v.meta.extra === true) ch.extra = true; + ch.title = ch.title || str(v.meta.title) || ck.title; + ch.versions.push(v); + m.updated = Math.max(m.updated, v.added); + } + book.chapters = [...byKey.values()].sort(order); + if (book.chapters.length) m.books.push(book); + } + m.books.sort(order); + for (const b of m.books) { + b.cover = b.cover || thumbOf(b.chapters[0].versions[0]); + for (const ch of b.chapters) { m.chapters.push(ch); m.chapterById.set(ch.id, ch); } + } + m.cover = m.cover || (m.books[0] && m.books[0].cover) || null; + return m; +} + +// Returns { index, cache }. Pass the previous cache to skip re-reading unchanged chapter folders. +async function scanLibrary(root, oldCache = {}) { + const cache = {}, mangas = []; + for (const name of (await list(root)).dirs) { + try { + const m = await scanManga(root, name, oldCache, cache); + if (m.chapters.length) mangas.push(m); + else console.warn(`[scan] "${name}" has no chapters (expected <manga>/<book>/<chapter>/<images>)`); + } catch (e) { console.warn(`[scan] skipped "${name}": ${e.message}`); } + } + mangas.sort((a, b) => cmp(a.title, b.title)); + return { index: { mangas, byId: new Map(mangas.map(m => [m.id, m])) }, cache }; +} + +module.exports = { scanLibrary, parseKey, sizeFromBuffer }; diff --git a/package.json b/package.json new file mode 100644 index 0000000..48bcdc0 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "mangaparty", + "version": "1.0.0", + "private": true, + "description": "No-frills self-hosted manga reader. No dependencies.", + "main": "server.js", + "scripts": { + "start": "node server.js", + "demo": "node tools/make-dummy.js library" + }, + "engines": { + "node": ">=18" + } +} diff --git a/public/app.css b/public/app.css new file mode 100644 index 0000000..07fffdf --- /dev/null +++ b/public/app.css @@ -0,0 +1,175 @@ +:root { + --bg: #121214; --fg: #e9e9ec; --muted: #9b9ba4; --card: #1d1d22; --card2: #26262c; --line: #2d2d34; --accent: #e0566a; + color-scheme: dark; +} +* { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } +body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } +a { color: inherit; } +h1 { font-size: 26px; line-height: 1.2; margin: 0 0 6px; } +[hidden] { display: none !important; } +.muted { color: var(--muted); } +.small { font-size: 13px; } +.error { color: #ff8a8a; } +.grow { flex: 1; min-width: 0; } + +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 36px; padding: 6px 12px; + border-radius: 8px; border: 1px solid var(--line); background: var(--card); color: var(--fg); font: inherit; + text-decoration: none; cursor: pointer; white-space: nowrap; -webkit-tap-highlight-color: transparent; +} +.btn:hover { background: var(--card2); } +.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn.on { border-color: var(--accent); color: #ffb3bd; } +.btn.small { min-height: 28px; padding: 2px 9px; font-size: 13px; } +.btn.icon { padding: 6px 9px; } +.btn[disabled], .btn.disabled { opacity: .4; pointer-events: none; } +input, select, textarea { font: inherit; color: var(--fg); background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; min-height: 36px; max-width: 100%; } +label { display: block; } + +/* navigation */ +.nav { + position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 8px; + padding: 8px 16px; padding-top: max(8px, env(safe-area-inset-top)); background: rgba(18, 18, 20, .94); border-bottom: 1px solid var(--line); +} +.brand { font-weight: 800; text-decoration: none; font-size: 17px; letter-spacing: .2px; } +.bell-wrap { position: relative; } +.bell .count { background: var(--accent); color: #fff; border-radius: 9px; font-size: 12px; font-weight: 700; padding: 0 6px; } +.panel { + position: absolute; right: 0; top: 44px; width: min(360px, 92vw); max-height: 70vh; overflow: auto; z-index: 20; + background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 8px; box-shadow: 0 10px 30px rgba(0, 0, 0, .5); +} +.panel p { margin: 8px; } +.panel-head { display: flex; justify-content: space-between; align-items: center; padding: 4px 6px 8px; } +.note { display: block; padding: 8px; border-radius: 8px; text-decoration: none; } +.note:hover { background: var(--card2); } +.note.unread { border-left: 3px solid var(--accent); } + +main { max-width: 1200px; margin: 0 auto; padding: 16px; } + +/* library */ +.toolbar { display: flex; gap: 8px; margin-bottom: 16px; } +.toolbar input { flex: 1; min-width: 0; } +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 22px 16px; } +.tile { position: relative; display: block; text-decoration: none; } +.tile .cv { aspect-ratio: 2 / 3; border-radius: 8px; overflow: hidden; background: var(--card); box-shadow: 0 3px 14px rgba(0, 0, 0, .45); } +.tile img { display: block; width: 100%; height: 100%; object-fit: cover; transition: transform .2s; } +.tile:hover img { transform: scale(1.03); } +.tile .t { margin-top: 7px; font-weight: 650; line-height: 1.25; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +.tile .s { color: var(--muted); font-size: 13px; } +.prog { height: 3px; background: var(--line); border-radius: 2px; margin-top: 5px; overflow: hidden; } +.prog i { display: block; height: 100%; background: var(--accent); } +.badge { position: absolute; top: 7px; right: 7px; background: var(--accent); color: #fff; font-size: 12px; font-weight: 700; padding: 2px 8px; border-radius: 10px; } +.empty { text-align: center; padding: 60px 10px; } +@media (max-width: 560px) { + main { padding: 12px; } + .grid { grid-template-columns: repeat(2, 1fr); gap: 16px 12px; } + .nav { padding-left: 12px; padding-right: 12px; } +} + +/* manga page */ +.hero { display: flex; gap: 22px; align-items: flex-start; margin-bottom: 22px; } +.hero > img { width: 220px; flex: none; border-radius: 10px; box-shadow: 0 4px 18px rgba(0, 0, 0, .5); background: var(--card); } +.hero .info { min-width: 0; } +.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; } +.chip { background: var(--card2); border-radius: 12px; padding: 1px 9px; font-size: 13px; } +.desc { white-space: pre-line; margin: 10px 0; } +.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +.sources a { margin-right: 10px; } +@media (max-width: 640px) { + .hero { flex-direction: column; align-items: center; text-align: center; } + .hero > img { width: 180px; } + .chips, .actions { justify-content: center; } +} +.chapters { border-top: 1px solid var(--line); } +.book-sep { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; padding: 16px 4px 6px; } +.ch { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--line); } +.ch-main { flex: 1; min-width: 0; display: flex; gap: 10px; align-items: baseline; padding: 12px 4px; text-decoration: none; } +.ch-main:hover .ch-title { text-decoration: underline; } +.ch-label { font-weight: 650; flex: none; } +.ch-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ch.read .ch-main { color: var(--muted); } +.tag { font-size: 11px; border: 1px solid var(--line); border-radius: 6px; padding: 0 5px; color: var(--muted); flex: none; } +.state { color: var(--muted); font-size: 13px; background: none; border: 0; cursor: pointer; padding: 6px; font: inherit; font-size: 13px; } +.state.done { color: #7fd3a0; } +.versions { background: var(--card); border-radius: 8px; margin: 4px 0 10px; padding: 4px 10px; } +.ver { display: flex; align-items: center; gap: 10px; padding: 7px 0; flex-wrap: wrap; } +.ver + .ver { border-top: 1px solid var(--line); } +.ver .src { flex: 1; min-width: 140px; } +.vote { display: inline-flex; align-items: center; gap: 4px; } +.vote button { background: var(--card2); border: 1px solid var(--line); color: var(--fg); border-radius: 6px; width: 30px; height: 28px; cursor: pointer; } +.vote button.on { background: var(--accent); border-color: var(--accent); color: #fff; } +.vote b { min-width: 22px; text-align: center; } + +/* settings & login */ +.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin-bottom: 16px; max-width: 720px; } +.card h2 { margin: 0 0 12px; font-size: 18px; } +.field { margin-bottom: 14px; } +.field > span { display: block; font-weight: 600; margin-bottom: 4px; } +.check { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 12px; } +.check input { min-height: 0; margin-top: 4px; } +.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.users div { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--line); } +.login { max-width: 360px; margin: 12vh auto 0; } +.login input { width: 100%; } +.saved { color: #7fd3a0; font-size: 13px; } + +/* ---------------- reader ---------------- */ +body.reader { position: fixed; top: 0; left: 0; right: 0; bottom: 0; overflow: hidden; background: #0b0b0c; overscroll-behavior: none; } +#stage { + position: absolute; top: 0; left: 0; right: 0; bottom: 0; overflow: auto; outline: none; + touch-action: pan-x pan-y; /* pinch is handled by the reader so it zooms the pages, not the controls */ + -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent; + overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: #3a3a40 transparent; +} +#stage.pan { cursor: grab; } +#stage.drag { cursor: grabbing; } +#canvas { position: relative; } +.pg { position: absolute; overflow: hidden; background: #19191c; } +.pg img { display: block; width: 100%; height: 100%; -webkit-user-drag: none; } +.pg.rot img { position: absolute; left: 50%; top: 50%; max-width: none; } +/* still loading: a spinner that only appears after 150 ms, so fast loads never flash it */ +.pg.loading::after { + content: ''; position: absolute; left: 50%; top: 50%; width: 38px; height: 38px; margin: -19px 0 0 -19px; + border: 3px solid rgba(255, 255, 255, .15); border-top-color: var(--accent); border-radius: 50%; + opacity: 0; animation: mp-spin .8s linear infinite, mp-show 0s .15s forwards; +} +.pg.failed::after { + content: 'This page could not be loaded'; position: absolute; top: 0; left: 0; right: 0; bottom: 0; + display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 14px; text-align: center; +} +.pg.failed img { visibility: hidden; } +@keyframes mp-spin { to { transform: rotate(360deg); } } +@keyframes mp-show { to { opacity: 1; } } +.loading { color: var(--muted); padding: 40px; text-align: center; } +.end { position: absolute; left: 0; right: 0; display: flex; align-items: center; justify-content: center; } +#canvas.page .end, #canvas.book .end { top: 0; bottom: 0; } +.endbox { display: flex; flex-direction: column; gap: 12px; align-items: center; text-align: center; padding: 20px; } + +.bar { + position: fixed; left: 0; right: 0; z-index: 10; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; + padding: 6px max(8px, env(safe-area-inset-right)) 6px max(8px, env(safe-area-inset-left)); + background: rgba(14, 14, 16, .9); color: var(--fg); transition: transform .2s, opacity .2s; +} +#top { top: 0; padding-top: max(6px, env(safe-area-inset-top)); } +#bot { bottom: 0; padding-bottom: max(6px, env(safe-area-inset-bottom)); } +body.nobars #top { transform: translateY(-100%); opacity: 0; pointer-events: none; } +body.nobars #bot { transform: translateY(100%); opacity: 0; pointer-events: none; } +#title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; } +#chsel { flex: 0 1 auto; min-width: 0; max-width: min(440px, 60vw); } +#bot .row { width: 100%; flex-wrap: nowrap; } +#slider { flex: 1; min-width: 0; accent-color: var(--accent); padding: 0; border: 0; background: none; } +#pageno { min-width: 64px; text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); font-size: 13px; } +#tools { width: 100%; display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 6px 8px; } +.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } +.seg button { background: var(--card); color: var(--fg); border: 0; padding: 7px 12px; font: inherit; cursor: pointer; } +.seg button + button { border-left: 1px solid var(--line); } +.seg button.on { background: var(--accent); color: #fff; } +.seg button.meh:not(.on) { color: var(--muted); } +#ver { max-width: 170px; } +#toast { + position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 30; pointer-events: none; + background: rgba(0, 0, 0, .75); color: #fff; padding: 9px 16px; border-radius: 10px; opacity: 0; transition: opacity .25s; text-align: center; +} +#toast.show { opacity: 1; } +@media (max-width: 560px) { .seg button { padding: 7px 10px; } #bot .btn { padding: 6px 10px; } .wide-only { display: none; } } diff --git a/public/common.js b/public/common.js new file mode 100644 index 0000000..c1e3199 --- /dev/null +++ b/public/common.js @@ -0,0 +1,94 @@ +'use strict'; +/* Shared helpers for all pages. Plain scripts, no build step. */ + +function $(sel, root) { return (root || document).querySelector(sel); } + +// h('a', { href, class, onclick }, 'text', childNode, [more]) -> element. Strings always become text, never HTML. +function h(tag, props, ...kids) { + const el = document.createElement(tag); + for (const k in props || {}) { + const v = props[k]; + if (v == null || v === false) continue; + if (k === 'class') el.className = v; + else if (k.slice(0, 2) === 'on') el.addEventListener(k.slice(2), v); + else el.setAttribute(k, v === true ? '' : v); + } + for (const c of kids.flat(9)) if (c != null && c !== false) el.append(c instanceof Node ? c : String(c)); + return el; +} + +// Only for the constant SVG icons below. +function icon(svg) { const t = document.createElement('template'); t.innerHTML = svg.trim(); return t.content.firstChild; } +const ICONS = { + bell: '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>', +}; + +async function api(path, body) { + const opt = body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; + const r = await fetch(path, opt); + if (r.status === 401 && !/login\.html$/.test(location.pathname)) { + location.href = 'login.html?next=' + encodeURIComponent(location.pathname + location.search); + throw new Error('Please log in'); + } + const data = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(data.error || r.statusText); + return data; +} + +const store = { + get(k) { try { return localStorage.getItem('mp.' + k); } catch (e) { return null; } }, + set(k, v) { try { localStorage.setItem('mp.' + k, v); } catch (e) { /* private mode */ } }, +}; + +function ago(t) { + const s = (Date.now() - t) / 1000; + if (s < 90) return 'just now'; + if (s < 5400) return Math.round(s / 60) + ' min ago'; + if (s < 129600) return Math.round(s / 3600) + ' h ago'; + if (s < 3e6) return Math.round(s / 86400) + ' days ago'; + return new Date(t).toLocaleDateString(); +} + +const qsa = obj => { const p = new URLSearchParams(); for (const k in obj) if (obj[k] != null && obj[k] !== '') p.set(k, obj[k]); return p.toString(); }; +const readUrl = (m, c, v) => 'read.html?' + qsa({ m, c, v }); +const mangaUrl = m => 'manga.html?' + qsa({ m }); +const safeHref = u => (/^https?:\/\//i.test(u || '') ? u : null); + +// Top navigation with the notification bell. +function topNav(me) { + const count = h('span', { class: 'count', hidden: !me.unread }, me.unread); + const panel = h('div', { class: 'panel', hidden: true }); + const setCount = n => { count.textContent = n; count.hidden = !n; }; + async function open() { + panel.hidden = !panel.hidden; + if (panel.hidden) return; + panel.textContent = 'Loading…'; + const { items } = await api('api/notifications'); + panel.textContent = ''; + if (!items.length) { + panel.append(h('p', { class: 'muted' }, 'Nothing yet. Press "Notify me" on a manga to hear about its new chapters.')); + return; + } + panel.append(h('div', { class: 'panel-head' }, h('b', {}, 'New chapters'), + h('button', { class: 'btn small', onclick: async () => { setCount((await api('api/notifications/read', { all: true })).unread); panel.querySelectorAll('.unread').forEach(e => e.classList.remove('unread')); } }, 'Mark all read'))); + for (const n of items) { + panel.append(h('a', { class: 'note' + (n.read ? '' : ' unread'), href: readUrl(n.m, n.c) }, h('div', {}, n.text), h('div', { class: 'muted small' }, ago(n.t)))); + } + } + const bell = h('button', { class: 'btn icon bell', title: 'Notifications', 'aria-label': 'Notifications', onclick: e => { e.stopPropagation(); open(); } }, icon(ICONS.bell), count); + panel.addEventListener('click', e => e.stopPropagation()); + document.addEventListener('click', () => { panel.hidden = true; }); + const nav = h('header', { class: 'nav' }, + h('a', { class: 'brand', href: './' }, 'MangaParty'), + h('div', { class: 'grow' }), + h('div', { class: 'bell-wrap' }, bell, panel), + h('a', { class: 'btn', href: 'settings.html' }, 'Settings'), + h('button', { class: 'btn', onclick: async () => { await api('api/logout', {}); location.href = 'login.html'; } }, 'Log out')); + document.body.prepend(nav); +} + +function showError(e) { + const main = $('main') || document.body; + main.textContent = ''; + main.append(h('p', { class: 'error' }, e.message || String(e)), h('a', { class: 'btn', href: './' }, 'Back to the library')); +} diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..c332a1d --- /dev/null +++ b/public/icon.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#e0566a"/><path d="M12 16c7-2 14-1 20 3v31c-6-4-13-5-20-3z" fill="#fff"/><path d="M52 16c-7-2-14-1-20 3v31c6-4 13-5 20-3z" fill="#ffd9de"/></svg> diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..a3ba5c0 --- /dev/null +++ b/public/index.html @@ -0,0 +1,17 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> +<title>MangaParty + + + + + + + + +

Loading…

+ + diff --git a/public/library.js b/public/library.js new file mode 100644 index 0000000..b16e7b3 --- /dev/null +++ b/public/library.js @@ -0,0 +1,45 @@ +'use strict'; +/* Library overview: a grid of covers. */ +(async function () { + let me, data; + try { [me, data] = await Promise.all([api('api/me'), api('api/library')]); } catch (e) { return showError(e); } + topNav(me); + const main = $('main'); + main.textContent = ''; + + if (!data.mangas.length) { + main.append(h('div', { class: 'empty' }, + h('h1', {}, data.scanning ? 'Scanning the library…' : 'The library is empty'), + h('p', { class: 'muted' }, data.scanning ? 'This page refreshes by itself.' : 'Put manga folders into the library folder (see README.md for the format)' + (me.admin ? ', then rescan it in the settings.' : '.')))); + if (data.scanning) setTimeout(() => location.reload(), 2000); + return; + } + + const filter = h('input', { type: 'search', placeholder: 'Filter…', 'aria-label': 'Filter', value: store.get('filter') || '' }); + const sort = h('select', { 'aria-label': 'Sort' }, + h('option', { value: 'title' }, 'A–Z'), h('option', { value: 'updated' }, 'Recently updated'), h('option', { value: 'read' }, 'Recently read')); + sort.value = store.get('sort') || 'title'; + const grid = h('div', { class: 'grid' }); + main.append(h('div', { class: 'toolbar' }, filter, sort), grid); + + function render() { + store.set('filter', filter.value); store.set('sort', sort.value); + const q = filter.value.trim().toLowerCase(); + const list = data.mangas.filter(m => !q || m.title.toLowerCase().includes(q)); + if (sort.value === 'updated') list.sort((a, b) => b.updated - a.updated); + if (sort.value === 'read') list.sort((a, b) => b.lastRead - a.lastRead); + grid.textContent = ''; + for (const m of list) { + grid.append(h('a', { class: 'tile', href: mangaUrl(m.id) }, + h('div', { class: 'cv' }, m.cover ? h('img', { loading: 'lazy', src: m.cover, alt: '' }) : null), + m.unread ? h('span', { class: 'badge' }, m.unread + ' new') : null, + h('div', { class: 't' }, m.title), + h('div', { class: 's' }, m.read ? `${m.read} / ${m.chapters} read` : `${m.chapters} chapter${m.chapters === 1 ? '' : 's'}`), + m.read ? h('div', { class: 'prog' }, h('i', { style: `width:${Math.round((100 * m.read) / m.chapters)}%` })) : null)); + } + if (!list.length) grid.append(h('p', { class: 'muted' }, 'Nothing matches.')); + } + filter.addEventListener('input', render); + sort.addEventListener('change', render); + render(); +})(); diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..a99ef6f --- /dev/null +++ b/public/login.html @@ -0,0 +1,26 @@ + + + + + +Sign in · MangaParty + + + + + + + + +
+

MangaParty

+
+

Sign in

+ + + + +
+
+ + diff --git a/public/login.js b/public/login.js new file mode 100644 index 0000000..19adae1 --- /dev/null +++ b/public/login.js @@ -0,0 +1,26 @@ +'use strict'; +(async function () { + const info = await api('api/auth').catch(() => ({})); + let signup = !!info.firstRun; + const draw = () => { + $('#heading').textContent = info.firstRun ? 'Create the admin account' : signup ? 'Create an account' : 'Sign in'; + $('#submit').textContent = signup ? 'Create account' : 'Sign in'; + $('#password').autocomplete = signup ? 'new-password' : 'current-password'; + $('#toggle').hidden = info.firstRun || !info.signup; + $('#toggle').textContent = signup ? 'I have an account' : 'Create an account'; + }; + $('#toggle').onclick = e => { e.preventDefault(); signup = !signup; draw(); }; + $('#form').onsubmit = async e => { + e.preventDefault(); + try { + await api(signup ? 'api/signup' : 'api/login', { name: $('#name').value, password: $('#password').value }); + const next = new URLSearchParams(location.search).get('next') || ''; + location.href = /^\/(?!\/)/.test(next) ? next : './'; + } catch (err) { + $('#msg').textContent = err.message; + $('#msg').hidden = false; + } + }; + draw(); + $('#name').focus(); +})(); diff --git a/public/manga.html b/public/manga.html new file mode 100644 index 0000000..ef15e17 --- /dev/null +++ b/public/manga.html @@ -0,0 +1,17 @@ + + + + + +MangaParty + + + + + + + + +

Loading…

+ + diff --git a/public/manga.js b/public/manga.js new file mode 100644 index 0000000..20c618b --- /dev/null +++ b/public/manga.js @@ -0,0 +1,95 @@ +'use strict'; +/* Manga page: info, follow toggle and the chapter list (books are just separators). */ +(async function () { + const id = new URLSearchParams(location.search).get('m') || ''; + let me, M; + try { [me, M] = await Promise.all([api('api/me'), api('api/manga?' + qsa({ m: id }))]); } catch (e) { return showError(e); } + topNav(me); + document.title = M.title + ' · MangaParty'; + const meta = M.meta || {}, main = $('main'); + main.textContent = ''; + + const TYPE = { rtl: 'Manga · right to left', ltr: 'Comic · left to right', ttb: 'Webtoon · vertical strip' }; + const arr = x => (Array.isArray(x) ? x : x ? [x] : []); + const follow = h('button', { class: 'btn' }, ''); + const drawFollow = () => { follow.textContent = M.following ? '🔔 Notifying you' : 'Notify me about new chapters'; follow.classList.toggle('on', M.following); }; + follow.onclick = async () => { M.following = (await api('api/follow', { m: M.id, on: !M.following })).following; drawFollow(); }; + drawFollow(); + + const sources = arr(meta.sources).filter(s => s && s.name); + main.append(h('section', { class: 'hero' }, + M.cover ? h('img', { src: M.cover, alt: '' }) : null, + h('div', { class: 'info' }, + h('h1', {}, M.title), + arr(meta.altTitles).length ? h('div', { class: 'muted' }, arr(meta.altTitles).join(' · ')) : null, + h('div', { class: 'muted' }, [arr(meta.authors).join(', '), meta.status, meta.year, TYPE[M.direction]].filter(Boolean).join(' · ')), + h('div', { class: 'chips' }, arr(meta.tags).map(t => h('span', { class: 'chip' }, t))), + meta.description ? h('p', { class: 'desc' }, meta.description) : null, + sources.length ? h('div', { class: 'sources small' }, h('span', { class: 'muted' }, 'Sources: '), + sources.map(s => (safeHref(s.url) ? h('a', { href: safeHref(s.url), target: '_blank', rel: 'noopener noreferrer' }, s.name) : h('span', {}, s.name + ' ')))) : null, + h('div', { class: 'actions' }, + M.continue ? h('a', { class: 'btn primary', href: readUrl(M.id, M.continue.c, M.continue.v) }, M.continue.text) : null, + follow)))); + + const books = {}; + for (const b of M.books) books[b.key] = b; + const list = h('section', { class: 'chapters' }); + main.append(list); + let lastBook = null; + for (const ch of M.chapters) { + if (ch.book !== lastBook && M.books.length > 1) { + const b = books[ch.book]; + list.append(h('div', { class: 'book-sep' }, b.label + (b.title ? ' · ' + b.title : ''))); + } + lastBook = ch.book; + list.append(chapterRow(ch)); + } + + function chapterRow(ch) { + const wrap = h('div', {}); + const panel = h('div', { class: 'versions', hidden: true }); + function draw() { + const p = ch.progress, done = p && p.done; + const state = h('button', { class: 'state' + (done ? ' done' : ''), title: done ? 'Mark as unread' : 'Mark as read' }, + done ? '✓ read' : p ? `page ${p.p + 1} / ${p.n}` : `${ch.versions[0].pages} p.`); + state.onclick = async () => { + if (done) { await api('api/unread', { m: M.id, c: ch.id }); ch.progress = null; } + else { + const v = ch.versions[0]; + await api('api/progress', { m: M.id, c: ch.id, v: v.id, p: v.pages - 1, n: v.pages, done: true }); + ch.progress = { v: v.id, p: v.pages - 1, n: v.pages, done: true }; + } + draw(); + }; + const verBtn = ch.versions.length > 1 + ? h('button', { class: 'btn small', onclick: () => { panel.hidden = !panel.hidden; } }, ch.versions.length + ' versions') + : null; + wrap.textContent = ''; + wrap.append(h('div', { class: 'ch' + (done ? ' read' : '') }, + h('a', { class: 'ch-main', href: readUrl(M.id, ch.id) }, + h('span', { class: 'ch-label' }, ch.label), h('span', { class: 'ch-title' }, ch.title || ''), + ch.extra ? h('span', { class: 'tag' }, 'extra') : null), + verBtn, state), panel); + drawVersions(); + } + function drawVersions() { + panel.textContent = ''; + panel.append(h('div', { class: 'muted small', style: 'padding-top:6px' }, 'The best rated version opens by default. Vote to change that for everyone.')); + ch.versions.forEach((v, i) => { + const vote = async value => { ch.versions = (await api('api/vote', { m: M.id, c: ch.id, v: v.id, value: v.my === value ? 0 : value })).versions; drawVersions(); }; + panel.append(h('div', { class: 'ver' }, + h('div', { class: 'src' }, + safeHref(v.sourceUrl) ? h('a', { href: safeHref(v.sourceUrl), target: '_blank', rel: 'noopener noreferrer' }, v.source || v.id) : h('span', {}, v.source || v.id), + i === 0 ? h('span', { class: 'tag', style: 'margin-left:8px' }, 'default') : null, + h('div', { class: 'muted small' }, `${v.pages} pages · added ${new Date(v.added).toLocaleDateString()}`)), + h('span', { class: 'vote' }, + h('button', { class: v.my > 0 ? 'on' : '', title: 'Better', onclick: () => vote(1) }, '▲'), + h('b', {}, v.score), + h('button', { class: v.my < 0 ? 'on' : '', title: 'Worse', onclick: () => vote(-1) }, '▼')), + h('a', { class: 'btn small', href: readUrl(M.id, ch.id, v.id) }, 'Read'))); + }); + } + draw(); + return wrap; + } +})(); diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..00eaa99 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,10 @@ +{ + "name": "MangaParty", + "short_name": "MangaParty", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#121214", + "theme_color": "#121214", + "icons": [{ "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }] +} diff --git a/public/read.html b/public/read.html new file mode 100644 index 0000000..f14932e --- /dev/null +++ b/public/read.html @@ -0,0 +1,47 @@ + + + + + + + + +MangaParty + + + + + + + + +

Loading…

+ +
+ ‹ +
+ + +
+ +
+
+ + +
+
+ ‹ Prev +
+ + + +
+ + + + Next › +
+
+
+ + diff --git a/public/reader.js b/public/reader.js new file mode 100644 index 0000000..ae58de1 --- /dev/null +++ b/public/reader.js @@ -0,0 +1,538 @@ +'use strict'; +/* + * Chapter reader. + * - Modes: flow (all pages below each other, same width), page (one page), book (two facing pages; double pages alone). + * - Page zoom ("zoom" below): 1 = the page (in flow: a normal single page) exactly fits the screen. + * It is remembered per mode; flow for webtoons has its own value ("strip", 1 = strip as wide as the screen / its own width). + * - Tap zones: left/right third turns pages following the reading direction (top/bottom third in flow), the middle toggles the bars. + * - All pages are laid out with absolute positions computed from the image sizes the server knows, so zooming and + * restoring positions never depend on images having loaded. + */ +(async function () { + const P = new URLSearchParams(location.search); + const stage = $('#stage'), canvas = $('#canvas'), slider = $('#slider'), ver = $('#ver'); + let me, D; + try { [me, D] = await Promise.all([api('api/me'), api('api/chapter?' + qsa({ m: P.get('m'), c: P.get('c'), v: P.get('v') }))]); } + catch (e) { canvas.textContent = ''; canvas.append(h('p', { class: 'loading' }, e.message + ' · ', h('a', { href: './' }, 'Library'))); return; } + + const S = me.settings, rtl = D.direction === 'rtl', ttb = D.direction === 'ttb'; + const GAP = ttb ? 0 : 8, END_H = 280; + document.title = `${D.chapter.label} · ${D.manga.title}`; + + // ---------- pages ---------- + const all = D.pages.map((p, i) => { + const t = p.t || [], r = p.r || 0, unknown = !p.w || !p.h; + const w = unknown ? (t.includes('double') ? 2000 : 1000) : p.w, hh = unknown ? 1414 : p.h; + const dw = r % 180 ? hh : w, dh = r % 180 ? w : hh; // size as displayed (after rotation) + return { i, u: p.u, r, w: dw, a: dw / dh, unknown, cover: t.includes('cover'), extra: t.includes('extra'), wide: t.includes('double') || dw > dh }; + }); + let list = all.filter(p => !(S.skipExtras && p.extra) && !(S.skipCovers && p.cover)); + if (!list.length) list = all; + const median = xs => (xs.length ? xs.sort((x, y) => x - y)[xs.length >> 1] : 0); + const refA = median(list.filter(p => !p.wide).map(p => p.a)) || 0.7; // aspect ratio of a normal single page + const refW = median(list.map(p => p.w)) || 800; // natural width of a webtoon strip + const solo = p => p.wide || p.cover; // shown alone in book mode + + // ---------- state ---------- + const compatible = ttb ? ['flow'] : ['flow', 'page', 'book']; + const intended = S.startMode === 'last' ? S.lastMode : S.startMode; + let mode = S.adapt && !compatible.includes(intended) ? compatible[0] : intended; + if (!['flow', 'page', 'book'].includes(mode)) mode = compatible[0]; + + let cur = 0, atEnd = false, done = !!(D.progress && D.progress.done), bars = true, barsAt = 0; + let spreads = [], spreadIdx = 0, shown = [], els = [], boxes = [], endEl = null, lastW = 0, lastH = 0; + const shift = new Set(D.progress && D.progress.v === D.version ? D.progress.shift : []); + const zkey = () => (mode === 'flow' && ttb ? 'strip' : mode); + const zoom = () => S.zoom[zkey()] || 1; + + const pr = D.progress; + if (pr && !(pr.done && pr.p >= pr.n - 1)) { + const p = pr.v === D.version || !pr.n ? pr.p : Math.round((pr.p / pr.n) * all.length); + const k = list.findIndex(x => x.i >= p); + cur = k < 0 ? list.length - 1 : k; + } + + // Book mode: pages pair up inside each run of single pages. Runs restart after every double page / cover, + // so one missing pair never shifts the rest of the chapter. "Shift" makes a run start with a lone page. + function computeSpreads() { + const out = []; + for (let i = 0; i < list.length;) { + if (solo(list[i])) { out.push([i++]); continue; } + let j = i, k = i; + while (j < list.length && !solo(list[j])) j++; + if (shift.has(list[i].i)) out.push([k++]); + for (; k < j; k += 2) out.push(k + 1 < j ? [k, k + 1] : [k]); + i = j; + } + return out; + } + const runStart = k => { while (k > 0 && !solo(list[k - 1])) k--; return k; }; + + // ---------- rendering ---------- + const io = window.IntersectionObserver + ? new IntersectionObserver(entries => { + for (const e of entries) if (e.isIntersecting) { setSrc(e.target, e.target._p.u); io.unobserve(e.target); } + }, { root: stage, rootMargin: '150% 50%' }) + : null; + + // Page/book mode: preloaded images are downloaded *and decoded* ahead of time and then shown as the very same + // elements, so a page turn swaps pictures within one frame instead of flashing an empty box. + const pool = new Map(); + function pooled(url, priority) { + let img = pool.get(url); + if (img) pool.delete(url); + else { + img = new Image(); + img.decoding = 'async'; + img.fetchPriority = priority; + img.src = url; + if (img.decode) img.decode().catch(() => {}); + } + pool.set(url, img); // most recently used last + while (pool.size > 12) pool.delete(pool.keys().next().value); + return img; + } + function preloadAround() { + const ahead = mode === 'page' ? [cur + 1, cur + 2, cur + 3, cur - 1] + : [].concat(spreads[spreadIdx + 1] || [], spreads[spreadIdx + 2] || [], spreads[spreadIdx - 1] || []); + for (const k of ahead) if (list[k]) pooled(list[k].u, 'low'); + } + + // A spinner shows (after a short delay, see CSS) while a page is still loading, a message if it failed. + function watch(el) { + const img = el.firstChild, p = el._p; + const state = () => { + el.classList.toggle('loading', !!img.getAttribute('src') && !img.complete); + el.classList.toggle('failed', img.complete && !!img.getAttribute('src') && !img.naturalWidth); + }; + img.onload = () => { + state(); + if (p.unknown && img.naturalWidth) { + const w = p.r % 180 ? img.naturalHeight : img.naturalWidth, hh = p.r % 180 ? img.naturalWidth : img.naturalHeight; + Object.assign(p, { unknown: false, w, a: w / hh, wide: p.wide || w > hh }); + relayout(); + } + }; + img.onerror = state; + state(); + } + function setSrc(el, url) { el.firstChild.src = url; watch(el); } + + function pageEl(p, lazy) { + const img = lazy ? h('img', { decoding: 'async' }) : pooled(p.u, 'high'); + img.alt = ''; + img.draggable = false; + img.style.cssText = p.r ? `transform: translate(-50%, -50%) rotate(${p.r}deg)` : ''; + if (!lazy) img.decoding = 'sync'; // paint together with the page turn (it is decoded already when preloaded) + const el = h('div', { class: 'pg' + (p.r ? ' rot' : '') }, img); + el._p = p; + if (!lazy) watch(el); + else if (io) io.observe(el); + else setSrc(el, p.u); + return el; + } + + function endCard() { + const n = D.next; + return h('div', { class: 'end' }, h('div', { class: 'endbox' }, + h('div', { class: 'muted' }, 'End of ' + D.chapter.label + (D.chapter.title ? ' · ' + D.chapter.title : '')), + n ? h('a', { class: 'btn primary', href: readUrl(D.manga.id, n.id), onclick: () => saveProgress(true) }, `Next: ${n.label}${n.title ? ' · ' + n.title : ''} ›`) + : h('div', {}, 'You are up to date.'), + h('a', { class: 'btn', href: mangaUrl(D.manga.id) }, 'Back to ' + D.manga.title))); + } + + function build() { + if (io) io.disconnect(); + canvas.textContent = ''; + canvas.className = mode + (ttb ? ' ttb' : ''); + els = []; shown = []; endEl = null; + if (mode === 'flow') { + els = list.map(p => canvas.appendChild(pageEl(p, true))); + endEl = canvas.appendChild(endCard()); + } else if (atEnd) { + endEl = canvas.appendChild(endCard()); + } else { + shown = mode === 'page' ? [cur] : spreads[spreadIdx]; + els = shown.map(k => canvas.appendChild(pageEl(list[k]))); + // The pages on screen get the bandwidth first, then the next ones are fetched. + const token = ++buildToken, waiting = els.map(el => el.firstChild).filter(img => !img.complete); + Promise.all(waiting.map(img => new Promise(done => { + img.addEventListener('load', done, { once: true }); + img.addEventListener('error', done, { once: true }); + }))).then(() => { if (token === buildToken) preloadAround(); }); + } + layout(); + } + let buildToken = 0; + + // Scrollbars appear/disappear with the content size and change the viewport, so repeat until it is stable. + function layout() { + for (let pass = 0; pass < 3; pass++) { + const vw = stage.clientWidth, vh = stage.clientHeight; + layoutFor(vw, vh); + if (stage.clientWidth === vw && stage.clientHeight === vh) break; + } + } + function layoutFor(vw, vh) { + const z = zoom(); + let cw = vw, ch = vh; + boxes = []; + if (mode === 'flow') { + // Every page gets the width of a normal single page; wide pages keep that height but never get wider than the screen. + const W = (ttb ? Math.min(vw, refW) : Math.min(vw, vh * refA)) * z; + const ws = list.map(p => (p.wide && !ttb ? Math.min((W * p.a) / refA, Math.max(vw, W)) : W)); + cw = Math.max(vw, ...ws); + let y = 0; + ws.forEach((w, k) => { boxes.push({ x: (cw - w) / 2, y, w, h: w / list[k].a }); y += w / list[k].a + GAP; }); + endEl.style.top = y + 'px'; + endEl.style.height = END_H + 'px'; + ch = y + END_H; + } else if (shown.length) { + const sum = shown.reduce((s, k) => s + list[k].a, 0), H = Math.min(vh, vw / sum) * z, W = H * sum; + cw = Math.max(vw, W); ch = Math.max(vh, H); + const y = (ch - H) / 2, order = shown.map((k, j) => j); + if (rtl) order.reverse(); // right-to-left: the first page of a pair sits on the right + let x = (cw - W) / 2; + for (const j of order) { boxes[j] = { x, y, w: H * list[shown[j]].a, h: H }; x += boxes[j].w; } + } + canvas.style.width = cw + 'px'; + canvas.style.height = ch + 'px'; + els.forEach((el, j) => place(el, boxes[j])); + stage.classList.toggle('pan', cw > vw + 1 || (mode === 'flow' ? z > 1.01 : ch > vh + 1)); + lastW = vw; lastH = vh; + } + + function place(el, b) { + const s = el.style, x = Math.round(b.x), y = Math.round(b.y), w = Math.round(b.x + b.w) - x, hh = Math.round(b.y + b.h) - y; + s.left = x + 'px'; s.top = y + 'px'; s.width = w + 'px'; s.height = hh + 'px'; + if (el._p.r) { + const im = el.firstChild.style, turned = el._p.r % 180; + im.width = (turned ? hh : w) + 'px'; + im.height = (turned ? w : hh) + 'px'; + } + } + + function flowIndexAt(y) { + let lo = 0, hi = boxes.length - 1; + while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (boxes[mid].y <= y) lo = mid; else hi = mid - 1; } + return lo; + } + function union() { + const x = Math.min(...boxes.map(b => b.x)); + return { x, y: boxes[0].y, w: Math.max(...boxes.map(b => b.x + b.w)) - x, h: boxes[0].h }; + } + // Remember which point of which page is under (cx, cy) and put it back there after a re-layout. + function anchor(cx, cy) { + if (!boxes.length) return null; + const X = stage.scrollLeft + cx, Y = stage.scrollTop + cy, k = mode === 'flow' ? flowIndexAt(Y) : -1, b = k < 0 ? union() : boxes[k]; + return { k, fx: (X - b.x) / b.w, fy: (Y - b.y) / b.h }; + } + function restore(a, cx, cy) { + if (!a || !boxes.length) return; + const b = a.k < 0 ? union() : boxes[a.k]; + stage.scrollLeft = b.x + a.fx * b.w - cx; + stage.scrollTop = b.y + a.fy * b.h - cy; + barsAt = stage.scrollTop; + } + function relayout() { const a = anchor(lastW / 2, lastH / 2); layout(); restore(a, lastW / 2, lastH / 2); } + + // ---------- zoom ---------- + let zoomTimer = 0; + function setZoom(z, cx = stage.clientWidth / 2, cy = stage.clientHeight / 2) { + z = Math.min(6, Math.max(0.2, z)); + if (Math.abs(z - 1) < 0.03) z = 1; // easy to get back to "fit" + if (Math.abs(z - zoom()) < 0.001) return; + const a = anchor(cx, cy); + S.zoom[zkey()] = z; + layout(); + restore(a, cx, cy); + toast(Math.round(z * 100) + '%'); + updateUI(); + clearTimeout(zoomTimer); + zoomTimer = setTimeout(() => api('api/settings', { zoom: S.zoom }).catch(() => {}), 800); + } + + // Ctrl + wheel (and touchpad pinch, which browsers report the same way) zooms the pages, not the page controls. + stage.addEventListener('wheel', e => { + if (!e.ctrlKey) return; + e.preventDefault(); + setZoom(zoom() * Math.exp(-e.deltaY * (e.deltaMode === 1 ? 0.05 : 0.0025)), e.clientX, e.clientY); + }, { passive: false }); + + // Touch: two fingers zoom, a quick horizontal swipe turns the page (page/book mode, when not zoomed in). + let pinch = null, swipe = null, pinchRaf = 0, pinchNext = null; + const pinchApply = () => { pinchRaf = 0; if (pinchNext) setZoom(...pinchNext); pinchNext = null; }; + const dist = t => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY); + stage.addEventListener('touchstart', e => { + const t = e.touches; + if (t.length === 2) { pinch = { d: dist(t), z: zoom() }; swipe = null; } + else if (t.length === 1) swipe = { x: t[0].clientX, y: t[0].clientY, t: Date.now() }; + }, { passive: true }); + stage.addEventListener('touchmove', e => { + if (!pinch || e.touches.length !== 2) return; + e.preventDefault(); + const t = e.touches; + pinchNext = [pinch.z * dist(t) / pinch.d, (t[0].clientX + t[1].clientX) / 2, (t[0].clientY + t[1].clientY) / 2]; + pinchRaf = pinchRaf || requestAnimationFrame(pinchApply); + }, { passive: false }); + stage.addEventListener('touchend', e => { + if (e.touches.length < 2) { if (pinchNext) pinchApply(); pinch = null; } + const s = swipe; + swipe = null; + if (!s || mode === 'flow' || e.touches.length) return; + const t = e.changedTouches[0], dx = t.clientX - s.x, dy = t.clientY - s.y; + if (Math.abs(dx) < 50 || Math.abs(dx) < 2 * Math.abs(dy) || Date.now() - s.t > 800) return; + if (stage.scrollWidth > stage.clientWidth + 1) return; // zoomed in: the finger pans instead + ((dx > 0) === rtl ? next : prev)(); + }); + // Safari reports pinches as gesture events; without this it would zoom the whole page. + let gestureZoom = 1; + stage.addEventListener('gesturestart', e => { e.preventDefault(); gestureZoom = zoom(); }); + stage.addEventListener('gesturechange', e => { e.preventDefault(); if (!pinch) setZoom(gestureZoom * e.scale, e.clientX, e.clientY); }); + + // Mouse: drag to move a zoomed page around; a click without dragging uses the tap zones. + let drag = null, dragged = false; + stage.addEventListener('pointerdown', e => { + if (e.pointerType === 'mouse' && e.button === 0) drag = { x: e.clientX, y: e.clientY, l: stage.scrollLeft, t: stage.scrollTop, moved: false }; + }); + addEventListener('pointermove', e => { + if (!drag) return; + const dx = e.clientX - drag.x, dy = e.clientY - drag.y; + if (!drag.moved && Math.abs(dx) + Math.abs(dy) < 6) return; + drag.moved = true; + stage.classList.add('drag'); + stage.scrollLeft = drag.l - dx; + stage.scrollTop = drag.t - dy; + }); + addEventListener('pointerup', () => { + if (drag && drag.moved) { dragged = true; setTimeout(() => { dragged = false; }, 0); } + drag = null; + stage.classList.remove('drag'); + }); + + // Outside the pages: toggle the controls. On a page: the outer 35 % of the visible page area turn pages + // (top/bottom in flow), the middle toggles the controls (needed when a zoomed page fills the whole screen). + stage.addEventListener('click', e => { + if (dragged || e.target.closest('a, button, select, input')) return; + const onPage = e.target.closest('.pg'), vw = stage.clientWidth; + if (!onPage && !(atEnd && mode !== 'flow')) return toggleBars(); + if (mode === 'flow') { + const fy = e.clientY / stage.clientHeight; + return fy < 0.35 ? prev() : fy > 0.65 ? next() : toggleBars(); + } + let left = 0, right = vw; + if (!atEnd) { + const rects = els.map(el => el.getBoundingClientRect()); + left = Math.max(0, Math.min(...rects.map(r => r.left))); + right = Math.min(vw, Math.max(...rects.map(r => r.right))); + } + const fx = (e.clientX - left) / (right - left); + if (fx < 0.35) (rtl ? next : prev)(); + else if (fx > 0.65) (rtl ? prev : next)(); + else toggleBars(); + }); + + addEventListener('keydown', e => { + if ((e.target.closest && e.target.closest('input, select, textarea')) || e.ctrlKey || e.metaKey || e.altKey) return; + const k = e.key; + if (k === 'ArrowLeft' || k === 'ArrowRight') { e.preventDefault(); (k === (rtl ? 'ArrowLeft' : 'ArrowRight') ? next : prev)(); } + else if ((k === ' ' || k === 'PageDown') && mode !== 'flow') { e.preventDefault(); (e.shiftKey ? prev : next)(); } + else if (k === 'PageUp' && mode !== 'flow') { e.preventDefault(); prev(); } + else if (k === '+' || k === '=') setZoom(zoom() * 1.15); + else if (k === '-') setZoom(zoom() / 1.15); + else if (k === '0') setZoom(1); + else if (k === 'm' || k === 'Escape') toggleBars(); + else if (k === 'f') toggleFullscreen(); + }); + + let scrollRaf = 0; + stage.addEventListener('scroll', () => { + if (mode !== 'flow' || scrollRaf) return; + scrollRaf = requestAnimationFrame(() => { + scrollRaf = 0; + cur = flowIndexAt(stage.scrollTop + stage.clientHeight * 0.3); + // Scrolling (up or down) only ever hides the bars; they come back with a tap in the middle. + if (bars && Math.abs(stage.scrollTop - barsAt) > 100) autoHide(); + moved(); + }); + }); + + let resizeRaf = 0; + addEventListener('resize', () => { resizeRaf = resizeRaf || requestAnimationFrame(() => { resizeRaf = 0; relayout(); }); }); + + // ---------- navigation ---------- + function next() { + if (mode === 'flow') return flowStep(1); + if (atEnd) return goChapter(D.next); + if (mode === 'page' ? cur >= list.length - 1 : spreadIdx >= spreads.length - 1) atEnd = true; + else if (mode === 'page') cur++; + else cur = spreads[++spreadIdx][0]; + turned(); + } + function prev() { + if (mode === 'flow') return flowStep(-1); + if (atEnd) atEnd = false; + else if (mode === 'page' ? cur === 0 : spreadIdx === 0) return toast('First page'); + else if (mode === 'page') cur--; + else cur = spreads[--spreadIdx][0]; + turned(); + } + function turned() { build(); toStart(); autoHide(); moved(); } + function toStart() { stage.scrollTop = 0; stage.scrollLeft = rtl ? stage.scrollWidth : 0; barsAt = 0; } + function goChapter(c) { + if (!c) return toast('You are up to date'); + saveProgress(true); + location.href = readUrl(D.manga.id, c.id); + } + // Flow: jump to the top of the next page, but never further than ~one screen. + function flowStep(dir) { + const st = stage.scrollTop, vh = stage.clientHeight; + if (dir > 0 && st + vh >= stage.scrollHeight - 2) return goChapter(D.next); + let t; + if (ttb) t = st + dir * vh * 0.85; // webtoon slices are arbitrary cuts: just scroll + else if (dir > 0) { const b = boxes.find(b => b.y > st + 2); t = b ? Math.min(b.y, st + vh * 0.9) : st + vh * 0.9; } + else { let b = null; for (const x of boxes) { if (x.y < st - 2) b = x; else break; } t = b ? Math.max(b.y, st - vh * 0.9) : 0; } + stage.scrollTo({ top: t, behavior: 'smooth' }); + } + function goTo(k) { + atEnd = false; + if (mode === 'flow') { stage.scrollTop = boxes[k].y; barsAt = stage.scrollTop; cur = k; return moved(); } + if (mode === 'book') { spreadIdx = spreads.findIndex(s => s.includes(k)); cur = spreads[spreadIdx][0]; } + else cur = k; + build(); toStart(); moved(); + } + + function setMode(m, byUser) { + mode = m; atEnd = false; + if (mode === 'book') { + spreads = computeSpreads(); + spreadIdx = Math.max(0, spreads.findIndex(s => s.includes(cur))); + cur = spreads[spreadIdx][0]; + } + build(); + if (mode === 'flow') { + stage.scrollTop = boxes[cur].y; + stage.scrollLeft = (stage.scrollWidth - stage.clientWidth) / 2; + barsAt = stage.scrollTop; + } else toStart(); + // Only a click changes the remembered mode, never an automatic switch for incompatible content. + if (byUser) { S.lastMode = m; api('api/settings', { lastMode: m }).catch(() => {}); } + moved(); + } + + function toggleShift() { + const k = shown[0]; + if (k === undefined || solo(list[k])) return toast('This page is shown alone anyway'); + const s = list[runStart(k)].i; + if (shift.has(s)) shift.delete(s); + else shift.add(s); + spreads = computeSpreads(); + spreadIdx = spreads.findIndex(sp => sp.includes(k)); + cur = spreads[spreadIdx][0]; + build(); toStart(); moved(); + toast(shift.has(s) ? 'Pairs shifted by one page' : 'Pairs back to normal'); + } + + // ---------- bars, progress ---------- + function setBars(on) { + // Stop a running smooth scroll first, otherwise it would hide the bars again right away. + if (on) stage.scrollTo({ left: stage.scrollLeft, top: stage.scrollTop }); + bars = on; + document.body.classList.toggle('nobars', !on); + barsAt = stage.scrollTop; + } + function toggleBars() { setBars(!bars); } + // Reading hides the bars. The first time (per device and per kind of mode) say how to get them back. + function autoHide() { + if (!bars) return; + setBars(false); + const key = mode === 'flow' ? 'hint.flow' : 'hint.paged'; + if (store.get(key)) return; + store.set(key, '1'); + toast(mode === 'flow' ? 'Controls hidden · tap the middle of the screen or beside the pages to show them' + : 'Controls hidden · tap the middle of the page or beside it to show them', 3500); + } + + let toastTimer = 0; + function toast(msg, ms = 900) { + const t = $('#toast'); + t.textContent = msg; + t.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => t.classList.remove('show'), ms); + } + + const modeBtns = [...document.querySelectorAll('#modes button')]; + function updateUI() { + const n = list.length; + $('#pageno').textContent = atEnd ? 'end' : (shown.length === 2 ? `${cur + 1}-${cur + 2}` : cur + 1) + ' / ' + n; + slider.max = n; + slider.value = cur + 1; + for (const b of modeBtns) { + b.classList.toggle('on', b.dataset.mode === mode); + b.classList.toggle('meh', !compatible.includes(b.dataset.mode)); + } + const sh = $('#shift'); + sh.hidden = mode !== 'book'; + sh.classList.toggle('on', shown.length > 0 && !solo(list[shown[0]]) && shift.has(list[runStart(shown[0])].i)); + $('#zoom').textContent = Math.round(zoom() * 100) + '%'; + } + + let saveTimer = 0; + function moved() { + if (mode === 'flow') { + const b = boxes[boxes.length - 1]; + if (b && stage.scrollTop + stage.clientHeight >= b.y + b.h - 4) { done = true; cur = list.length - 1; } + } else if (atEnd || (mode === 'page' ? cur === list.length - 1 : spreadIdx === spreads.length - 1)) done = true; + updateUI(); + clearTimeout(saveTimer); + saveTimer = setTimeout(saveProgress, 700); + } + function saveProgress(keepalive) { + clearTimeout(saveTimer); + const body = JSON.stringify({ m: D.manga.id, c: D.chapter.id, v: D.version, p: list[cur].i, n: all.length, done, shift: [...shift] }); + fetch('api/progress', { method: 'POST', keepalive: !!keepalive, headers: { 'Content-Type': 'application/json' }, body }).catch(() => {}); + } + addEventListener('pagehide', () => saveProgress(true)); + + // ---------- controls ---------- + $('#title').textContent = D.manga.title; + // Chapter picker: every chapter of the manga, grouped by book when there is more than one. + const chsel = $('#chsel'), groups = {}; + for (const b of D.books) { + groups[b.key] = D.books.length > 1 ? chsel.appendChild(h('optgroup', { label: b.label + (b.title ? ' · ' + b.title : '') })) : chsel; + } + for (const c of D.chapters) { + groups[c.book].append(h('option', { value: c.id }, `${c.label}${c.title ? ' · ' + c.title : ''}${c.extra ? ' (extra)' : ''}${c.done ? ' ✓' : ''}`)); + } + chsel.value = D.chapter.id; + chsel.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, chsel.value); }; + $('#back').href = mangaUrl(D.manga.id); + for (const [id, c] of [['#prevch', D.prev], ['#nextch', D.next]]) { + const a = $(id); + if (c) { a.href = readUrl(D.manga.id, c.id); a.title = c.label + (c.title ? ' · ' + c.title : ''); a.onclick = () => saveProgress(true); } + else a.classList.add('disabled'); + } + for (const v of D.versions) ver.append(h('option', { value: v.id }, (v.source || v.id) + (v.score ? ` (${v.score > 0 ? '+' : ''}${v.score})` : ''))); + ver.value = D.version; + ver.hidden = D.versions.length < 2; + ver.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, D.chapter.id, ver.value); }; + for (const b of modeBtns) b.onclick = () => { if (b.dataset.mode !== mode) setMode(b.dataset.mode, true); }; + $('#shift').onclick = toggleShift; + $('#zoom').onclick = () => setZoom(1); + slider.dir = rtl ? 'rtl' : 'ltr'; + slider.addEventListener('input', () => goTo(+slider.value - 1)); + function toggleFullscreen() { + if (!document.fullscreenEnabled) return; + if (document.fullscreenElement) document.exitFullscreen(); + else document.documentElement.requestFullscreen().catch(() => {}); + } + $('#fs').hidden = !document.fullscreenEnabled; + $('#fs').onclick = toggleFullscreen; + // Keep keyboard focus on the pages so Space/arrows never "press" the last clicked button again. + for (const bar of [$('#top'), $('#bot')]) bar.addEventListener('click', e => { if (e.target.closest('button')) stage.focus({ preventScroll: true }); }); + + setMode(mode, false); + stage.focus({ preventScroll: true }); + setBars(true); +})(); diff --git a/public/settings.html b/public/settings.html new file mode 100644 index 0000000..c983b29 --- /dev/null +++ b/public/settings.html @@ -0,0 +1,17 @@ + + + + + +Settings · MangaParty + + + + + + + + +

Loading…

+ + diff --git a/public/settings.js b/public/settings.js new file mode 100644 index 0000000..2fca5ac --- /dev/null +++ b/public/settings.js @@ -0,0 +1,105 @@ +'use strict'; +/* Settings: reading preferences, notifications, password and (for admins) library + users. */ +(async function () { + let me; + try { me = await api('api/me'); } catch (e) { return showError(e); } + topNav(me); + const S = me.settings, main = $('main'); + main.textContent = ''; + + const saved = h('span', { class: 'saved', hidden: true }, 'Saved'); + let savedTimer = 0; + async function save(patch) { + Object.assign(S, (await api('api/settings', patch)).settings); + saved.hidden = false; + clearTimeout(savedTimer); + savedTimer = setTimeout(() => { saved.hidden = true; }, 1500); + } + const check = (key, text, hint) => h('label', { class: 'check' }, + h('input', { type: 'checkbox', checked: !!S[key], onchange: e => save({ [key]: e.target.checked }) }), + h('span', {}, text, hint ? h('div', { class: 'muted small' }, hint) : null)); + + const startMode = h('select', { onchange: e => save({ startMode: e.target.value }) }, + h('option', { value: 'last' }, 'The mode I used last'), + h('option', { value: 'flow' }, 'Always Flow (pages below each other)'), + h('option', { value: 'page' }, 'Always Page (one page at a time)'), + h('option', { value: 'book' }, 'Always Book (two pages side by side)')); + startMode.value = S.startMode; + const LABEL = { flow: 'Flow', page: 'Page', book: 'Book', strip: 'Webtoon flow' }; + const zooms = () => Object.keys(LABEL).map(k => `${LABEL[k]} ${Math.round((S.zoom[k] || 1) * 100)}%`).join(' · '); + const zoomText = h('span', { class: 'muted' }, zooms()); + + main.append(h('section', { class: 'card' }, + h('h2', {}, 'Reading ', saved), + h('label', { class: 'field' }, h('span', {}, 'Open chapters in'), startMode, + h('div', { class: 'muted small' }, `Last used: ${LABEL[S.lastMode]}. Only clicking a mode in the reader changes it.`)), + check('adapt', 'Use the fitting mode when the content is not made for my choice', + 'Example: webtoons are one long strip, so they always open in Flow. This never changes the mode you picked.'), + check('skipExtras', 'Skip extra pages and extra chapters', 'Pages or chapters marked as extra (omake, notes, specials) are left out while reading.'), + check('skipCovers', 'Skip chapter cover pages'), + h('div', { class: 'field' }, h('span', {}, 'Page zoom'), zoomText, ' ', + h('button', { class: 'btn small', onclick: async () => { await save({ zoom: {} }); zoomText.textContent = zooms(); } }, 'Reset to fit'), + h('div', { class: 'muted small' }, '100% = the page fits the screen. Change it in the reader with Ctrl + mouse wheel, a pinch, or the + / - keys.')))); + + const hook = h('input', { type: 'url', placeholder: 'https://ntfy.sh/my-manga-topic', value: S.webhook || '', style: 'width:100%' }); + const hookMsg = h('div', { class: 'small' }); + main.append(h('section', { class: 'card' }, + h('h2', {}, 'Notifications'), + h('p', { class: 'muted' }, 'Press "Notify me" on a manga page. New chapters then show up under the bell at the top.'), + h('label', { class: 'field' }, h('span', {}, 'Also send them to a webhook (optional)'), hook, + h('div', { class: 'muted small' }, 'The server POSTs a short text message to this URL, e.g. an ntfy.sh or Gotify topic.')), + h('div', { class: 'row' }, h('button', { class: 'btn', onclick: async () => { + try { await save({ webhook: hook.value }); hookMsg.textContent = ''; } catch (e) { hookMsg.textContent = e.message; hookMsg.className = 'small error'; } + } }, 'Save webhook'), hookMsg))); + + const oldPw = h('input', { type: 'password', autocomplete: 'current-password', placeholder: 'Current password' }); + const newPw = h('input', { type: 'password', autocomplete: 'new-password', placeholder: 'New password' }); + const pwMsg = h('span', { class: 'small' }); + main.append(h('section', { class: 'card' }, + h('h2', {}, 'Account · ' + me.name), + h('div', { class: 'row' }, oldPw, newPw, h('button', { class: 'btn', onclick: async () => { + try { await api('api/password', { old: oldPw.value, new: newPw.value }); pwMsg.textContent = 'Password changed'; pwMsg.className = 'saved'; oldPw.value = newPw.value = ''; } + catch (e) { pwMsg.textContent = e.message; pwMsg.className = 'small error'; } + } }, 'Change password'), pwMsg))); + + if (me.admin) adminCard(); + + async function adminCard() { + const card = h('section', { class: 'card' }); + main.append(card); + const draw = data => { + const s = data.scan; + const scanText = s.running ? 'Scanning…' : s.error ? 'Last scan failed: ' + s.error + : s.at ? `${s.mangas} manga, ${s.chapters} chapters, ${s.pages} pages · scanned ${ago(s.at)} in ${s.ms} ms` : 'Not scanned yet'; + const rescan = full => async e => { + e.target.disabled = true; e.target.textContent = 'Scanning…'; + try { await api('api/admin/rescan', { full }); } finally { load(); } + }; + const name = h('input', { placeholder: 'user name', autocomplete: 'off' }); + const pw = h('input', { type: 'password', placeholder: 'password', autocomplete: 'new-password' }); + const isAdmin = h('input', { type: 'checkbox' }); + const msg = h('span', { class: 'small' }); + card.textContent = ''; + card.append(h('h2', {}, 'Admin'), + h('div', { class: 'field' }, h('span', {}, 'Library'), h('div', { class: 'muted small' }, s.library), h('div', {}, scanText), + h('div', { class: 'row', style: 'margin-top:8px' }, + h('button', { class: 'btn', onclick: rescan(false) }, 'Rescan'), + h('button', { class: 'btn', onclick: rescan(true), title: 'Also re-read all image sizes' }, 'Full rescan'))), + h('div', { class: 'field' }, h('span', {}, 'Users'), + h('div', { class: 'users' }, data.users.map(u => h('div', {}, + h('span', {}, u.name, u.admin ? h('span', { class: 'tag', style: 'margin-left:8px' }, 'admin') : null), + u.name === me.name ? h('span', { class: 'muted small' }, 'you') : h('button', { class: 'btn small', onclick: async () => { + if (!confirm(`Delete ${u.name} and their reading progress?`)) return; + await api('api/admin/user/delete', { name: u.name }); load(); + } }, 'Delete'))))), + h('div', { class: 'field' }, h('span', {}, 'Add a user or reset a password'), + h('div', { class: 'row' }, name, pw, h('label', { class: 'row' }, isAdmin, 'admin'), + h('button', { class: 'btn', onclick: async () => { + try { await api('api/admin/user', { name: name.value, password: pw.value, admin: isAdmin.checked }); load(); } + catch (e) { msg.textContent = e.message; msg.className = 'small error'; } + } }, 'Save user'), msg))); + }; + async function load() { draw(await api('api/admin')); } + load(); + } +})(); diff --git a/server.js b/server.js new file mode 100644 index 0000000..acdbdc2 --- /dev/null +++ b/server.js @@ -0,0 +1,377 @@ +'use strict'; +/* + * MangaParty: a no-frills, self-hosted manga reader. No dependencies, just `node server.js`. + * + * Environment: + * PORT=8080 LIBRARY=./library DATA=./data + * SIGNUP=closed|open (the very first account can always be created and becomes admin) + * SCAN_INTERVAL=10 minutes between automatic library rescans, 0 = off + * COOKIE_SECURE=1 mark the session cookie Secure (when served over HTTPS) + */ +const http = require('http'), fs = require('fs'), path = require('path'), crypto = require('crypto'); +const { scanLibrary } = require('./library'); + +const PORT = +process.env.PORT || 8080; +const LIB = path.resolve(process.env.LIBRARY || 'library'); +const DATA = path.resolve(process.env.DATA || 'data'); +const OPEN_SIGNUP = process.env.SIGNUP === 'open'; +const SCAN_MINUTES = +(process.env.SCAN_INTERVAL || 10); +const SECURE_COOKIE = process.env.COOKIE_SECURE === '1'; +const PUB = path.join(__dirname, 'public'); +const IMG = /\.(jpe?g|png|webp|gif|avif|bmp)$/i; +const MIME = { + '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', + '.json': 'application/json', '.webmanifest': 'application/manifest+json', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', + '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.avif': 'image/avif', '.bmp': 'image/bmp', +}; + +// ---------- storage: one JSON file, written atomically ---------- +fs.mkdirSync(DATA, { recursive: true }); +const DB_FILE = path.join(DATA, 'db.json'), CACHE_FILE = path.join(DATA, 'scan-cache.json'); +const db = { users: {}, sessions: {}, progress: {}, follows: {}, notes: {}, votes: {}, known: {} }; +// A corrupt db.json stops the server instead of silently starting empty and overwriting it. +if (fs.existsSync(DB_FILE)) Object.assign(db, JSON.parse(fs.readFileSync(DB_FILE, 'utf8'))); +function writeAtomic(file, text) { fs.writeFileSync(file + '.tmp', text); fs.renameSync(file + '.tmp', file); } +let saveTimer = null; +function save() { saveTimer = saveTimer || setTimeout(flush, 500); } +function flush() { clearTimeout(saveTimer); saveTimer = null; writeAtomic(DB_FILE, JSON.stringify(db)); } +for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { flush(); process.exit(0); }); + +class HttpError extends Error { constructor(code, msg) { super(msg); this.code = code; } } +function bad(code, msg) { throw new HttpError(code, msg); } + +// ---------- users & sessions ---------- +const sha = s => crypto.createHash('sha256').update(s).digest('hex'); +function hashPw(pw) { const salt = crypto.randomBytes(16).toString('hex'); return salt + ':' + crypto.scryptSync(pw, salt, 32).toString('hex'); } +function checkPw(pw, stored) { + const [salt, hex] = String(stored).split(':'), want = Buffer.from(hex || '', 'hex'), got = crypto.scryptSync(pw, salt || '', 32); + return want.length === got.length && crypto.timingSafeEqual(want, got); +} +const DUMMY_HASH = hashPw('x'); +const DEFAULTS = { startMode: 'last', lastMode: 'page', adapt: true, skipExtras: false, skipCovers: false, zoom: {}, webhook: '' }; +const settingsOf = u => ({ ...DEFAULTS, ...u.settings, zoom: { ...(u.settings && u.settings.zoom) } }); + +function createUser(name, pw, admin, replace) { + name = String(name || '').trim().toLowerCase(); pw = String(pw || ''); + if (!/^[a-z0-9_.-]{1,32}$/.test(name)) bad(400, 'User names may only use a-z, 0-9, "_", "." and "-" (max. 32).'); + if (pw.length < 4) bad(400, 'The password needs at least 4 characters.'); + const old = db.users[name]; + if (old && !replace) bad(409, 'That user name is taken.'); + db.users[name] = { settings: {}, ...old, name, hash: hashPw(pw), admin: !!admin, created: old ? old.created : Date.now() }; + save(); + return db.users[name]; +} +function startSession(res, user) { + const token = crypto.randomBytes(32).toString('base64url'); + db.sessions[sha(token)] = { u: user.name, t: Date.now() }; + save(); + res.setHeader('Set-Cookie', `sid=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${SECURE_COOKIE ? '; Secure' : ''}`); +} +const tokenOf = req => { const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || ''); return m ? m[1] : null; }; +function userOf(req) { const t = tokenOf(req), s = t && db.sessions[sha(t)]; return (s && db.users[s.u]) || null; } +for (const [k, s] of Object.entries(db.sessions)) if (Date.now() - s.t > 365 * 864e5 || !db.users[s.u]) delete db.sessions[k]; + +// ---------- library index ---------- +let lib = { mangas: [], byId: new Map() }, scanning = null, scanState = {}, scanCache = {}; +try { scanCache = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); } catch { /* first run */ } + +function rescan(full) { + scanning = scanning || (async () => { + const t0 = Date.now(); + try { + const { index, cache } = await scanLibrary(LIB, full ? {} : scanCache); + announce(index); + lib = index; scanCache = cache; + writeAtomic(CACHE_FILE, JSON.stringify(cache)); + let chapters = 0, pages = 0; + for (const m of index.mangas) for (const ch of m.chapters) { chapters++; for (const v of ch.versions) pages += v.pages.length; } + scanState = { at: Date.now(), ms: Date.now() - t0, mangas: index.mangas.length, chapters, pages, error: null }; + console.log(`[scan] ${index.mangas.length} manga, ${chapters} chapters, ${pages} pages in ${scanState.ms} ms`); + } catch (e) { + scanState = { ...scanState, at: Date.now(), error: e.message }; + console.error('[scan] failed:', e.message); + } finally { scanning = null; } + })(); + return scanning; +} + +// New chapter ids (compared with every id ever seen for that manga) notify the followers. +function announce(index) { + for (const m of index.mangas) { + const known = db.known[m.id]; + if (!known) { db.known[m.id] = m.chapters.map(c => c.id); save(); continue; } + const seen = new Set(known), fresh = m.chapters.filter(c => !seen.has(c.id)); + if (!fresh.length) continue; + db.known[m.id] = known.concat(fresh.map(c => c.id)); + for (const [name, follows] of Object.entries(db.follows)) if (follows[m.id] && db.users[name]) notify(db.users[name], m, fresh); + save(); + } +} +function notify(user, m, chapters) { + const notes = (db.notes[user.name] = db.notes[user.name] || []); + for (const ch of chapters) { + notes.push({ id: crypto.randomBytes(6).toString('hex'), t: Date.now(), m: m.id, c: ch.id, text: `${m.title} · ${ch.label}${ch.title ? ': ' + ch.title : ''}`, read: false }); + } + if (notes.length > 200) notes.splice(0, notes.length - 200); + const hook = settingsOf(user).webhook; + if (hook) { + const body = `New in ${m.title}: ${chapters.map(c => c.label + (c.title ? ' ' + c.title : '')).join(', ')}`; + fetch(hook, { method: 'POST', body, headers: { 'Content-Type': 'text/plain; charset=utf-8', Title: 'MangaParty' }, signal: AbortSignal.timeout(10000) }) + .catch(e => console.warn(`[notify] webhook of ${user.name} failed: ${e.message}`)); + } +} + +// ---------- view helpers ---------- +const url = rel => (rel ? 'lib/' + rel.split('/').map(encodeURIComponent).join('/') : null); +const mangaOf = id => lib.byId.get(String(id)) || bad(404, 'Manga not found'); +const chapterOf = (m, id) => m.chapterById.get(String(id)) || bad(404, 'Chapter not found'); +const progressOf = (u, m) => (db.progress[u.name] && db.progress[u.name][m.id]) || {}; +const unreadOf = u => (db.notes[u.name] || []).filter(n => !n.read).length; +const admin = u => u.admin || bad(403, 'Admins only'); + +// Versions sorted by the sum of all user votes; ties go to the higher "priority" in chapter.json, then folder order. +function ranked(m, ch) { + const votes = (db.votes[m.id] && db.votes[m.id][ch.id]) || {}; + return ch.versions + .map((v, i) => ({ v, i, score: Object.values(votes[v.id] || {}).reduce((a, b) => a + b, 0) })) + .sort((a, b) => b.score - a.score || (+b.v.meta.priority || 0) - (+a.v.meta.priority || 0) || a.i - b.i); +} +function versionsOf(m, ch, u) { + const votes = (db.votes[m.id] && db.votes[m.id][ch.id]) || {}; + return ranked(m, ch).map(({ v, score }) => ({ + id: v.id, source: v.source, sourceUrl: v.sourceUrl, pages: v.pages.length, added: v.added, score, my: (votes[v.id] && votes[v.id][u.name]) || 0, + })); +} +function continueOf(m, prog, s) { + const ok = c => !(s.skipExtras && c.extra); + let last = null; + for (const ch of m.chapters) { const p = prog[ch.id]; if (p && (!last || p.t > last.p.t)) last = { ch, p }; } + if (!last) { const first = m.chapters.find(ok) || m.chapters[0]; return { c: first.id, text: 'Start reading · ' + first.label }; } + if (!last.p.done) return { c: last.ch.id, v: last.p.v, text: `Continue · ${last.ch.label} · page ${last.p.p + 1}` }; + const next = m.chapters.slice(m.chapters.indexOf(last.ch) + 1).find(ok); + return next ? { c: next.id, text: 'Read next · ' + next.label } : { c: last.ch.id, v: last.p.v, text: 'Read again · ' + last.ch.label }; +} +function neighbour(m, ch, step, s) { + for (let i = m.chapters.indexOf(ch) + step; i >= 0 && i < m.chapters.length; i += step) { + const c = m.chapters[i]; + if (!(s.skipExtras && c.extra)) return { id: c.id, label: c.label, title: c.title }; + } + return null; +} + +// ---------- API ---------- +const PUBLIC = new Set(['/api/auth', '/api/login', '/api/signup']); +const routes = { + 'GET /api/auth': () => ({ firstRun: !Object.keys(db.users).length, signup: OPEN_SIGNUP }), + + 'POST /api/signup': ({ b, res }) => { + const first = !Object.keys(db.users).length; + if (!first && !OPEN_SIGNUP) bad(403, 'Sign-up is closed. Ask an admin for an account.'); + startSession(res, createUser(b.name, b.password, first)); + return { ok: true }; + }, + 'POST /api/login': ({ b, res }) => { + const user = db.users[String(b.name || '').trim().toLowerCase()], pw = String(b.password || ''); + if (!checkPw(pw, user ? user.hash : DUMMY_HASH) || !user) bad(403, 'Wrong user name or password.'); + startSession(res, user); + return { ok: true }; + }, + 'POST /api/logout': ({ req, res }) => { + delete db.sessions[sha(tokenOf(req))]; + save(); + res.setHeader('Set-Cookie', 'sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'); + return { ok: true }; + }, + 'GET /api/me': ({ u }) => ({ name: u.name, admin: !!u.admin, settings: settingsOf(u), unread: unreadOf(u) }), + + 'POST /api/settings': ({ u, b }) => { + if (typeof b.webhook === 'string' && b.webhook.trim() && !/^https?:\/\/\S+$/i.test(b.webhook.trim())) bad(400, 'The webhook must be an http(s) URL.'); + const s = (u.settings = u.settings || {}); + if (['last', 'flow', 'page', 'book'].includes(b.startMode)) s.startMode = b.startMode; + if (['flow', 'page', 'book'].includes(b.lastMode)) s.lastMode = b.lastMode; + for (const k of ['adapt', 'skipExtras', 'skipCovers']) if (typeof b[k] === 'boolean') s[k] = b[k]; + if (b.zoom && typeof b.zoom === 'object') { + s.zoom = {}; + for (const k of ['flow', 'page', 'book', 'strip']) if (+b.zoom[k] > 0) s.zoom[k] = Math.min(8, Math.max(0.1, +b.zoom[k])); + } + if (typeof b.webhook === 'string') s.webhook = b.webhook.trim().slice(0, 500); + save(); + return { settings: settingsOf(u) }; + }, + 'POST /api/password': ({ u, b }) => { + if (!checkPw(String(b.old || ''), u.hash)) bad(403, 'The current password is wrong.'); + createUser(u.name, b.new, u.admin, true); + return { ok: true }; + }, + + 'GET /api/library': ({ u }) => { + const prog = db.progress[u.name] || {}, fol = db.follows[u.name] || {}, notes = (db.notes[u.name] || []).filter(n => !n.read); + return { + scanning: !!scanning && !lib.mangas.length, + mangas: lib.mangas.map(m => { + const p = prog[m.id] || {}; + return { + id: m.id, title: m.title, cover: url(m.cover), direction: m.direction, chapters: m.chapters.length, updated: m.updated, + read: m.chapters.filter(c => p[c.id] && p[c.id].done).length, + lastRead: Object.values(p).reduce((t, x) => Math.max(t, x.t || 0), 0), + following: !!fol[m.id], unread: notes.filter(n => n.m === m.id).length, + }; + }), + }; + }, + + 'GET /api/manga': ({ u, q }) => { + const m = mangaOf(q.get('m')), prog = progressOf(u, m); + return { + id: m.id, title: m.title, meta: m.meta, cover: url(m.cover), direction: m.direction, + following: !!(db.follows[u.name] && db.follows[u.name][m.id]), + books: m.books.map(b => ({ key: b.key, label: b.label, title: b.title, extra: b.extra })), + chapters: m.chapters.map(ch => ({ + id: ch.id, book: ch.book, label: ch.label, title: ch.title, extra: ch.extra, progress: prog[ch.id] || null, versions: versionsOf(m, ch, u), + })), + continue: continueOf(m, prog, settingsOf(u)), + }; + }, + + 'GET /api/chapter': ({ u, q }) => { + const m = mangaOf(q.get('m')), ch = chapterOf(m, q.get('c')), s = settingsOf(u), prog = progressOf(u, m); + const v = ch.versions.find(x => x.id === q.get('v')) || ranked(m, ch)[0].v; + // Opening a chapter clears its "new chapter" notification. + let seen = false; + for (const n of db.notes[u.name] || []) if (!n.read && n.m === m.id && n.c === ch.id) seen = n.read = true; + if (seen) save(); + return { + manga: { id: m.id, title: m.title }, + chapter: { id: ch.id, label: ch.label, title: ch.title, extra: ch.extra }, + version: v.id, versions: versionsOf(m, ch, u), direction: v.direction, + pages: v.pages.map(p => ({ u: url(v.rel + '/' + p.f), w: p.w, h: p.h, t: p.t, r: p.r })), + prev: neighbour(m, ch, -1, s), next: neighbour(m, ch, 1, s), + progress: prog[ch.id] || null, + // for the chapter picker: the whole manga across all books + books: m.books.map(b => ({ key: b.key, label: b.label, title: b.title })), + chapters: m.chapters.map(c => ({ id: c.id, book: c.book, label: c.label, title: c.title, extra: c.extra, done: !!(prog[c.id] && prog[c.id].done) })), + }; + }, + + 'POST /api/progress': ({ u, b }) => { + const m = mangaOf(b.m), ch = chapterOf(m, b.c); + const all = (db.progress[u.name] = db.progress[u.name] || {}), mp = (all[m.id] = all[m.id] || {}), old = mp[ch.id] || {}; + mp[ch.id] = { + v: String(b.v || ''), p: Math.max(0, b.p | 0), n: Math.max(0, b.n | 0), t: Date.now(), done: !!(old.done || b.done), + shift: Array.isArray(b.shift) ? b.shift.filter(Number.isInteger).slice(0, 1000) : old.shift || [], + }; + save(); + return { ok: true }; + }, + 'POST /api/unread': ({ u, b }) => { + const m = mangaOf(b.m), mp = db.progress[u.name] && db.progress[u.name][m.id]; + if (mp) delete mp[chapterOf(m, b.c).id]; + save(); + return { ok: true }; + }, + 'POST /api/vote': ({ u, b }) => { + const m = mangaOf(b.m), ch = chapterOf(m, b.c), v = ch.versions.find(x => x.id === b.v) || bad(404, 'Version not found'); + const byChapter = (db.votes[m.id] = db.votes[m.id] || {}), byVersion = (byChapter[ch.id] = byChapter[ch.id] || {}); + const votes = (byVersion[v.id] = byVersion[v.id] || {}), value = Math.sign(+b.value || 0); + if (value) votes[u.name] = value; + else delete votes[u.name]; + save(); + return { versions: versionsOf(m, ch, u) }; + }, + 'POST /api/follow': ({ u, b }) => { + const m = mangaOf(b.m), f = (db.follows[u.name] = db.follows[u.name] || {}); + if (b.on) f[m.id] = true; + else delete f[m.id]; + save(); + return { following: !!f[m.id] }; + }, + 'GET /api/notifications': ({ u }) => ({ items: (db.notes[u.name] || []).slice().reverse() }), + 'POST /api/notifications/read': ({ u, b }) => { + for (const n of db.notes[u.name] || []) if (b.all || n.id === b.id) n.read = true; + save(); + return { unread: unreadOf(u) }; + }, + + 'GET /api/admin': ({ u }) => { + admin(u); + return { users: Object.values(db.users).map(x => ({ name: x.name, admin: !!x.admin, created: x.created })), scan: { ...scanState, running: !!scanning, library: LIB } }; + }, + 'POST /api/admin/rescan': async ({ u, b }) => { + admin(u); + await rescan(!!b.full); + return { scan: { ...scanState, running: false, library: LIB } }; + }, + 'POST /api/admin/user': ({ u, b }) => { + admin(u); + const name = String(b.name || '').trim().toLowerCase(); + createUser(name, b.password, name === u.name || !!b.admin, true); + return { ok: true }; + }, + 'POST /api/admin/user/delete': ({ u, b }) => { + admin(u); + const name = String(b.name || ''); + if (name === u.name) bad(400, "You can't delete your own account."); + delete db.users[name]; delete db.progress[name]; delete db.follows[name]; delete db.notes[name]; + for (const [k, s] of Object.entries(db.sessions)) if (s.u === name) delete db.sessions[k]; + save(); + return { ok: true }; + }, +}; + +// ---------- HTTP ---------- +function sendJson(res, code, obj) { + res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify(obj)); +} +async function readBody(req) { + let s = ''; + for await (const c of req) { s += c; if (s.length > 1e6) bad(413, 'Request too large'); } + try { return s ? JSON.parse(s) : {}; } catch { bad(400, 'Invalid JSON'); } +} +async function handleApi(req, res, u, route, q) { + let b = {}; + if (req.method === 'POST') { + // JSON-only + same-site: plain HTML forms from other sites can't reach the API. + if (!/^application\/json/.test(req.headers['content-type'] || '') || req.headers['sec-fetch-site'] === 'cross-site') bad(403, 'Bad request origin'); + b = (await readBody(req)) || {}; + } + sendJson(res, 200, await route({ req, res, u, b, q })); +} +function serveFile(req, res, root, rel, cacheControl, only) { + let file; + try { file = path.resolve(root, decodeURIComponent(rel)); } catch { return sendJson(res, 400, { error: 'Bad path' }); } + if (!file.startsWith(root + path.sep) || (only && !only.test(file))) return sendJson(res, 404, { error: 'Not found' }); + fs.stat(file, (err, st) => { + if (err || !st.isFile()) return sendJson(res, 404, { error: 'Not found' }); + const etag = `"${st.size.toString(36)}-${Math.round(st.mtimeMs).toString(36)}"`; + if (req.headers['if-none-match'] === etag) { res.writeHead(304, { ETag: etag, 'Cache-Control': cacheControl }); return res.end(); } + res.writeHead(200, { 'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream', 'Content-Length': st.size, ETag: etag, 'Cache-Control': cacheControl }); + if (req.method === 'HEAD') return res.end(); + fs.createReadStream(file).on('error', () => res.destroy()).pipe(res); + }); +} + +http.createServer(async (req, res) => { + try { + const { pathname, searchParams } = new URL(req.url, 'http://localhost'); + const u = userOf(req); + if (pathname.startsWith('/api/')) { + const route = routes[req.method + ' ' + pathname]; + if (!route) bad(404, 'Unknown endpoint'); + if (!u && !PUBLIC.has(pathname)) bad(401, 'Please log in'); + return await handleApi(req, res, u, route, searchParams); + } + if (req.method !== 'GET' && req.method !== 'HEAD') bad(405, 'Method not allowed'); + if (pathname.startsWith('/lib/')) { + if (!u) bad(401, 'Please log in'); + return serveFile(req, res, LIB, pathname.slice(5), 'private, max-age=86400', IMG); + } + serveFile(req, res, PUB, pathname === '/' ? 'index.html' : pathname.slice(1), 'no-cache'); + } catch (e) { + if (!(e instanceof HttpError)) console.error(e); + if (!res.headersSent) sendJson(res, e.code || 500, { error: e instanceof HttpError ? e.message : 'Internal error' }); + } +}).listen(PORT, () => console.log(`MangaParty on http://localhost:${PORT} (library: ${LIB}, data: ${DATA})`)); + +rescan(); +if (SCAN_MINUTES > 0) setInterval(() => rescan(), SCAN_MINUTES * 60000).unref(); diff --git a/tools/make-dummy.js b/tools/make-dummy.js new file mode 100644 index 0000000..9c215e2 --- /dev/null +++ b/tools/make-dummy.js @@ -0,0 +1,215 @@ +'use strict'; +// Builds a demo library that exercises every part of the folder format (see README.md). +// Usage: node tools/make-dummy.js [libraryDir] (default: ./library) +// Only the demo manga folders listed below are (re)created. Nothing else in the target is touched. +const fs = require('fs'), path = require('path'); +const { Canvas, hsl } = require('./png'); + +const OUT = path.resolve(process.argv[2] || 'library'); +const PW = 900, PH = 1300; // single page size in px +const WHITE = [255, 255, 255]; +const SUFFIX = { C: '_cover', D: '_double', R: '_rot90', E: '_extra', S: '', W: '' }; + +// Page kinds: C chapter cover, S single, D double (tagged), W wide but untagged, R stored rotated, E extra +const LIBRARY = [ + { + dir: 'Moon Garden', hue: 205, type: 'MANGA', + meta: { + title: 'Moon Garden', altTitles: ['Tsuki no Niwa'], authors: ['Aki Tanaka'], status: 'ongoing', year: 2021, + tags: ['Fantasy', 'Slice of Life', 'Demo'], direction: 'rtl', + description: 'Demo manga (right to left) that uses every feature of the folder format: chapter covers, double pages, a page stored sideways, extra pages, an in-between chapter (2.5), an extra chapter, an extra book and two versions of chapter 2 from different sources.', + sources: [ + { name: 'MangaDex', url: 'https://mangadex.org/title/00000000-0000-0000-0000-000000000000' }, + { name: 'Official Web', url: 'https://example.com/moon-garden' }, + ], + }, + books: [ + { dir: 'v01 Spring', meta: { title: 'Spring' }, cover: true, chapters: [ + { dir: 'c0001 The Seed', meta: { title: 'The Seed', source: { name: 'MangaDex', url: 'https://mangadex.org/chapter/demo-1' }, added: '2024-01-10' }, pages: 'C S S S S D S S S S S S' }, + { dir: 'c0002 Roots [MangaDex]', meta: { title: 'Roots', source: { name: 'MangaDex', url: 'https://mangadex.org/chapter/demo-2' }, added: '2024-01-24' }, pages: 'C S S S S S S D S S S S S S' }, + { dir: 'c0002 Roots [Official Web]', meta: { title: 'Roots', source: { name: 'Official Web', url: 'https://example.com/moon-garden/2' }, added: '2024-01-25' }, pages: 'C S S S S D S S S S S', hue: 40 }, + { dir: 'c0002.5 Tea Time', meta: { title: 'Tea Time (omake)', added: '2024-02-01' }, pages: 'S S S S' }, + { dir: 'x01 Character Sheets', meta: { title: 'Character Sheets', added: '2024-02-02' }, pages: 'E E E' }, + ] }, + { dir: 'v02 Summer', meta: { title: 'Summer' }, cover: true, chapters: [ + { dir: 'c0003 The Tide', meta: { title: 'The Tide', added: '2024-03-01' }, pages: 'C S S D D S S S R S S S S E E' }, + { dir: 'c0004 Storm', meta: { title: 'Storm', added: '2024-03-15' }, pages: 'S S S S S S S S W S S S S S S S S S' }, + { dir: 'c0005 Harvest', meta: { title: 'Harvest', added: '2024-04-01' }, pages: 'C S S S S S S S S S' }, + ] }, + { dir: 'x01 Specials', meta: { title: 'Specials' }, chapters: [ + { dir: 'x01 Artbook', meta: { title: 'Artbook', added: '2024-05-01' }, pages: 'C S D S S' }, + ] }, + ], + }, + { + dir: 'Iron Harbor', hue: 20, type: 'COMIC', + meta: { + title: 'Iron Harbor', authors: ['J. Smith', 'R. Lee'], status: 'completed', year: 2019, tags: ['Action', 'Demo'], direction: 'ltr', + description: 'Demo western comic that reads left to right.', + sources: [{ name: 'Comic Vault', url: 'https://example.com/iron-harbor' }], + }, + books: [ + { dir: 'v01 Book One', meta: { title: 'Book One' }, chapters: [ + { dir: 'c0001 Arrival', meta: { title: 'Arrival', added: '2023-06-01' }, pages: 'C S S S D S S S S' }, + { dir: 'c0002 Rust', meta: { title: 'Rust', added: '2023-06-08' }, pages: 'S S S S S S S S S S S S' }, + { dir: 'c0003 Departure', meta: { title: 'Departure', added: '2023-06-15' }, pages: 'C S S D S S S S E' }, + ] }, + ], + }, + { + dir: 'Skyline Tower', hue: 280, type: 'WEBTOON', + meta: { + title: 'Skyline Tower', authors: ['Min-jun Park'], status: 'ongoing', year: 2023, tags: ['Drama', 'Webtoon', 'Demo'], direction: 'ttb', + description: 'Demo webtoon: every chapter is one long vertical strip cut into slices. The numbers on the left edge are the pixel position inside the strip, so gaps between slices are easy to spot.', + sources: [{ name: 'Webtoon Site', url: 'https://example.com/skyline' }, { name: 'Fan TL', url: 'https://example.org/skyline' }], + }, + books: [ + { dir: 'v01 Season 1', meta: { title: 'Season 1' }, chapters: [ + { dir: 'c0001 Episode 1', meta: { title: 'Ground Floor', added: '2024-06-01' }, strip: [1800, 2000, 1600, 2200, 1900, 2100, 1700, 2000] }, + { dir: 'c0002 Episode 2 [Official]', meta: { title: 'Elevator', source: { name: 'Webtoon Site', url: 'https://example.com/skyline/2' }, added: '2024-06-08', priority: 1 }, strip: Array(10).fill(1900) }, + { dir: 'c0002 Episode 2 [Fan TL]', meta: { title: 'Elevator', source: { name: 'Fan TL', url: 'https://example.org/skyline/2' }, added: '2024-06-09' }, strip: Array(9).fill(2100), hue: 330 }, + { dir: 'c0003 Episode 3', meta: { title: 'Rooftop', added: '2024-06-15' }, strip: [2400, 1500, 2000, 1800, 2200, 1600, 2000] }, + ] }, + { dir: 'v02 Season 2', meta: { title: 'Season 2' }, chapters: [ + { dir: 'c0004 Episode 4', meta: { title: 'Basement', added: '2024-09-01' }, strip: [2000, 2000, 2000, 2000, 2000, 2000] }, + ] }, + ], + }, + { + // No manga.json / book.json / chapter.json at all: everything comes from folder names. + dir: 'Blank Slate', hue: 120, + books: [{ dir: 'v01', chapters: [{ dir: 'c0001', pages: 'S S S S S S' }, { dir: 'c0002', pages: 'S S S S S S' }] }], + }, + { + dir: 'Double Trouble', hue: 330, type: 'BOOK MODE TEST', + meta: { + title: 'Double Trouble', authors: ['Demo'], status: 'completed', direction: 'rtl', tags: ['Demo', 'Book mode test'], + description: 'Book mode torture test: odd runs of single pages between double pages and back-to-back doubles.', + }, + books: [{ dir: 'v01', chapters: [ + { dir: 'c0001 Odd Runs', meta: { title: 'Odd Runs' }, pages: 'C S S S D S D D S S S S D S' }, + { dir: 'c0002 All Doubles', meta: { title: 'All Doubles' }, pages: 'D D D' }, + ] }], + }, +]; + +function wrap(s, max = 10) { + const out = []; + for (const w of String(s || '').split(/\s+/).filter(Boolean)) { + const last = out[out.length - 1]; + if (last && (last + ' ' + w).length <= max) out[out.length - 1] = last + ' ' + w; + else out.push(w); + } + return out; +} + +function cover(hue, lines, sub) { + const c = new Canvas(PW, PH, hsl(hue, 0.55, 0.3)); + for (let i = 0; i < 12; i++) c.rect(0, PH * 0.64 + i * 36, PW, 14, hsl(hue + 25, 0.5, 0.2 + i * 0.012)); + c.frame(30, 30, PW - 60, PH - 60, 6, hsl(hue, 0.4, 0.75)); + let y = 150; + for (const line of lines) { + const size = Math.min(14, Math.floor((PW - 140) / (6 * line.length))); + c.text(line, PW / 2, y, size, WHITE, 'center'); + y += size * 7 + 40; + } + c.text(sub, PW / 2, PH * 0.64 - 90, 6, hsl(hue, 0.3, 0.85), 'center'); + return c; +} + +function panels(n, wide, m, top, iw, ih, g) { + const R = (fx, fy, fw, fh) => [m + fx * iw, top + fy * ih, fw * iw - g, fh * ih - g]; + if (wide) return [R(0, 0, 0.6, 1), R(0.6, 0, 0.4, 0.5), R(0.6, 0.5, 0.4, 0.5)]; + return [ + [R(0, 0, 1, 0.4), R(0, 0.4, 0.5, 0.6), R(0.5, 0.4, 0.5, 0.6)], + [R(0, 0, 1, 1 / 3), R(0, 1 / 3, 1, 1 / 3), R(0, 2 / 3, 1, 1 / 3)], + [R(0, 0, 0.55, 1), R(0.55, 0, 0.45, 0.5), R(0.55, 0.5, 0.45, 0.5)], + ][n % 3]; +} + +function paper(w, n, ctx, wide) { + const ink = hsl(ctx.hue, 0.4, 0.2), c = new Canvas(w, PH, hsl(ctx.hue, 0.3, 0.94)); + const m = 50, top = 90, g = 20; + panels(n, wide, m, top, w - 2 * m, PH - 180, g).forEach(([x, y, pw, ph], i) => { + c.rect(x, y, pw, ph, hsl(ctx.hue + 25 * i, 0.35, 0.84)); + c.frame(x, y, pw, ph, 7, ink); + }); + if (wide) for (let y = top; y < PH - 90; y += 40) c.rect(w / 2 - 2, y, 4, 20, ink); // the fold + c.text(ctx.manga, m, 34, 4, ink); + c.text(ctx.label, w - m, 34, 4, ink, 'right'); + c.text(ctx.dir === 'ltr' ? 'READ LEFT TO RIGHT >>>' : '<<< READ RIGHT TO LEFT', w / 2, PH - 60, 4, ink, 'center'); + c.label(String(n), w / 2, PH / 2 - 120, 26, ink, WHITE); + return c; +} + +function pageImage(kind, n, ctx) { + if (kind === 'C') return cover(ctx.hue, [ctx.label, ...wrap(ctx.title)], 'CHAPTER COVER - PAGE ' + n); + const wide = 'DWR'.includes(kind), w = wide ? PW * 2 : PW; + const c = paper(w, n, ctx, wide); + const band = hsl(ctx.hue, 0.5, 0.3); + if (kind === 'D') c.label('DOUBLE PAGE', w / 2, PH / 2 + 150, 8, WHITE, band); + if (kind === 'W') c.label('WIDE - NOT TAGGED', w / 2, PH / 2 + 150, 8, WHITE, band); + if (kind === 'E') c.label('EXTRA PAGE', w / 2, PH / 2 + 150, 9, WHITE, [190, 40, 50]); + if (kind === 'R') { + c.label('STORED SIDEWAYS', w / 2, PH / 2 + 150, 8, WHITE, [30, 120, 60]); + return c.rotateCCW(); // "_rot90" = the viewer must turn it 90 degrees clockwise + } + return c; +} + +function strip(heights, ctx) { + const W = 800, total = heights.reduce((a, b) => a + b, 0), ink = hsl(ctx.hue, 0.5, 0.18); + const c = new Canvas(W, total); + for (let y = 0; y < total; y += 10) c.rect(0, y, W, 10, hsl(ctx.hue + (y / total) * 140, 0.55, 0.86 - 0.22 * Math.sin((y / total) * Math.PI))); + for (let y = 500; y < total; y += 500) { + c.rect(0, y, 40, 4, ink); c.rect(W - 40, y, 40, 4, ink); + c.text(String(y), 48, y - 8, 3, ink); + } + for (let y = 560, s = 1; y < total - 900; s++) { + const ph = 520 + (s % 3) * 160, x = s % 2 ? 70 : 190; + c.rect(x + 14, y + 14, 540, ph, hsl(ctx.hue, 0.3, 0.3)); + c.rect(x, y, 540, ph, WHITE); + c.frame(x, y, 540, ph, 8, ink); + c.text('SCENE ' + s, x + 270, y + ph / 2 - 35, 10, ink, 'center'); + y += ph + 380; + } + c.label(ctx.manga, W / 2, 110, 7, ink, WHITE); + c.text(ctx.label + ' ' + ctx.title, W / 2, 260, 5, ink, 'center'); + c.label('TO BE CONTINUED', W / 2, total - 380, 6, WHITE, ink); + let y0 = 0; + return heights.map(hh => { const s = c.crop(y0, hh); y0 += hh; return s; }); +} + +const pad = n => String(n).padStart(4, '0'); +const json = (file, obj) => fs.writeFileSync(file, JSON.stringify(obj, null, 2) + '\n'); +let files = 0; +const png = (file, canvas) => { fs.writeFileSync(file, canvas.png()); files++; }; + +const t0 = Date.now(); +for (const m of LIBRARY) { + const mDir = path.join(OUT, m.dir), title = (m.meta && m.meta.title) || m.dir, dir = (m.meta && m.meta.direction) || 'rtl'; + fs.rmSync(mDir, { recursive: true, force: true }); + fs.mkdirSync(mDir, { recursive: true }); + if (m.meta) { + json(path.join(mDir, 'manga.json'), m.meta); + png(path.join(mDir, 'cover.png'), cover(m.hue, wrap(title), m.type)); + } + let hue = m.hue; + for (const b of m.books) { + const bDir = path.join(mDir, b.dir); + fs.mkdirSync(bDir); + if (b.meta) json(path.join(bDir, 'book.json'), b.meta); + if (b.cover) png(path.join(bDir, 'cover.png'), cover(m.hue + 30, [...wrap(title), b.meta.title], b.dir.split(' ')[0].toUpperCase())); + for (const ch of b.chapters) { + const cDir = path.join(bDir, ch.dir), k = /^(x?)[a-z]*([\d.]+)/i.exec(ch.dir); + fs.mkdirSync(cDir); + if (ch.meta) json(path.join(cDir, 'chapter.json'), ch.meta); + hue += 18; + const ctx = { manga: title, dir, hue: ch.hue !== undefined ? ch.hue : hue, label: (k[1] ? 'EXTRA ' : 'CH ') + parseFloat(k[2]), title: (ch.meta && ch.meta.title) || '' }; + if (ch.strip) strip(ch.strip, ctx).forEach((img, i) => png(path.join(cDir, pad(i + 1) + '.png'), img)); + else ch.pages.split(' ').forEach((kind, i) => png(path.join(cDir, pad(i + 1) + SUFFIX[kind] + '.png'), pageImage(kind, i + 1, ctx))); + } + } + console.log('created', m.dir); +} +console.log(`${files} images in ${((Date.now() - t0) / 1000).toFixed(1)}s -> ${OUT}`); diff --git a/tools/png.js b/tools/png.js new file mode 100644 index 0000000..38bda81 --- /dev/null +++ b/tools/png.js @@ -0,0 +1,114 @@ +'use strict'; +// Minimal RGB canvas + PNG encoder + 5x7 bitmap font. Only used to generate the demo library. +const zlib = require('zlib'); + +// prettier-ignore +const FONT = { + A: '01110 10001 10001 11111 10001 10001 10001', B: '11110 10001 10001 11110 10001 10001 11110', + C: '01110 10001 10000 10000 10000 10001 01110', D: '11110 10001 10001 10001 10001 10001 11110', + E: '11111 10000 10000 11110 10000 10000 11111', F: '11111 10000 10000 11110 10000 10000 10000', + G: '01110 10001 10000 10111 10001 10001 01111', H: '10001 10001 10001 11111 10001 10001 10001', + I: '01110 00100 00100 00100 00100 00100 01110', J: '00111 00010 00010 00010 00010 10010 01100', + K: '10001 10010 10100 11000 10100 10010 10001', L: '10000 10000 10000 10000 10000 10000 11111', + M: '10001 11011 10101 10101 10001 10001 10001', N: '10001 10001 11001 10101 10011 10001 10001', + O: '01110 10001 10001 10001 10001 10001 01110', P: '11110 10001 10001 11110 10000 10000 10000', + Q: '01110 10001 10001 10001 10101 10010 01101', R: '11110 10001 10001 11110 10100 10010 10001', + S: '01111 10000 10000 01110 00001 00001 11110', T: '11111 00100 00100 00100 00100 00100 00100', + U: '10001 10001 10001 10001 10001 10001 01110', V: '10001 10001 10001 10001 10001 01010 00100', + W: '10001 10001 10001 10101 10101 10101 01010', X: '10001 10001 01010 00100 01010 10001 10001', + Y: '10001 10001 01010 00100 00100 00100 00100', Z: '11111 00001 00010 00100 01000 10000 11111', + 0: '01110 10001 10011 10101 11001 10001 01110', 1: '00100 01100 00100 00100 00100 00100 01110', + 2: '01110 10001 00001 00010 00100 01000 11111', 3: '11111 00010 00100 00010 00001 10001 01110', + 4: '00010 00110 01010 10010 11111 00010 00010', 5: '11111 10000 11110 00001 00001 10001 01110', + 6: '00110 01000 10000 11110 10001 10001 01110', 7: '11111 00001 00010 00100 01000 01000 01000', + 8: '01110 10001 10001 01110 10001 10001 01110', 9: '01110 10001 10001 01111 00001 00010 01100', + '.': '00000 00000 00000 00000 00000 01100 01100', '-': '00000 00000 00000 11111 00000 00000 00000', + ':': '00000 01100 01100 00000 01100 01100 00000', '/': '00000 00001 00010 00100 01000 10000 00000', + '#': '01010 01010 11111 01010 11111 01010 01010', '<': '00010 00100 01000 10000 01000 00100 00010', + '>': '01000 00100 00010 00001 00010 00100 01000', '(': '00010 00100 01000 01000 01000 00100 00010', + ')': '01000 00100 00010 00010 00010 00100 01000', '!': '00100 00100 00100 00100 00100 00000 00100', + '?': '01110 10001 00001 00010 00100 00000 00100', ',': '00000 00000 00000 00000 01100 00100 01000', + "'": '00100 00100 01000 00000 00000 00000 00000', '&': '01100 10010 10100 01000 10101 10010 01101', + '+': '00000 00100 00100 11111 00100 00100 00000', '=': '00000 00000 11111 00000 11111 00000 00000', +}; + +const CRC = new Int32Array(256).map((_, n) => { + for (let k = 0; k < 8; k++) n = n & 1 ? 0xedb88320 ^ (n >>> 1) : n >>> 1; + return n; +}); +function crc32(buf) { + let c = -1; + for (let i = 0; i < buf.length; i++) c = CRC[(c ^ buf[i]) & 255] ^ (c >>> 8); + return (c ^ -1) >>> 0; +} +function chunk(type, data) { + const head = Buffer.alloc(4), crc = Buffer.alloc(4), body = Buffer.concat([Buffer.from(type, 'ascii'), data]); + head.writeUInt32BE(data.length); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([head, body, crc]); +} + +function hsl(h, s, l) { + h = (((h % 360) + 360) % 360) / 30; + const a = s * Math.min(l, 1 - l); + const f = n => { const k = (n + h) % 12; return Math.round(255 * (l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)))); }; + return [f(0), f(8), f(4)]; +} + +class Canvas { + constructor(w, h, bg) { + this.w = w; this.h = h; this.px = Buffer.alloc(w * h * 3); + if (bg) this.rect(0, 0, w, h, bg); + } + rect(x, y, w, h, color) { + const x0 = Math.max(0, Math.round(x)), y0 = Math.max(0, Math.round(y)); + const x1 = Math.min(this.w, Math.round(x + w)), y1 = Math.min(this.h, Math.round(y + h)); + if (x1 <= x0 || y1 <= y0) return; + const pat = Buffer.from(color); + for (let r = y0; r < y1; r++) this.px.fill(pat, (r * this.w + x0) * 3, (r * this.w + x1) * 3); + } + frame(x, y, w, h, t, color) { + this.rect(x, y, w, t, color); this.rect(x, y + h - t, w, t, color); + this.rect(x, y, t, h, color); this.rect(x + w - t, y, t, h, color); + } + textWidth(s, size) { return String(s).length * 6 * size - size; } + text(s, x, y, size, color, align) { + s = String(s).toUpperCase(); + if (align === 'center') x -= this.textWidth(s, size) / 2; + if (align === 'right') x -= this.textWidth(s, size); + for (const ch of s) { + const g = FONT[ch]; + if (g) g.split(' ').forEach((row, ry) => { + for (let rx = 0; rx < 5; rx++) if (row[rx] === '1') this.rect(x + rx * size, y + ry * size, size, size, color); + }); + x += 6 * size; + } + } + // Text on a padded box, handy for labels that must stay readable on any background. + label(s, x, y, size, color, box, align = 'center') { + const w = this.textWidth(s, size), pad = size * 2; + const left = align === 'center' ? x - w / 2 : align === 'right' ? x - w : x; + this.rect(left - pad, y - pad, w + 2 * pad, 7 * size + 2 * pad, box); + this.text(s, left, y, size, color); + } + crop(y, h) { + const c = new Canvas(this.w, h); + this.px.copy(c.px, 0, y * this.w * 3, (y + h) * this.w * 3); + return c; + } + // Rotate 90° counter-clockwise (so a viewer must rotate it 90° clockwise to see it upright). + rotateCCW() { + const c = new Canvas(this.h, this.w); + for (let y = 0; y < this.h; y++) + for (let x = 0; x < this.w; x++) this.px.copy(c.px, ((this.w - 1 - x) * c.w + y) * 3, (y * this.w + x) * 3, (y * this.w + x) * 3 + 3); + return c; + } + png() { + const ihdr = Buffer.alloc(13), stride = this.w * 3, raw = Buffer.alloc((stride + 1) * this.h); + ihdr.writeUInt32BE(this.w, 0); ihdr.writeUInt32BE(this.h, 4); ihdr[8] = 8; ihdr[9] = 2; // 8 bit RGB + for (let y = 0; y < this.h; y++) this.px.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride); + return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]); + } +} + +module.exports = { Canvas, hsl };