Compare commits

...
Author SHA1 Message Date
MaashkinoozheandClaude Opus 5.5 fe8956087e Reading activity grid in Settings: months, weeks, days, hours with drill-down
Active reader time is now kept per user in small UTC buckets (6 minutes for
120 days, then hours, dropped after three years), seeded once from the visits
the clock still remembers. Settings -> Reading activity shows them as a grid
whose cells glow with their total and carry a start-to-end strip; month/week
cells open their days, days open their hours. Finished chapters come from
progress and work without the clock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:41:44 +01:00
MaashkinoozheandClaude Opus 5.5 c760141636 Continue row: a run of more than N chapters in order also marks a reread
A done chapter after the head now counts as left again when it was finished
more than rereadDays before the head (default 7) or before a run of more
than rereadChapters chapters read in order up to the head (default 5).
Both are user settings under Library page; 0 turns a rule off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:47:45 +01:00
MaashkinoozheandClaude Opus 5.5 9b529d7709 Continue row: only manga with something left after the last chapter read
GET /api/library returns `left` per manga: chapters after the newest saved
position that are unfinished, unread, or were last read more than 7 days
before it (a reread from the start). The continue row lists manga with
left > 0, so caught-up series drop out until a new chapter arrives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:27:02 +01:00
Maashkinoozhe dddf9bb4ac Merge remote-tracking branch 'origin/main' into feature/stale-reading-session 2026-10-31 12:21:24 +01:00
MaashkinoozheandClaude Opus 5.5 618992ec14 Reader: stale reading sessions never move "Continue" back
A reader tab left open on one device (or restored by the browser) used to
save its old chapter as the newest position as soon as it was shown again.
Progress saves now carry the tab id and the newest progress time the tab
knows (`seen`); the server refuses a save when another tab or device saved
a newer position since. The reader then asks: jump to the newest position,
or keep reading here (which makes this chapter the head again). It checks on
open, when the tab becomes visible, and on every save. Reader links carry
`at` and the reader keeps it in the address bar, so reloads and restored
tabs still know what they had seen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:49:52 +01:00
MaashkinoozheandClaude Opus 5.5 e3e57b60bd Reader settings per login session
Each sign-in keeps its own reader mode, zoom, page tone and skipping once
it changes one; sessions that never changed follow the latest set, and a
new sign-in starts from the session used last. Settings -> Reading shows
every session's state, lets a session follow the latest again or become
the latest, and turns the feature off for one shared set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:45:44 +01:00
MaashkinoozheandClaude Opus 5.5 03767d5704 Scraper: stop walking the whole library once a minute for status polls
The reader polled /api/state every 15 s; each call rebuilt the scraper's library
summary when it was over 60 s old, reading every chapter.json (on the NAS about
5,300 opens / 16,000 NFS calls and 1.3 s CPU per walk, once a minute, plus 115 KB
per poll). The reader now polls GET /api/revision (falls back to /api/state on an
older scraper), and the summary is rebuilt after a library change or after 15
minutes. Release-date writes invalidate it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:10:38 +01:00
MaashkinoozheandClaude Opus 5.5 b328ad98fa Moments test waits for the server to exit before removing its folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:52:29 +01:00
MaashkinoozheandClaude Opus 5.5 d7d3bf5b2b Read-ahead per reader; compose passes READ_AHEAD and PERF_TRACE; report fixes
Each user gets an own read-ahead queue, served in turn, so two people reading
at once no longer cancel each other's chapter. The report rounds phase times
and measures the quiet time before a slow storage probe correctly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:51:19 +01:00
MaashkinoozheandClaude Opus 5.5 896726a51b Performance trace, load test and chapter read-ahead
A switchable trace (DATA/perf/trace.on or PERF_TRACE=1) records every request's
phases (stat, open, first read, disk vs network wait), browser resource timings,
page-turn waits and flow stalls, a cold storage probe, event-loop lag, scans and
scraper calls as JSONL. tools/perf-report.js says which phase slow loads spend
their time in; tools/perf-bench.js simulates readers.

READ_AHEAD (default on) reads an opened chapter's pages, then the next chapter's,
into the OS cache so a NAS's cold reads stop reaching the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:28:50 +01:00
MaashkinoozheandClaude Opus 5.5 ea8a5b40e7 .dockerignore: let moments.js into the image; guard test checks the allowlist too
f38f218 failed to build: .dockerignore is an allowlist and kept
moments.js out of the build context. Verified by building the image and
starting the reader from it (health ok).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:47:26 +01:00
MaashkinoozheandClaude Opus 5.5 f38f218ae0 Dockerfile: copy moments.js; guard test for required top-level modules
The 5497e8b image lacked moments.js, so the reader crashed at start on
docker-01. test/dockerfile.test.js follows server.js requires and fails
when a top-level module is not copied into the image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:56:07 +01:00
MaashkinoozheandClaude Opus 5.5 5497e8bfbc Moments across up to four pages: stacked in Flow, side by side in Page/Book (BL-0055)
The save sheet gets "+ Previous page" / "+ Next page" (right to left for
rtl in Page/Book) for a picture that runs over several pages. Moments
store a layout (row | column); pages the selected region does not touch
are dropped. Crops, viewer and gallery draw both layouts.

Dialogs now clean up directly instead of waiting for the "close" event,
which a backgrounded page can deliver late (a closed sheet stayed in the
page and M stopped working).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:38:22 +01:00
MaashkinoozheandClaude Opus 5.5 74842c59e0 Moments: per-user saved pages and regions with one category (BL-0055)
Reader: bookmark button and M save the page (Flow: page at the reading
line, Book: the spread) with an optional dragged region, exactly one
category (Twist, Hot, Awesome, Cute, Funny, Feels) and a note. I now
toggles the bars; ? lists all keys. Keys dispatch through public/keys.js,
which also feeds the new Settings -> Keyboard shortcuts section.

Manga page: "Your moments" strip of crops (story or recent order,
category filters), viewer with step, full page, edit, delete and
"Open in reader" (read.html ...&p=). Moments point at pages by file
name and show "page moved" when the file is gone. Private per user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:30:24 +01:00
MaashkinoozheandClaude Opus 5.5 41e7ed063e Continue row: first line of the content, only on the plain overview
The row now sits below the search, filters and tags, directly above the
grid. It hides while a search is typed or a status filter is set (as well
as on "Recently read"); tags and collection grouping keep it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:08:35 +01:00
MaashkinoozheandClaude Opus 5.5 1ddefaa7ac Library: "Continue where you left off" row with a per-user setting
One fixed row above the library showing the five most recently read,
unfinished manga with the normal tiles (they stay in the grid as well).
It ignores search and filters, hides while sorted by "Recently read",
and can be turned off under Settings > Reading > Library page
(continueRow, default on).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:02:16 +01:00
MaashkinoozheandClaude Opus 5.5 e9da27e688 Greatest: serve catalog covers from a local copy (BL-0053)
The page hotlinked ~100 AniList CDN covers per load and some failed at random.
The reader now downloads each catalog cover once (sequential, ~1/s, stops on
401/403/429, images up to 2 MB) into data/greatest-covers/ and serves it from
/api/greatest-cover with a year-long browser cache; until a copy exists the
route redirects to the catalog URL. The page also accepts the server's
relative cover URLs, so owned titles show their library cover again.
GREATEST_COVER_WARM=0 turns the downloads off (tests, offline installs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 00:38:55 +01:00
58 changed files with 3474 additions and 110 deletions

No files matched your search

+5
View File
@@ -10,6 +10,9 @@
!backup.js !backup.js
!reading-analytics.js !reading-analytics.js
!reading-access.js !reading-access.js
!moments.js
!reader-sessions.js
!reading-activity.js
!public/ !public/
!public/** !public/**
!greatest/ !greatest/
@@ -22,6 +25,8 @@
!scraper/** !scraper/**
!peering/ !peering/
!peering/** !peering/**
!perf/
!perf/**
!tools/ !tools/
!tools/** !tools/**
scraper/.cache/** scraper/.cache/**
+4
View File
@@ -20,6 +20,10 @@ READING_TIMEZONE=UTC
READING_QUIZ_UNLOCK_MINUTES=60 READING_QUIZ_UNLOCK_MINUTES=60
# Optional deployment-managed JSON file under DATA_PATH, e.g. /data/reading-quiz.json. # Optional deployment-managed JSON file under DATA_PATH, e.g. /data/reading-quiz.json.
# READING_QUIZ_CATALOG_PATH=/data/reading-quiz.json # READING_QUIZ_CATALOG_PATH=/data/reading-quiz.json
# Read an opened chapter into the OS cache ahead of the browser (1 = on, the default); see docs/performance.md.
# READ_AHEAD=1
# Performance trace from the start (also switchable at runtime: touch DATA_PATH/perf/trace.on).
# PERF_TRACE=0
PROXY_NETWORK=mangacircus-edge PROXY_NETWORK=mangacircus-edge
# Extra Compose files, ':'-separated (docs/deployment.md), e.g. HTTPS with the bundled Caddy: # Extra Compose files, ':'-separated (docs/deployment.md), e.g. HTTPS with the bundled Caddy:
# COMPOSE_FILE=docker-compose.yml:deploy/compose.https.yml # COMPOSE_FILE=docker-compose.yml:deploy/compose.https.yml
+1
View File
@@ -12,6 +12,7 @@ Guide for contributors and coding agents. Users who only want to run it: [docs/d
| `scraper/` | Download queue daemon (`service.js`, `daemon.js`), source adapters and parsers, the publisher that writes the library, metadata lookups | | `scraper/` | Download queue daemon (`service.js`, `daemon.js`), source adapters and parsers, the publisher that writes the library, metadata lookups |
| `peering/` | Optional server-to-server sharing (gateway, client, adapter) | | `peering/` | Optional server-to-server sharing (gateway, client, adapter) |
| `runtime/` | In-container supervisor and configuration | | `runtime/` | In-container supervisor and configuration |
| `perf/` | Switchable performance trace and chapter read-ahead ([docs/performance.md](docs/performance.md)) |
| `discovery/`, `greatest/` | Discovery Scout and the Greatest of All Time dashboard | | `discovery/`, `greatest/` | Discovery Scout and the Greatest of All Time dashboard |
| `tools/` | Demo library, health checks, maintenance tools (dry run by default), UI and Compose test drivers | | `tools/` | Demo library, health checks, maintenance tools (dry run by default), UI and Compose test drivers |
| `deploy/` | `circus.sh` operations helper, HTTPS with Caddy | | `deploy/` | `circus.sh` operations helper, HTTPS with Caddy |
+2 -1
View File
@@ -3,7 +3,7 @@ RUN apk add --no-cache tzdata openssl
WORKDIR /app WORKDIR /app
ARG VCS_REF=development ARG VCS_REF=development
LABEL org.opencontainers.image.title="MangaCircus" org.opencontainers.image.revision=$VCS_REF LABEL org.opencontainers.image.title="MangaCircus" org.opencontainers.image.revision=$VCS_REF
COPY package.json server.js library.js reading-direction.js chapter-identity.js chapter-dates.js reading-analytics.js reading-access.js catalog.js backup.js ./ COPY package.json server.js library.js reading-direction.js chapter-identity.js chapter-dates.js reading-analytics.js reading-access.js catalog.js backup.js moments.js reader-sessions.js reading-activity.js ./
COPY public ./public COPY public ./public
COPY greatest ./greatest COPY greatest ./greatest
COPY discovery ./discovery COPY discovery ./discovery
@@ -11,6 +11,7 @@ COPY tools ./tools
COPY scraper ./scraper COPY scraper ./scraper
COPY peering ./peering COPY peering ./peering
COPY runtime ./runtime COPY runtime ./runtime
COPY perf ./perf
RUN mkdir -p /library /data && chown node:node /data /library RUN mkdir -p /library /data && chown node:node /data /library
ENV PORT=8080 LIBRARY=/library DATA=/data TZ=UTC ENV PORT=8080 LIBRARY=/library DATA=/data TZ=UTC
EXPOSE 8080 EXPOSE 8080
+10 -2
View File
@@ -50,6 +50,10 @@ Settings has **User** and **Admin** menu groups. User sections cover Reading, Re
Notifications and Account. Admin sections cover Scraper, Server & library, Users & access, Collections & Notifications and Account. Admin sections cover Scraper, Server & library, Users & access, Collections &
metadata and Service health. Only admins see administration ([settings navigation](docs/settings-navigation.md)). metadata and Service health. Only admins see administration ([settings navigation](docs/settings-navigation.md)).
Reader settings (mode, zoom, page tone, skipping) are kept per sign-in: each device or browser keeps its own once you
change one there, and a new sign-in starts from the one you used last. **Settings → Reading → Reader settings per
session** shows every session's state and turns this off for one shared set ([details](docs/architecture.md#67-reader-settings-per-session)).
### Settings (environment variables) ### Settings (environment variables)
These configure the reader process. In Docker most are set for you by `docker-compose.yml`; the variables These configure the reader process. In Docker most are set for you by `docker-compose.yml`; the variables
@@ -63,6 +67,8 @@ you put in `.env` are listed in [docs/deployment.md](docs/deployment.md#4-config
| `SIGNUP` | `closed` | `open` lets anyone create an account. Otherwise the admin adds users under Settings | | `SIGNUP` | `closed` | `open` lets anyone create an account. Otherwise the admin adds users under Settings |
| `SCAN_INTERVAL` | `10` | Minutes between shallow library change checks (`0` disables this timer) | | `SCAN_INTERVAL` | `10` | Minutes between shallow library change checks (`0` disables this timer) |
| `FULL_SCAN_INTERVAL` | `1440` | Minutes between full library reconciliations (`0` disables this timer) | | `FULL_SCAN_INTERVAL` | `1440` | Minutes between full library reconciliations (`0` disables this timer) |
| `READ_AHEAD` | `1` | Read an opened chapter's pages (then the next chapter's) into the OS cache ahead of the browser; `0` = off ([docs/performance.md](docs/performance.md)) |
| `PERF_TRACE` | `0` | `1` = performance trace from the start; it can also be switched at runtime with the file `DATA/perf/trace.on` ([docs/performance.md](docs/performance.md)) |
| `SCAN_CONCURRENCY` | `8` | Chapter folders and images probed at once by an uncached scan (1–64); higher helps on network storage | | `SCAN_CONCURRENCY` | `8` | Chapter folders and images probed at once by an uncached scan (1–64); higher helps on network storage |
| `COOKIE_SECURE` | – | `1` marks the login cookie `Secure` (use it when you serve over HTTPS) | | `COOKIE_SECURE` | – | `1` marks the login cookie `Secure` (use it when you serve over HTTPS) |
| `SCRAPER_URL` | `http://127.0.0.1:8091` | Private scraper daemon URL | | `SCRAPER_URL` | `http://127.0.0.1:8091` | Private scraper daemon URL |
@@ -142,6 +148,7 @@ completeness.
- **Flow page gap:** Settings → Reading lets each user keep the normal separator between Flow pages or remove it for continuous webtoon artwork and text that crosses image edges. - **Flow page gap:** Settings → Reading lets each user keep the normal separator between Flow pages or remove it for continuous webtoon artwork and text that crosses image edges.
- **Page tone:** the reader's **Tone** button cycles Original → Inverted → Sepia → Dim → Extra dim in Flow, Page and Book. It changes only the artwork, never the controls or site theme. The choice is saved per user; it can also be selected in Settings → User → Reading. - **Page tone:** the reader's **Tone** button cycles Original → Inverted → Sepia → Dim → Extra dim in Flow, Page and Book. It changes only the artwork, never the controls or site theme. The choice is saved per user; it can also be selected in Settings → User → Reading.
- **Active Reading Clock:** opt in under Settings → User → Reading time & privacy. No scrolling-speed limit; a visit counts at 60% viewed-page coverage and saves on exit. Pauses in the background or after 30 seconds without reader activity. Tap the clock for this visit's time, coverage, qualification and loading wait. - **Active Reading Clock:** opt in under Settings → User → Reading time & privacy. No scrolling-speed limit; a visit counts at 60% viewed-page coverage and saves on exit. Pauses in the background or after 30 seconds without reader activity. Tap the clock for this visit's time, coverage, qualification and loading wait.
- **Continue where you left off:** the first line of the library content (below the search, filters and tags) is one row of the five manga you read most recently that still have something left after the last chapter you read (same tiles as the grid; they stay in the grid too). Left means an unfinished last chapter, an unread later chapter, or — when you are rereading — a later chapter you finished in an earlier pass: more than *N days* before your current position (default 7), or before a run of more than *M chapters* you have since read in order up to it (default 5). Both are set under Settings → User → Reading → *Library page*; 0 turns a rule off. A manga you have caught up on drops out until a new chapter arrives. It hides while a search is typed, a status filter is set, or the library is sorted by *Recently read*; tags and collection grouping do not affect it. Turn it off under Settings → User → Reading → *Library page*.
- **Your Reading Story:** private active-time summaries appear on the library and manga pages when history exists. Chapter rows show the last timed read (60%-coverage visit), rereads remain visible as lifetime time, and Settings lets each user hide, export, or delete their own history. No leaderboards or speed ranking are used. - **Your Reading Story:** private active-time summaries appear on the library and manga pages when history exists. Chapter rows show the last timed read (60%-coverage visit), rereads remain visible as lifetime time, and Settings lets each user hide, export, or delete their own history. No leaderboards or speed ranking are used.
- **Tap / click zones:** - **Tap / click zones:**
- Tapping **beside the pages** always shows or hides the controls. - Tapping **beside the pages** always shows or hides the controls.
@@ -149,8 +156,9 @@ completeness.
- In Flow: the bottom part of the screen goes forward, the top part goes back. - 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). - 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. - A horizontal swipe also turns pages on touch screens.
- Keys: arrows, Space / PageDown, `m` = controls, `f` = fullscreen. - Keys: arrows, Space / PageDown, `i` = controls, `m` = save a moment, `f` = fullscreen, `?` = all shortcuts (also under Settings → User → Keyboard shortcuts).
- **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. - **Moments:** the bookmark button in the top bar (or `m`) saves the page you are on (in Book mode the spread) as a moment. A picture that runs over several pages can take up to four: *+ Previous page* / *+ Next page* add them, stacked in Flow and side by side in Page and Book. Drag over the preview to keep only a region (pages it doesn't touch are left out), and pick exactly one category: Twist, Hot, Awesome, Cute, Funny or Feels, plus an optional note. The button is filled on pages that already have one. Each manga page shows *Your moments* as a strip of crops, sorted by story or by when you saved them; click one to step through them, see the full page, edit, delete or jump into the reader at that page. Moments are private to each user.
- **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) or `i`. 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. - **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. - **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. - **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.
+3
View File
@@ -60,6 +60,9 @@ services:
SCRAPER_TOKEN_FILE: /run/secrets/scraper_token SCRAPER_TOKEN_FILE: /run/secrets/scraper_token
DISCOVERY_HARVEST_AT: ${DISCOVERY_HARVEST_AT:-03:30} DISCOVERY_HARVEST_AT: ${DISCOVERY_HARVEST_AT:-03:30}
DISCOVERY_ANILIST_URL: ${DISCOVERY_ANILIST_URL:-} DISCOVERY_ANILIST_URL: ${DISCOVERY_ANILIST_URL:-}
GREATEST_COVER_WARM: ${GREATEST_COVER_WARM:-1}
READ_AHEAD: ${READ_AHEAD:-1}
PERF_TRACE: ${PERF_TRACE:-0}
networks: [backend, egress] networks: [backend, egress]
healthcheck: healthcheck:
test: [CMD, node, tools/healthcheck.js, reader] test: [CMD, node, tools/healthcheck.js, reader]
+50 -10
View File
@@ -128,12 +128,18 @@ This keeps ids human-readable and derivable without a database. The price: renam
"flowPageGap": true, // show the 8 px separator between pages in Flow "flowPageGap": true, // show the 8 px separator between pages in Flow
"readingClock": false, // opt in to credible per-chapter active-time tracking "readingClock": false, // opt in to credible per-chapter active-time tracking
"readingStory": true, // show private retrospective summaries when history exists "readingStory": true, // show private retrospective summaries when history exists
"continueRow": true, // library: "Continue where you left off" row at the top
"rereadDays": 7, // continue row: a later chapter finished this long before the head is left again (0 = off)
"rereadChapters": 5, // ... or one finished before a run of more than this many in-order chapters (0 = off)
"zoom": { "flow": 1, "page": 1.5, "book": 1, "strip": 0.8 }, // page zoom per mode "zoom": { "flow": 1, "page": 1.5, "book": 1, "strip": 0.8 }, // page zoom per mode
"webhook": "" "webhook": "",
} "readerPerSession": true // reader settings per login session (section 6.7)
},
"readerFrom": "3f9c…" // public id of the session the latest reader settings came from
} }
}, },
"sessions": { "<sha256 of token>": { "u": "admin", "t": 1790796794000 } }, "sessions": { "<sha256 of token>": { "u": "admin", "t": 1790796794000, "seen": 1790796794000,
"agent": "<User-Agent>", "reader": { …locked reader settings, only after a change… } } },
"progress": { "admin": { "Moon Garden": { "1/2": { "progress": { "admin": { "Moon Garden": { "1/2": {
"v": "c0002 Roots [MangaDex]", // version read "v": "c0002 Roots [MangaDex]", // version read
"p": 4, "n": 14, // page index in that version (all pages), page count "p": 4, "n": 14, // page index in that version (all pages), page count
@@ -150,6 +156,11 @@ This keeps ids human-readable and derivable without a database. The price: renam
"follows": { "admin": { "Moon Garden": true } }, "follows": { "admin": { "Moon Garden": true } },
"notes": { "admin": [ { "id": "a1b2c3d4e5f6", "t": 1790797300000, "m": "Moon Garden", "c": "2/6", "notes": { "admin": [ { "id": "a1b2c3d4e5f6", "t": 1790797300000, "m": "Moon Garden", "c": "2/6",
"text": "Moon Garden · Ch. 6: Frost", "read": false } ] }, "text": "Moon Garden · Ch. 6: Frost", "read": false } ] },
"moments": { "admin": [ { "id": "9f1c2b3a4d5e", "m": "Moon Garden", "c": "1/2", "v": "c0002 Roots [MangaDex]",
"i": [4], "f": ["0005.png"], "sha": ["<sha256 of the file>"], // 1–4 pages in displayed order
"layout": "row", // "row" side by side (Page/Book), "column" stacked (Flow)
"region": { "x": 0.1, "y": 0.5, "w": 0.5, "h": 0.25 }, // fractions of the pages put together; null = all
"cat": "awesome", "note": "", "t": 1790797400000 } ] },
"votes": { "Moon Garden": { "1/2": { "c0002 Roots [Official]": { "admin": 1, "reader": -1 } } } }, "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) "known": { "Moon Garden": ["1/1", "1/2", "1/2.5", "..."] } // every chapter id ever seen (append-only)
} }
@@ -171,7 +182,7 @@ This keeps ids human-readable and derivable without a database. The price: renam
| Path | Handling | | Path | Handling |
|------|----------| |------|----------|
| `/api/*` | `routes["<METHOD> <path>"]`. Handlers get `{ u, b, q, req, res }` and return an object, which is sent as JSON. Throwing `HttpError(code, message, extra)` sends `{ error, ...extra }` with that status (for example `nextAllowedAt` on a discovery `429`) | | `/api/*` | `routes["<METHOD> <path>"]`. Handlers get `{ u, b, q, req, res, sess, sk }` (`sess` = the login session record, `sk` its key) and return an object, which is sent as JSON. Throwing `HttpError(code, message, extra)` sends `{ error, ...extra }` with that status (for example `nextAllowedAt` on a discovery `429`) |
| `/lib/*` | Image files from `LIBRARY`. Requires a session, allows image extensions only, and the path must resolve inside `LIBRARY`. `ETag` + `private, max-age=86400` | | `/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` | | everything else | Static files from `public/` (`/` → `index.html`). `ETag` + `no-cache` |
@@ -188,25 +199,34 @@ All requests and responses are JSON. POSTs require `Content-Type: application/js
| `POST /api/login` `{name, password}` | public | Starts a session (cookie `sid`) | | `POST /api/login` `{name, password}` | public | Starts a session (cookie `sid`) |
| `POST /api/logout` | user | Ends the session | | `POST /api/logout` | user | Ends the session |
| `GET /api/me` | user | Name, admin flag, settings, unread count | | `GET /api/me` | user | Name, admin flag, settings, unread count |
| `POST /api/settings` `{…partial}` | user | Validated update of the settings keys above | | `POST /api/settings` `{…partial}` | user | Validated update of the settings keys above; reader keys go to the current session (6.7) |
| `GET /api/reader-sessions` | user | `{ enabled, current, latestFrom, latest, sessions: [{ id, device, created, seen, current, locked, reader }] }` |
| `POST /api/reader-sessions` `{id, action}` | user | `follow`: the session drops its own reader settings and follows the latest; `latest`: its settings become the latest |
| `POST /api/password` `{old, new}` | user | Change password | | `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, and optional personal Reading Story aggregates | | `GET /api/library` | user | Manga list with covers, counts, the user's read count, unread notifications, last read, `left` (chapters after the newest saved position that are unfinished, unread, or finished in an earlier pass — over `rereadDays` before it, or before a run of more than `rereadChapters` chapters read in order up to it; 0 = caught up), and optional personal Reading Story aggregates |
| `GET /api/manga?m=` | user | Manga details, books, chapters with ranked versions, progress, optional chapter-time summaries, and `continue` target | | `GET /api/manga?m=` | user | Manga details, books, chapters with ranked versions, progress, optional chapter-time summaries, and `continue` target |
| `GET /api/chapter?m=&c=[&v=]` | user | Pages (`u, w, h, t, r`), direction, versions, prev/next, progress, optional Active Reading Clock summary, 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 | | `GET /api/chapter?m=&c=[&v=]` | user | Pages (`u, w, h, t, r`), direction, versions, prev/next, progress, `latest` (as in `/api/progress/latest`), optional Active Reading Clock summary, 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/progress` `{m, c, v, p, n, done, shift[, tab, seen]}` | user | Save the reading position. With `seen`, a save is refused (`{ok:false, stale:true, latest}`) when another tab saved a newer position of this manga since; otherwise `{ok:true, t}` |
| `GET /api/progress/latest?m=` | user | The manga's newest saved position (`latest`: `t, w, c, label, v, p, n, done, continue`) or `null` |
| `POST /api/reading/pulse` `{m, c, v, session, seq, …deltas}` | user | When opted in, idempotently merge a clamped active-time pulse, verified page coverage and input totals under a single-reader lease | | `POST /api/reading/pulse` `{m, c, v, session, seq, …deltas}` | user | When opted in, idempotently merge a clamped active-time pulse, verified page coverage and input totals under a single-reader lease |
| `GET /api/reading/export` | user | Export the user's private derived reading-time summary without raw input or page-exposure diagnostics | | `GET /api/reading/export` | user | Export the user's private derived reading-time summary without raw input or page-exposure diagnostics |
| `GET /api/reading/activity` | user | The user's reading activity for the Settings grid: active reader time in UTC buckets `[start, size, ms, [[mangaIndex, ms]...]]` (6 minutes for the last 120 days, hours up to three years), chapter completions `[doneAt, mangaIndex, label]`, manga titles and whether the clock is on. The browser groups them into local months, weeks, days and hours (`public/activity-core.js`) |
| `POST /api/reading/delete` `{m}` or `{all:true,password}` | user | Delete one manga's reading history, or all history after password confirmation, without changing normal reading progress | | `POST /api/reading/delete` `{m}` or `{all:true,password}` | user | Delete one manga's reading history, or all history after password confirmation, without changing normal reading progress |
| `POST /api/unread` `{m, c}` | user | Forget progress for a chapter | | `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/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 | | `POST /api/follow` `{m, on}` | user | Follow a manga for notifications |
| `GET /api/moments?m=` | user | The caller's moments of that manga in story order: category, note, region, chapter, `order`, page images, `missing` when a page file is gone. `/api/chapter` also lists the caller's moments on the opened version |
| `POST /api/moments` `{m, c, v, pages, layout, region, cat, note}` | user | Save a moment: `pages` are 1–4 distinct indexes in displayed order, `layout` `row` (default) or `column`, `cat` one of `twist hot awesome cute funny feels` (required), `region` fractions of the pages put together, or null |
| `POST /api/moments/update` `{id, cat?, region?, note?}` · `POST /api/moments/delete` `{id}` | user | Edit or delete one of the caller's own moments (another user's id is `404`) |
| `GET /api/notifications` · `POST /api/notifications/read` `{id \| all}` | user | Notification list and read state | | `GET /api/notifications` · `POST /api/notifications/read` `{id \| all}` | user | Notification list and read state |
| `POST /api/perf` `{page, events: […]}` | user | Browser timings for the performance trace; ignored with `{on: false}` while the trace is off. `/api/chapter` returns `perf: true` while it is on ([performance.md](performance.md)) |
| `GET /api/admin` | admin | Users, last scan result, library path | | `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/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` `{name, password, admin}` | admin | Create a user or reset a password |
| `POST /api/admin/user/role` `{name, admin}` | admin | Grant or revoke admin rights; keeps the password and sessions. Revoking your own rights is refused, so one admin always remains | | `POST /api/admin/user/role` `{name, admin}` | admin | Grant or revoke admin rights; keeps the password and sessions. Revoking your own rights is refused, so one admin always remains |
| `POST /api/admin/user/delete` `{name}` | admin | Delete a user and their data | | `POST /api/admin/user/delete` `{name}` | admin | Delete a user and their data |
| `GET /api/admin/backup` · `POST /api/admin/backup` `{command:"settings"\|"run"}` | admin | Built-in backup status, schedule/retention, "back up now" ([backup.md](backup.md)) | | `GET /api/admin/backup` · `POST /api/admin/backup` `{command:"settings"\|"run"}` | admin | Built-in backup status, schedule/retention, "back up now" ([backup.md](backup.md)) |
| `GET /api/greatest-cover?id=<entry>&v=<version>` | user | The Greatest entry's cover from `data/greatest-covers/` (`greatest/covers.js`), cached by the browser for a year; `v` changes with the catalog URL. A `302` to the catalog URL until the copy exists. `/api/greatest` lists this URL as `cover` for titles not in the library |
| `GET /api/discovery` | user | The caller's discovery feed, settings, feedback and `refresh: { state: idle\|queued\|running, position?, nextAllowedAt?, blockedReason? }` (§6.6) | | `GET /api/discovery` | user | The caller's discovery feed, settings, feedback and `refresh: { state: idle\|queued\|running, position?, nextAllowedAt?, blockedReason? }` (§6.6) |
| `POST /api/discovery/feedback` `{candidateId, value}` | user | The caller's choice for a candidate; requires opt-in | | `POST /api/discovery/feedback` `{candidateId, value}` | user | The caller's choice for a candidate; requires opt-in |
| `POST /api/discovery/refresh` | user | Queue a per-user AniList harvest for the caller (opt-in required, else `403`). `{ harvestId, position }`; `409` while one is queued or running for the caller; `429 { nextAllowedAt }` within an hour of the last accepted refresh; `503 { blockedReason }` after AniList pushed back, until a nightly or admin harvest | | `POST /api/discovery/refresh` | user | Queue a per-user AniList harvest for the caller (opt-in required, else `403`). `{ harvestId, position }`; `409` while one is queued or running for the caller; `429 { nextAllowedAt }` within an hour of the last accepted refresh; `503 { blockedReason }` after AniList pushed back, until a nightly or admin harvest |
@@ -258,6 +278,22 @@ The reader alone talks to AniList for discovery ([discovery.md](discovery.md)).
(all reasons, strongest first) next to `reason`; seed evidence carries `fetchedAt`. (all reasons, strongest first) next to `reason`; seed evidence carries `fetchedAt`.
- `DISCOVERY_ANILIST_URL` overrides the AniList endpoint (tests point it at a local fixture server). - `DISCOVERY_ANILIST_URL` overrides the AniList endpoint (tests point it at a local fixture server).
### 6.7 Reader settings per session
The reader keys (`startMode`, `lastMode`, `adapt`, `skipExtras`, `skipCovers`, `flowPageGap`, `pageTone`, `zoom`)
can differ per login session, so a phone and a desktop keep their own mode and zoom (`reader-sessions.js`):
- `user.settings` holds the **latest** reader set. A session without its own set follows it.
- The first reader change in a session copies the latest set into `session.reader` with the change applied: the session
is now **locked** and keeps that set whatever other sessions do. The change also becomes the latest set.
- Loading a page (`GET /api/me`) from a locked session makes its set the latest again, so a new sign-in starts from the
session used last. A new sign-in has no set of its own until it changes something.
- Other settings (Reading Story, dates, discovery, webhook, …) stay per user.
- `readerPerSession: false` (Settings → Reading) brings back one shared set: changes go to `user.settings` and apply to
every session. Locked sets are kept and apply again when the switch is turned back on.
Settings → Reading → *Reader settings per session* lists the user's sessions (device from the User-Agent, sign-in and last
activity, own or following) with "Follow latest again" and "Use for new sessions".
## 7. Frontend ## 7. Frontend
### 7.1 Pages ### 7.1 Pages
@@ -321,12 +357,12 @@ mode = adapt && intended ∉ compatible ? compatible[0] : intended
| Horizontal swipe ≥ 50 px within 800 ms (paged, not zoomed in) | Previous/next | | Horizontal swipe ≥ 50 px within 800 ms (paged, not zoomed in) | Previous/next |
| Ctrl + wheel, touchpad pinch, Safari gesture events, two-finger pinch | Continuous page zoom 20–600 %, snapping to 100 % within ±1 %, anchored at the pointer or finger midpoint. Browser zoom is suppressed only over the pages | | Ctrl + wheel, touchpad pinch, Safari gesture events, two-finger pinch | Continuous page zoom 20–600 %, snapping to 100 % within ±1 %, 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 | | 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); `+` and `-` zoom in 5-percentage-point steps, `0` fits at 100 %; `m`/Esc bars; `f` fullscreen | | Keys | Dispatched through `READER_KEYS` in `public/keys.js`, which also feeds Settings → Keyboard shortcuts and the reader's `?` list. Arrows follow the direction; Space/PageDown/PageUp (paged); `+` and `-` zoom in 5-percentage-point steps, `0` fits at 100 %; `m` saves a moment; `i`/Esc bars; `f` fullscreen; `?` shortcuts |
**Bars:** **Bars:**
- Shown on open. - 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. - 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. - **Never shown by scrolling**, only by a tap beside the pages or in the middle of a page (or `i`). 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). - The top bar has the chapter picker (every chapter, grouped by book, ✓ = read).
**Loading:** **Loading:**
@@ -341,6 +377,7 @@ mode = adapt && intended ∉ compatible ? compatible[0] : intended
- Saved 700 ms after the position settles, and on `pagehide` (a `fetch` with `keepalive`). - 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. - 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. - On open, the saved page is used for the same version, and mapped proportionally for another version. A finished chapter reopens at the start.
- **Stale reading sessions.** A tab left open on one device (or restored by the browser) must not move "Continue" back after the user read on elsewhere. Each browser tab has an id (`sessionStorage`) that its saves store as `w`; reader links carry `at`, the newest progress time the linking page knew (`continue.at`), and the reader keeps `at` current in its address bar so a reload or restored tab still knows it. A link without `at` (bookmark, notification) counts as a deliberate open. When the manga's newest position is later than `at` and came from another tab, the reader saves nothing and asks: jump to the newest position, or keep reading here, which makes this chapter the head again. It checks on open, when the tab becomes visible again, and with every save (the server refuses the stale save). The comparison is by save time, not chapter order; the question names no device.
**Active Reading Clock (opt-in):** **Active Reading Clock (opt-in):**
- Runs only while the reader is visible and focused, a sufficiently exposed page is decoded, a server lease is held, and accepted reader input occurred in the last 50 seconds. - Runs only while the reader is visible and focused, a sufficiently exposed page is decoded, a server lease is held, and accepted reader input occurred in the last 50 seconds.
@@ -350,6 +387,8 @@ mode = adapt && intended ∉ compatible ? compatible[0] : intended
- Pulses are sent at most every 10 seconds and on pause/page exit. The server validates the manga/chapter/version and page indices, de-duplicates `(session, seq)`, clamps time to server-observed intervals and stores compact page ranges separately from ordinary progress. - Pulses are sent at most every 10 seconds and on pause/page exit. The server validates the manga/chapter/version and page indices, de-duplicates `(session, seq)`, clamps time to server-observed intervals and stores compact page ranges separately from ordinary progress.
- The bottom toolbar chip shows active time and the current state; its panel adds pass time, verified pages, predominant input and loading wait. - The bottom toolbar chip shows active time and the current state; its panel adds pass time, verified pages, predominant input and loading wait.
**Moments** (`public/moments.js`): the top bar's bookmark button (or `m`) opens a sheet for the page(s) on screen: in Flow the page at the reading line, in Book the spread. "+ Previous page" / "+ Next page" add neighbours (up to four pages) for a picture that runs over several pages: stacked in Flow (`column`), side by side in Page and Book (`row`, right to left for `rtl`). A rectangle dragged over the preview keeps only that region, and pages it does not touch are left out. Exactly one category is required. The button is filled when the page already has a moment. The manga page shows the user's moments as a strip of crops (sort by story or by date saved, category filters from five moments), with a viewer to step through them, edit, delete or open the reader at `read.html?…&p=<page index>`. Crops position the full page images inside a clipping box; nothing is generated on the server.
**Zoom persistence:** per mode key (`flow`, `page`, `book`, `strip`), saved 800 ms after the last change. **Zoom persistence:** per mode key (`flow`, `page`, `book`, `strip`), saved 800 ms after the last change.
## 8. Deployment ## 8. Deployment
@@ -388,6 +427,7 @@ docker compose up -d --build
| 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) | | 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 | | 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 | | Images | Served as stored. No resizing or thumbnails, so large covers make the library page heavy |
| Slow storage | Opening a chapter reads its pages (then the next chapter's) into the OS cache, two at a time (`READ_AHEAD`). Where the time goes: [performance.md](performance.md) |
## 10. Design decisions ## 10. Design decisions
+2 -1
View File
@@ -21,7 +21,7 @@ Data on the host, all bind mounts (no named volumes):
| Path (`.env`) | Contents | Back up? | | Path (`.env`) | Contents | Back up? |
|---|---|---| |---|---|---|
| `LIBRARY_PATH` | The manga: `Manga/Book/Chapter/images` ([library-format.md](library-format.md)) | Yes, it is your collection | | `LIBRARY_PATH` | The manga: `Manga/Book/Chapter/images` ([library-format.md](library-format.md)) | Yes, it is your collection |
| `DATA_PATH` | `db.json` (users, progress, votes), catalog, discovery feeds, library index cache | Yes (built-in backup, section 8). `discovery-cache/` inside it is safe to delete and may be left out of backups: the next harvest rebuilds it | | `DATA_PATH` | `db.json` (users, progress, votes), catalog, discovery feeds, library index cache | Yes (built-in backup, section 8). `discovery-cache/` inside it is safe to delete and may be left out of backups: the next harvest rebuilds it. The same holds for `greatest-covers/` (local copies of the Greatest catalog's cover images, fetched again after the next start) |
| `SCRAPER_DATA_PATH` | Queue, settings, source ledger, metadata cache | Included in the built-in backup | | `SCRAPER_DATA_PATH` | Queue, settings, source ledger, metadata cache | Included in the built-in backup |
| `BACKUP_PATH` | Built-in backup archives | Keep it on another disk or NAS | | `BACKUP_PATH` | Built-in backup archives | Keep it on another disk or NAS |
| `SCRAPER_TOKEN_PATH` | Shared secret between reader and scraper | Regenerate if lost | | `SCRAPER_TOKEN_PATH` | Shared secret between reader and scraper | Regenerate if lost |
@@ -71,6 +71,7 @@ demo manga in an empty library after the build in section 5:
| `READING_WINDOWS_JSON`, `READING_TIMEZONE`, `READING_QUIZ_*` | off | Optional server-enforced reading hours ([reading-limiter.md](reading-limiter.md)) | | `READING_WINDOWS_JSON`, `READING_TIMEZONE`, `READING_QUIZ_*` | off | Optional server-enforced reading hours ([reading-limiter.md](reading-limiter.md)) |
| `DISCOVERY_HARVEST_AT` | `03:30` | Daily time (`HH:MM`) of the nightly Discover harvest from AniList, in the container's local time zone (`TZ`). Only users who opted in are harvested; the admin can change the time or switch it off on the Discover page ([discovery.md](discovery.md)) | | `DISCOVERY_HARVEST_AT` | `03:30` | Daily time (`HH:MM`) of the nightly Discover harvest from AniList, in the container's local time zone (`TZ`). Only users who opted in are harvested; the admin can change the time or switch it off on the Discover page ([discovery.md](discovery.md)) |
| `DISCOVERY_ANILIST_URL` | AniList's public API | Tests only: points the discovery harvest at a fixture server. Leave unset in production | | `DISCOVERY_ANILIST_URL` | AniList's public API | Tests only: points the discovery harvest at a fixture server. Leave unset in production |
| `GREATEST_COVER_WARM` | `1` | `0` stops the reader from downloading the Greatest catalog's covers (about 100 images, one per second, 10 s after start and once a day for missing ones). Tests and offline installs set `0`; the page then loads covers from the catalog's own URLs |
| `APP_IMAGE`, `VCS_REF` | `mangacircus:local`, `development` | Image to run; set a pinned registry image to deploy prebuilt releases (section 7) | | `APP_IMAGE`, `VCS_REF` | `mangacircus:local`, `development` | Image to run; set a pinned registry image to deploy prebuilt releases (section 7) |
| `COMPOSE_FILE` | `docker-compose.yml` | Extra Compose files, `:`-separated (e.g. `docker-compose.yml:deploy/compose.https.yml`) | | `COMPOSE_FILE` | `docker-compose.yml` | Extra Compose files, `:`-separated (e.g. `docker-compose.yml:deploy/compose.https.yml`) |
| `MANGACIRCUS_DOMAIN` | – | Host name for `deploy/compose.https.yml` | | `MANGACIRCUS_DOMAIN` | – | Host name for `deploy/compose.https.yml` |
+1 -1
View File
@@ -23,7 +23,7 @@ Within each cohort, **Rank by my vote** sorts flame → good → neutral → sku
The one-time seed is curated from the research document. All 106 catalog entries now have a cover URL. Most were matched to public AniList manga metadata; the five initial gaps were checked individually. Phoenix (1954) and Nausicaä use matching AniList records; Kitarō uses Drawn & Quarterly's Kitaro edition, Someone Hertz uses Shonen Jump's first-volume cover, and Hitoner uses Shueisha's 2026 first-volume cover. Hitoner's AniList 2024 entry represents an earlier work, so it was **not** used for the 2026 series. The five added URLs returned image content on 2026-10-09, but remote hosts can change them; the UI still shows a title-letter fallback if an image fails. The one-time seed is curated from the research document. All 106 catalog entries now have a cover URL. Most were matched to public AniList manga metadata; the five initial gaps were checked individually. Phoenix (1954) and Nausicaä use matching AniList records; Kitarō uses Drawn & Quarterly's Kitaro edition, Someone Hertz uses Shonen Jump's first-volume cover, and Hitoner uses Shueisha's 2026 first-volume cover. Hitoner's AniList 2024 entry represents an earlier work, so it was **not** used for the 2026 series. The five added URLs returned image content on 2026-10-09, but remote hosts can change them; the UI still shows a title-letter fallback if an image fails.
`node tools/enrich-greatest-catalog.js` is an **optional manual** public-AniList metadata refresh, paced at one request every 1.5 seconds. It only accepts matching manga title and **original serialization year**, leaving uncertain results untouched rather than using a sequel or remake cover. The dashboard makes **no metadata calls when loading**; only an admin pressing **Search MangaDex** triggers a bounded lookup. Browser covers are lazy-loaded from their listed source; local library covers take precedence. `node tools/enrich-greatest-catalog.js` is an **optional manual** public-AniList metadata refresh, paced at one request every 1.5 seconds. It only accepts matching manga title and **original serialization year**, leaving uncertain results untouched rather than using a sequel or remake cover. The dashboard makes **no metadata calls when loading**; only an admin pressing **Search MangaDex** triggers a bounded lookup. Local library covers take precedence. For the rest, the reader keeps its own copy of each catalog cover in `data/greatest-covers/` (`greatest/covers.js`): it downloads missing ones one at a time, about one a second, 10 seconds after start and once a day, stops on HTTP 401/403/429, and accepts only JPEG/PNG/WebP/GIF/AVIF images up to 2 MB from the catalog's own HTTPS URLs. The page loads covers from `/api/greatest-cover`, which the browser caches for a year; until a copy exists that route redirects to the catalog URL. Hotlinking about 100 covers per page load made the image CDN drop some of them at random. `GREATEST_COVER_WARM=0` switches the downloads off.
Public catalog chapter counts are often absent; the UI says “Chapters unknown” rather than inventing one. For owned titles, the local library count wins. Status comes from AniList's releasing/finished flag or local library metadata, and may be unknown. Official source links may be publisher editions, chapter services, or an award/info lead; they do not imply all chapters are available in every country. No unverified hoster coverage is claimed. Public catalog chapter counts are often absent; the UI says “Chapters unknown” rather than inventing one. For owned titles, the local library count wins. Status comes from AniList's releasing/finished flag or local library metadata, and may be unknown. Official source links may be publisher editions, chapter services, or an award/info lead; they do not imply all chapters are available in every country. No unverified hoster coverage is claimed.
+78
View File
@@ -0,0 +1,78 @@
# Performance trace, load test and read-ahead
When pages load slowly, the trace shows where the time goes: the storage (a NAS, a disk waking up), the
reader process (CPU, event loop), library scans running at the same time, or the network to the browser.
## Switching the trace on and off
No restart is needed. The reader checks for a flag file every 5 seconds:
```bash
touch "$DATA/perf/trace.on" # on (Docker: the data folder mounted at /data)
rm "$DATA/perf/trace.on" # off
```
`PERF_TRACE=1` in the environment keeps it on from the start. While it is off, the reader does no extra work.
The trace goes to `DATA/perf/trace-YYYY-MM-DD.jsonl` (UTC days), one JSON object per line. Files older than
14 days are deleted; one day stops growing at 256 MB. The file contains request paths (manga and chapter
names) and user names, so treat it like the rest of `DATA`.
## What is recorded
Every line has `t` (server time, ms since the epoch) and `k` (kind):
| `k` | Fields | Meaning |
|---|---|---|
| `req` | `m`, `path`, `status`, `ms`, `bytes`, `conn`, `aborted`; for files `stat`, `open`, `first`, `readEnd`, `disk`, `netWait`; for pages also `access` | One HTTP request. Phase fields are ms since the request arrived: `stat` file lookup done, `open` file opened, `first` first chunk read, `readEnd` last chunk read. `disk` = time spent waiting for the storage, `netWait` = time the file stream was paused because the client did not take data fast enough. `conn` > 1 means a reused keep-alive connection |
| `client` | `e`: `hello`, `res`, `view`, `stall` | Reported by the reader page while the trace is on (`public/perf.js`). `res`: the browser's resource timing of a page image (`queue`, `dns`, `connect`, `ttfb`, `download`, `total`, `size`, `server` = the reader's `stat` time, `cached`). `view`: a page turn in page/book mode, `wait` until the new page was decoded and painted, `preloaded`. `stall`: flow mode, a page on screen before its image, `wait` until it loaded. `hello`: browser, viewport, connection type |
| `nas` | `statMs`, `firstMs`, `ms`, `bytes` or `error` | Every `PERF_NAS_PROBE_SECONDS` (60) one random library page is read. Out of a large library it is almost never cached, so this is the storage's cold latency over time; seconds after a quiet period mean disks were in standby |
| `loop` | `lagP50`, `lagP99`, `lagMax`, `cpu`, `rss`, `heap` | Every 5 s: event-loop delay (ms), CPU % of one core, memory (MB) |
| `scan` | `mode`, `ms`, `changed` | Library scans (change checks every `SCAN_INTERVAL`, full reconciliations) |
| `ahead` | `tag`, `files`, `done`, `skipped`, `bytes`, `ms`, `replaced` | A read-ahead batch (below) |
| `scraper` | `endpoint`, `ms`, `status`/`error` | Calls from the reader to the scraper daemon |
| `trace` | `on` | The trace was switched on or off |
## Reading the result
```bash
node tools/perf-report.js "$DATA/perf" # all days
node tools/perf-report.js "$DATA/perf" --since 2026-10-29T18:00Z --slow 300
docker compose exec reader node tools/perf-report.js /data/perf # inside the container
```
The report starts with a verdict (which phase dominates the slow loads), then percentiles for the server's
phases, the browser's timings, page turns, flow stalls, the storage probe, the event loop, scans, read-ahead
and the API routes. `--json` prints the same numbers for scripts and agents.
How to read it:
- **Slow `firstRead` / `readRest`, slow `nas` probes**: the storage is slow. Probes of several seconds after
minutes without reads point at disk standby (on a Synology: *Control Panel → Hardware & Power → HDD
Hibernation*).
- **Slow `stat`**: metadata lookups on the storage are slow (often during a scan, see `scan`).
- **High `netWait`, or browser `download` much longer than the server's `ms`**: the network between server
and browser (Wi-Fi, VPN, a proxy).
- **Browser `queue`**: the browser waited for a free connection (too many parallel requests).
- **High `lagMax`**: the reader process was busy and could not answer.
## Load test
`tools/perf-bench.js` simulates readers paging through random chapters with a think time and a small
look-ahead, and reports how long a reader looked at an empty page (`wait`), time to first byte and total
per page. It only reads (no progress is written). Use a separate account:
```bash
BENCH_PASSWORD=… node tools/perf-bench.js --url http://server:8080 --user bench --password-env BENCH_PASSWORD \
--readers 4 --chapters 3 --pages 40 --think 400 --ahead 2
```
Run it with the trace on to see the server's side of the same requests.
## Read-ahead
`READ_AHEAD=1` (default): when a chapter is opened, the reader reads its pages once in page order, starting
at the saved page, two at a time, and then the next chapter's. The data is thrown away; the OS page cache
keeps it, so the browser's requests are answered from memory instead of the storage. Opening another
chapter replaces whatever is still queued. `READ_AHEAD=0` turns it off (useful on a local SSD, or to compare
with the trace).
+10
View File
@@ -144,6 +144,16 @@ history from which those values can be reconstructed.
## 5. Later: Reading Journal ## 5. Later: Reading Journal
Update 2026-11-03: the activity calendar exists as **Settings → Reading activity**
(`reading-activity.js`, `db.activity`). It stores active reader time per user in UTC
buckets: 6 minutes for the last 120 days, then folded into hours, dropped after three
years. Each bucket keeps only its total and the time per manga. All active reader time
counts there, including visits below the 60% threshold, because it answers "when did I
read", not "how long did a chapter take". The first use seeds the buckets from the last
eight visits per chapter the clock still remembers; older history cannot be recovered.
Finished chapters come from the existing progress `doneAt` and need no clock.
Deleting reading-time history (all, or one manga) deletes the matching activity.
If readers enjoy Reading Story, add bounded daily aggregates rather than raw activity If readers enjoy Reading Story, add bounded daily aggregates rather than raw activity
events: events:
+2 -1
View File
@@ -199,7 +199,8 @@ All responses are JSON. When `SCRAPER_TOKEN` is set, send
| Method | Path | Body / result | | Method | Path | Body / result |
|---|---|---| |---|---|---|
| `GET` | `/health` | Basic liveness | | `GET` | `/health` | Basic liveness |
| `GET` | `/api/state` | Queue, all active source checkpoints, settings, errors, history, adapters | | `GET` | `/api/state` | Queue, all active source checkpoints, settings, errors, history, adapters. The library summary in it is rebuilt in the background after a publish, or when it is 15 minutes old (the walk reads every `chapter.json`) |
| `GET` | `/api/revision` | `{ publishedRevision, status, paused }` only; the reader polls this every 15 s |
| `POST` | `/api/queue` | `{url,title?,metadataSearch?,scope?,existingPolicy?,libraryMangaId?,inspect?}` | | `POST` | `/api/queue` | `{url,title?,metadataSearch?,scope?,existingPolicy?,libraryMangaId?,inspect?}` |
| `POST` | `/api/queue/remove` | `{id}`; active downloads cannot be removed | | `POST` | `/api/queue/remove` | `{id}`; active downloads cannot be removed |
| `POST` | `/api/queue/clear-completed` | `{source?}`; remove inactive successful download job rows only; preserve library, history, checkpoints and other statuses | | `POST` | `/api/queue/clear-completed` | `{source?}`; remove inactive successful download job rows only; preserve library, history, checkpoints and other statuses |
+3 -1
View File
@@ -27,7 +27,7 @@ heading when switching within Settings.
## Routes and ownership ## Routes and ownership
Personal routes: `settings.html#user/reading`, `#user/reading-time`, Personal routes: `settings.html#user/reading`, `#user/reading-time`, `#user/activity`,
`#user/notifications`, `#user/account`. `#user/notifications`, `#user/account`.
Admin routes: `settings.html#admin/scraper`, `#admin/library`, `#admin/users`. Admin routes: `settings.html#admin/scraper`, `#admin/library`, `#admin/users`.
@@ -63,6 +63,8 @@ Service health also holds the **Backups** card: built-in backup status (last run
size, duration, error), **Back up now**, and the daily schedule and retention form, size, duration, error), **Back up now**, and the daily schedule and retention form,
whose draft survives the page's polling. See [backup.md](backup.md). whose draft survives the page's polling. See [backup.md](backup.md).
Reading activity is a grid of months, weeks, days or hours (`public/activity.js`). Each cell glows with its total and holds a strip from the period's start (left) to its end (right): days in a month, quarter days in a week, hours in a day, 6 minutes in an hour. Clicking a month or week opens its days (the week is marked), a day opens its hours; hover, keyboard focus or a first tap shows details. It measures reading time (needs the Active Reading Clock) or finished chapters (from progress, always available).
Reading-time collection controls, export and deletion live under Reading time & Reading-time collection controls, export and deletion live under Reading time &
privacy; reader layout controls stay under Reading. Rescan controls and user privacy; reader layout controls stay under Reading. Rescan controls and user
administration are separate. Server & library describes existing maintenance, administration are separate. Server & library describes existing maintenance,
+73
View File
@@ -0,0 +1,73 @@
'use strict';
// Local copies of the Greatest catalog's cover images. Hotlinking ~100 covers per page
// load makes the image CDN drop some of them at random; one polite download each fixes that.
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const TYPES = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif', 'image/avif': '.avif' };
const EXTENSIONS = new Set(Object.values(TYPES));
const USER_AGENT = 'MangaCircus-Greatest/1.0';
class GreatestCovers {
constructor(dir, { fetchImpl = fetch, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
delayMs = 1000, maxBytes = 2 * 1024 * 1024, timeoutMs = 20000 } = {}) {
this.dir = path.resolve(dir);
Object.assign(this, { fetchImpl, sleep, delayMs, maxBytes, timeoutMs });
this.files = new Map(); // key -> file name
try {
for (const name of fs.readdirSync(this.dir)) {
const ext = path.extname(name);
if (EXTENSIONS.has(ext)) this.files.set(path.basename(name, ext), name);
}
} catch (error) { if (error.code !== 'ENOENT') throw error; }
}
// A hash of the URL names the file, so a changed catalog URL is fetched again.
version(url) { return crypto.createHash('sha256').update(String(url)).digest('hex').slice(0, 16); }
file(url) {
const name = url ? this.files.get(this.version(url)) : null;
return name ? path.join(this.dir, name) : null;
}
// Sequential, paced, and stops on 403/429 like every other outbound client here.
async warm(urls) {
const result = { fetched: 0, skipped: 0, failed: 0, stopped: null };
let requested = false;
for (const url of new Set(urls.filter(url => /^https:\/\/\S+$/i.test(String(url || ''))))) {
if (this.file(url)) { result.skipped++; continue; }
if (requested) await this.sleep(this.delayMs);
requested = true;
try {
await this.download(url);
result.fetched++;
} catch (error) {
if (error.stop) { result.stopped = error.message; break; }
result.failed++;
}
}
// Duplicates and repeats of stored covers count as skipped.
result.skipped += urls.filter(Boolean).length - new Set(urls.filter(Boolean)).size;
return result;
}
async download(url) {
const response = await this.fetchImpl(url, { headers: { 'user-agent': USER_AGENT },
signal: AbortSignal.timeout(this.timeoutMs) });
if ([401, 403, 429].includes(response.status)) throw Object.assign(new Error(`HTTP ${response.status}`), { stop: true });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const ext = TYPES[String(response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()];
if (!ext) throw new Error('Not an image');
if (Number(response.headers.get('content-length')) > this.maxBytes) throw new Error('Image too large');
const body = Buffer.from(await response.arrayBuffer());
if (!body.length || body.length > this.maxBytes) throw new Error('Image too large or empty');
const key = this.version(url), name = key + ext;
fs.mkdirSync(this.dir, { recursive: true });
const temp = path.join(this.dir, `.${key}.${crypto.randomBytes(4).toString('hex')}.tmp`);
try {
fs.writeFileSync(temp, body);
fs.renameSync(temp, path.join(this.dir, name));
} finally { fs.rmSync(temp, { force: true }); }
this.files.set(key, name);
}
}
module.exports = { GreatestCovers };
+77
View File
@@ -0,0 +1,77 @@
'use strict';
/*
* Moments: a page, or a region across up to four neighbouring pages, that a reader saved with exactly one category.
* `layout` says how those pages sit together: side by side ("row", Page and Book mode) or stacked ("column", Flow).
* Stored per user in db.moments[user] as a list. A moment points at its pages by version folder and file
* name (plus the file's SHA-256 for a later relocation), so a re-scan that shifts page numbers never makes it
* point at the wrong picture: a file that is gone shows as "page moved" instead.
*/
const CATEGORIES = ['twist', 'hot', 'awesome', 'cute', 'funny', 'feels'];
const NOTE_MAX = 200, MAX_PER_USER = 5000, MAX_PAGES = 4, LAYOUTS = ['row', 'column'];
class MomentError extends Error { constructor(msg) { super(msg); this.code = 400; } }
const fail = msg => { throw new MomentError(msg); };
const clamp01 = x => Math.min(1, Math.max(0, x));
// A region is a rectangle in fractions (0–1) of the displayed page, or of all its pages put together in `layout`.
// Nothing or a rectangle covering (almost) everything means the whole page.
function cleanRegion(r) {
if (r == null) return null;
const n = ['x', 'y', 'w', 'h'].map(k => Number(r[k]));
if (n.some(v => !Number.isFinite(v))) fail('Invalid region.');
const x = clamp01(n[0]), y = clamp01(n[1]), w = clamp01(Math.min(n[2], 1 - x)), h = clamp01(Math.min(n[3], 1 - y));
if (w < 0.01 || h < 0.01) fail('The selected region is too small.');
if (w > 0.995 && h > 0.995) return null;
const round = v => Math.round(v * 1e4) / 1e4;
return { x: round(x), y: round(y), w: round(w), h: round(h) };
}
function cleanCategory(cat) {
return CATEGORIES.includes(cat) ? cat : fail('Pick one category: ' + CATEGORIES.join(', ') + '.');
}
const cleanNote = note => String(note == null ? '' : note).replace(/\s+/g, ' ').trim().slice(0, NOTE_MAX);
// Page indexes (into the version's full page list) in displayed order: left to right, or top to bottom.
function cleanPages(pages, version) {
if (!Array.isArray(pages) || pages.length < 1 || pages.length > MAX_PAGES) fail(`A moment covers 1 to ${MAX_PAGES} pages.`);
const out = pages.map(Number);
if (out.some(i => !Number.isInteger(i) || i < 0 || i >= version.pages.length)) fail('Page not found.');
if (new Set(out).size !== out.length) fail('A page appears twice.');
return out;
}
const cleanLayout = layout => (layout == null ? 'row' : LAYOUTS.includes(layout) ? layout : fail('Invalid page layout.'));
// Where a stored moment's pages are now. Returns null when the chapter, the version or a page file is gone.
function locate(moment, manga) {
const chapter = manga && manga.chapterById.get(moment.c), version = chapter && chapter.versions.find(v => v.id === moment.v);
if (!version) return null;
const pages = moment.f.map((file, k) => {
const at = moment.i[k];
return version.pages[at] && version.pages[at].f === file ? at : version.pages.findIndex(p => p.f === file);
});
return pages.every(i => i >= 0) ? { chapter, version, pages } : null;
}
// The browser's view of a moment. `url` turns a library-relative path into an image URL.
function view(moment, manga, url) {
const at = locate(moment, manga);
const chapter = at ? at.chapter : manga && manga.chapterById.get(moment.c);
return {
id: moment.id, c: moment.c, v: moment.v, cat: moment.cat, note: moment.note || '', t: moment.t, region: moment.region || null,
layout: moment.layout || 'row',
chapter: chapter ? { label: chapter.label, title: chapter.title, book: chapter.book } : { label: moment.c, title: '', book: '' },
// Story order: chapter position in the manga, then page, then how far down the page the region starts.
order: [chapter ? manga.chapters.indexOf(chapter) : 1e9, at ? Math.min(...at.pages) : moment.i[0], moment.region ? moment.region.y : 0],
missing: !at,
pages: at ? at.pages.map(i => {
const p = at.version.pages[i];
return { i, u: url(at.version.rel + '/' + p.f), w: p.w, h: p.h, r: p.r || 0 };
}) : [],
};
}
function storyCompare(a, b) {
for (let k = 0; k < 3; k++) if (a.order[k] !== b.order[k]) return a.order[k] - b.order[k];
return a.t - b.t;
}
module.exports = { CATEGORIES, NOTE_MAX, MAX_PER_USER, MAX_PAGES, MomentError, cleanRegion, cleanCategory, cleanNote, cleanPages, cleanLayout, locate, view, storyCompare };
+78
View File
@@ -0,0 +1,78 @@
'use strict';
/*
* Read-ahead for network libraries: when a chapter is opened, its pages (then the next chapter's) are read
* once in page order, a few at a time, so the OS page cache holds them before the browser asks. On a NAS
* with spinning disks a cold page costs 10-100 ms (seconds while a disk wakes up); a cached one well under
* 5 ms. The data is discarded; only the cache effect matters.
*
* One queue per reader (`key`, the user): opening another chapter replaces that reader's queue only, and
* the queues take turns so two people reading at once both get their pages ahead.
*/
const fs = require('fs');
class ReadAhead {
constructor({ concurrency = 2, remember = 4000, onBatch = () => {} } = {}) {
this.concurrency = concurrency; this.remember = remember; this.onBatch = onBatch;
this.batches = new Map(); this.turn = []; this.active = 0; this.recent = new Map(); this.buffers = [];
}
// `files` in reading order; `tag` names the batch in the trace.
warm(files, tag, key = '') {
const old = this.batches.get(key);
if (old) this.finish(key, old, true);
const queue = files.filter(f => !this.recent.has(f));
const batch = { tag, queue, files: queue.length, skipped: files.length - queue.length, bytes: 0, done: 0, active: 0, t0: performance.now() };
this.batches.set(key, batch);
if (!this.turn.includes(key)) this.turn.unshift(key); // a fresh request goes next
if (!queue.length) this.finish(key, batch, false);
this.pump();
}
// Runs after the reader's current batch (e.g. the next chapter) unless a newer warm() replaces it first.
then(files, tag, key = '') {
const batch = this.batches.get(key);
if (!batch) this.warm(files, tag, key); else batch.next = () => this.warm(files, tag, key);
}
pump() {
while (this.active < this.concurrency) {
const key = this.turn.find(k => this.batches.get(k)?.queue.length);
if (key === undefined) return;
this.turn.push(...this.turn.splice(this.turn.indexOf(key), 1)); // round robin between readers
const batch = this.batches.get(key), file = batch.queue.shift();
this.active++; batch.active++;
this.readFile(file).then(bytes => { this.note(file); batch.bytes += bytes; batch.done++; }, () => {}).finally(() => {
this.active--; batch.active--;
if (this.batches.get(key) === batch && !batch.queue.length && !batch.active) this.finish(key, batch, false);
this.pump();
});
}
}
finish(key, b, replaced) {
if (this.batches.get(key) === b) this.batches.delete(key);
if (!this.batches.has(key)) this.turn = this.turn.filter(k => k !== key);
b.queue = [];
this.onBatch({ tag: b.tag, files: b.files, done: b.done, skipped: b.skipped, bytes: b.bytes,
ms: Math.round(performance.now() - b.t0), replaced });
if (!replaced && b.next) b.next();
}
note(file) {
this.recent.delete(file); this.recent.set(file, 1);
while (this.recent.size > this.remember) this.recent.delete(this.recent.keys().next().value);
}
async readFile(file) {
const buf = this.buffers.pop() || Buffer.allocUnsafe(256 * 1024);
const fh = await fs.promises.open(file, 'r');
let total = 0;
try {
for (;;) {
const { bytesRead } = await fh.read(buf, 0, buf.length, total);
if (!bytesRead) break;
total += bytesRead;
}
} finally { await fh.close(); this.buffers.push(buf); }
return total;
}
}
module.exports = { ReadAhead };
+173
View File
@@ -0,0 +1,173 @@
'use strict';
/*
* Switchable performance trace: one JSON object per line in DATA/perf/trace-YYYY-MM-DD.jsonl.
*
* On while PERF_TRACE=1 or while the file DATA/perf/trace.on exists (checked every few seconds, so it can be
* switched on a running server with `touch` / `rm`). Off, every call is a cheap no-op.
*
* Event kinds (field `k`): `req` one HTTP request with its phases, `client` timings the reader page reports,
* `loop` event-loop lag / CPU / memory every SAMPLE_MS, `nas` a periodic read of one uncached library page,
* `scan` library scans, `scraper` calls to the scraper daemon, `ahead` read-ahead batches, `trace` on/off.
* The format and the report tool: docs/performance.md, tools/perf-report.js.
*/
const fs = require('fs'), path = require('path'), { monitorEventLoopDelay } = require('perf_hooks');
const CHECK_MS = 5000, FLUSH_MS = 1000, SAMPLE_MS = 5000;
const MAX_DAY_BYTES = 256 * 1024 * 1024; // a runaway trace never fills the data disk
const KEEP_DAYS = 14;
class Tracer {
constructor({ dir, env = process.env, now = Date.now } = {}) {
this.dir = dir; this.now = now;
this.forced = env.PERF_TRACE === '1';
this.flag = path.join(dir, 'trace.on');
this.on = false; this.buf = []; this.day = ''; this.dayBytes = 0; this.dropped = 0;
this.timers = []; this.probe = null; this.probeMs = Math.max(10, +env.PERF_NAS_PROBE_SECONDS || 60) * 1000;
}
start() {
this.check();
this.timers.push(setInterval(() => this.check(), CHECK_MS).unref());
this.timers.push(setInterval(() => this.flush(), FLUSH_MS).unref());
return this;
}
stop() { for (const t of this.timers) clearInterval(t); this.timers = []; this.setOn(false); this.flush(); }
check() {
let on = this.forced;
if (!on) try { fs.accessSync(this.flag); on = true; } catch { /* off */ }
this.setOn(on);
}
setOn(on) {
if (on === this.on) return;
if (on) {
this.on = true;
this.log({ k: 'trace', on: true, pid: process.pid, node: process.version });
this.startSampler();
} else {
this.log({ k: 'trace', on: false });
this.on = false;
this.stopSampler();
}
}
log(event) {
if (!this.on) return;
this.buf.push(JSON.stringify({ t: this.now(), ...event }));
if (this.buf.length >= 500) this.flush();
}
flush() {
if (!this.buf.length) return;
const lines = this.buf.join('\n') + '\n'; this.buf = [];
const day = new Date(this.now()).toISOString().slice(0, 10);
try {
if (day !== this.day) { this.day = day; this.dayBytes = 0; fs.mkdirSync(this.dir, { recursive: true }); this.prune(); }
if (this.dayBytes + lines.length > MAX_DAY_BYTES) { this.dropped += lines.length; return; }
fs.appendFileSync(path.join(this.dir, `trace-${day}.jsonl`), lines);
this.dayBytes += lines.length;
} catch (error) { console.warn('[perf] trace write failed:', error.message); }
}
prune() {
const cutoff = new Date(this.now() - KEEP_DAYS * 864e5).toISOString().slice(0, 10);
for (const name of fs.readdirSync(this.dir)) {
const m = /^trace-(\d{4}-\d\d-\d\d)\.jsonl$/.exec(name);
if (m && m[1] < cutoff) fs.rmSync(path.join(this.dir, name), { force: true });
}
}
// Event-loop delay shows whether the reader itself (CPU, sync work) holds requests back.
startSampler() {
const h = monitorEventLoopDelay({ resolution: 10 }); h.enable();
let cpu = process.cpuUsage(), at = process.hrtime.bigint();
this.sampler = { h, timer: setInterval(() => {
const nowCpu = process.cpuUsage(), nowAt = process.hrtime.bigint(), wall = Number(nowAt - at) / 1000;
const mem = process.memoryUsage();
this.log({ k: 'loop', lagP50: ms(h.percentile(50)), lagP99: ms(h.percentile(99)), lagMax: ms(h.max),
cpu: round(((nowCpu.user - cpu.user) + (nowCpu.system - cpu.system)) / wall * 100), rss: mb(mem.rss), heap: mb(mem.heapUsed) });
h.reset(); cpu = nowCpu; at = nowAt;
}, SAMPLE_MS).unref() };
this.startProbe();
}
stopSampler() {
if (this.sampler) { clearInterval(this.sampler.timer); this.sampler.h.disable(); this.sampler = null; }
if (this.probe) { clearInterval(this.probe); this.probe = null; }
}
// NAS probe: read one random library page now and then. A page picked at random out of a large library is
// almost never in the OS cache, so this measures the storage itself (and shows disks waking from standby).
setProbeSource(pick) { this.pick = pick; if (this.on) this.startProbe(); }
startProbe() {
if (this.probe || !this.pick) return;
const run = () => this.probeOnce().catch(() => {});
this.probe = setInterval(run, this.probeMs).unref();
setTimeout(run, 1000).unref();
}
async probeOnce() {
const file = this.pick && this.pick();
if (!file) return;
const t0 = performance.now();
let st, bytes = 0, firstMs = null, error = null;
try {
st = await fs.promises.stat(file);
const statMs = performance.now() - t0;
const fh = await fs.promises.open(file, 'r');
try {
const chunk = Buffer.allocUnsafe(256 * 1024);
for (;;) {
const { bytesRead } = await fh.read(chunk, 0, chunk.length, bytes);
if (firstMs === null) firstMs = performance.now() - t0;
if (!bytesRead) break;
bytes += bytesRead;
}
} finally { await fh.close(); }
this.log({ k: 'nas', statMs: round(statMs), firstMs: round(firstMs), ms: round(performance.now() - t0), bytes });
} catch (e) { error = e.code || e.message; this.log({ k: 'nas', ms: round(performance.now() - t0), error }); }
}
}
// Wraps one HTTP request: `mark(name)` stores the time since the request arrived, `set` adds fields, and the
// event is written when the response is done (or the client went away). Off, returns a shared no-op.
const NOOP = { mark() {}, set() {}, stream() {}, elapsed: () => 0, on: false };
function requestTrace(tracer, req, res) {
if (!tracer.on) return NOOP;
const t0 = performance.now(), ev = { k: 'req', m: req.method, path: req.url.split('?')[0].slice(0, 300) };
const trace = {
on: true,
mark(name) { ev[name] = round(performance.now() - t0); },
elapsed: () => round(performance.now() - t0),
set(fields) { Object.assign(ev, fields); },
// Splits a file stream's time into waiting for the disk and waiting for the client (TCP backpressure):
// pipe() pauses the file stream while the socket buffer is full and resumes it on 'drain'.
stream(src) {
let pausedAt = 0, waitNet = 0;
src.on('open', () => trace.mark('open'));
src.once('data', () => trace.mark('first'));
src.on('data', chunk => { ev.bytes = (ev.bytes || 0) + chunk.length; });
src.on('pause', () => { pausedAt = performance.now(); });
src.on('resume', () => { if (pausedAt) { waitNet += performance.now() - pausedAt; pausedAt = 0; } });
src.on('end', () => {
trace.mark('readEnd');
ev.netWait = round(waitNet);
ev.disk = round(Math.max(0, ev.readEnd - (ev.open || 0) - waitNet));
});
},
};
let done = false;
const finish = aborted => {
if (done) return; done = true;
ev.ms = round(performance.now() - t0); ev.status = res.statusCode;
if (aborted) ev.aborted = true;
ev.conn = req.socket._perfRequests = (req.socket._perfRequests || 0) + 1; // >1: keep-alive reuse
tracer.log(ev);
};
res.on('finish', () => finish(false));
res.on('close', () => finish(!res.writableFinished));
return trace;
}
const round = v => (v == null || Number.isNaN(v) ? null : Math.round(v * 10) / 10);
const ms = ns => round(ns / 1e6);
const mb = b => Math.round(b / 1048576);
module.exports = { Tracer, requestTrace };
+167
View File
@@ -0,0 +1,167 @@
'use strict';
/* Reading activity grid: periods, layouts and sums in the browser's local time.
Pure functions, so the node tests can run them. The server only sends UTC buckets. */
const ACTIVITY_VIEWS = ['month', 'week', 'day', 'hour'];
const ACTIVITY_DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const ACTIVITY_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const ACTIVITY_QUARTERS = ['night', 'morning', 'afternoon', 'evening'];
const ACTIVITY_HOUR_MS = 3600000, ACTIVITY_SLICES = 10;
// Start of the local month, Monday-based week, day or hour containing t.
function periodStart(level, t) {
const d = new Date(t);
if (level === 'hour') { d.setMinutes(0, 0, 0); return d.getTime(); }
d.setHours(0, 0, 0, 0);
if (level === 'week') d.setDate(d.getDate() - (d.getDay() + 6) % 7);
if (level === 'month') d.setDate(1);
return d.getTime();
}
// n periods after a period start; calendar arithmetic keeps midnight across DST changes.
function periodShift(level, start, n) {
if (level === 'hour') return start + n * ACTIVITY_HOUR_MS;
const d = new Date(start);
if (level === 'month') d.setMonth(d.getMonth() + n);
else d.setDate(d.getDate() + n * (level === 'week' ? 7 : 1));
return d.getTime();
}
function isoWeek(t) {
const d = new Date(t); d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + 3 - (d.getDay() + 6) % 7); // Thursday of this week decides the year
const jan4 = new Date(d.getFullYear(), 0, 4);
return 1 + Math.round(((d - jan4) / 864e5 - 3 + (jan4.getDay() + 6) % 7) / 7);
}
const dayText = t => { const d = new Date(t); return `${d.getDate()} ${ACTIVITY_MONTHS[d.getMonth()]}`; };
const clockText = t => { const d = new Date(t); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
// Inside a cell: month → its days, week → quarter days, day → hours, hour → ten 6-minute slices.
function segmentBounds(level, start) {
const end = periodShift(level, start, 1), bounds = [start];
if (level === 'month') for (let t = periodShift('day', start, 1); t < end; t = periodShift('day', t, 1)) bounds.push(t);
else if (level === 'week') {
for (let day = start; day < end; day = periodShift('day', day, 1)) {
for (const hour of [6, 12, 18]) { const d = new Date(day); d.setHours(hour, 0, 0, 0); bounds.push(d.getTime()); }
if (periodShift('day', day, 1) < end) bounds.push(periodShift('day', day, 1));
}
} else if (level === 'day') {
for (let hour = 1; hour < 24; hour++) { const d = new Date(start); d.setHours(hour, 0, 0, 0); if (d.getTime() > bounds[bounds.length - 1] && d.getTime() < end) bounds.push(d.getTime()); }
} else for (let i = 1; i < ACTIVITY_SLICES; i++) bounds.push(start + i * ACTIVITY_HOUR_MS / ACTIVITY_SLICES);
bounds.push(end);
return bounds;
}
function segmentText(level, from, to) {
if (level === 'month') return dayText(from);
if (level === 'week') return `${ACTIVITY_DAY_NAMES[(new Date(from).getDay() + 6) % 7]} ${ACTIVITY_QUARTERS[Math.floor(new Date(from).getHours() / 6)]}`;
return `${clockText(from)}–${clockText(to)}`;
}
function cellFor(level, start, extra = {}) {
const d = new Date(start), end = periodShift(level, start, 1);
const label = level === 'month' ? ACTIVITY_MONTHS[d.getMonth()] : level === 'week' ? `W${isoWeek(start)}`
: level === 'day' ? String(d.getDate()) : String(d.getHours()).padStart(2, '0');
const title = level === 'month' ? `${ACTIVITY_MONTHS[d.getMonth()]} ${d.getFullYear()}`
: level === 'week' ? `Week ${isoWeek(start)} · ${dayText(start)} – ${dayText(periodShift('day', end, -1))} ${new Date(end - 1).getFullYear()}`
: level === 'day' ? `${ACTIVITY_DAY_NAMES[(d.getDay() + 6) % 7]} ${dayText(start)} ${d.getFullYear()}`
: `${dayText(start)} · ${clockText(start)}–${clockText(end)}`;
return { level, start, end, label, title, sub: level === 'week' ? dayText(start) : '', ...extra };
}
// The grid for one view around `anchor`: rows of cells, plus where the arrows lead.
function activityLayout(view, anchor) {
const rows = [];
if (view === 'month') {
const year = new Date(anchor).getFullYear();
for (const y of [year - 1, year]) {
rows.push({ label: String(y), cells: ACTIVITY_MONTHS.map((_, m) => cellFor('month', new Date(y, m, 1).getTime())) });
}
return { view, rows, columns: 12, title: `${year - 1} – ${year}`, step: ['month', 12], from: rows[0].cells[0].start, to: rows[1].cells[11].end };
}
if (view === 'week') {
const last = periodStart('month', anchor);
for (let i = 5; i >= 0; i--) {
const month = periodShift('month', last, -i), next = periodShift('month', month, 1), cells = [];
// A week belongs to the month of its Thursday, so every week shows exactly once.
for (let week = periodStart('week', month); week < next; week = periodShift('week', week, 1)) {
const thursday = periodShift('day', week, 3);
if (thursday >= month && thursday < next) cells.push(cellFor('week', week));
}
const d = new Date(month);
rows.push({ label: `${ACTIVITY_MONTHS[d.getMonth()]} ${d.getFullYear()}`, cells });
}
const first = new Date(periodShift('month', last, -5)), end = new Date(last);
return { view, rows, columns: 5, title: `${ACTIVITY_MONTHS[first.getMonth()]} ${first.getFullYear()} – ${ACTIVITY_MONTHS[end.getMonth()]} ${end.getFullYear()}`,
step: ['month', 6], from: rows[0].cells[0].start, to: rows[5].cells[rows[5].cells.length - 1].end };
}
if (view === 'day') {
const month = periodStart('month', anchor), next = periodShift('month', month, 1), d = new Date(month);
for (let week = periodStart('week', month); week < next; week = periodShift('week', week, 1)) {
const cells = [];
for (let i = 0; i < 7; i++) { const day = periodShift('day', week, i); cells.push(cellFor('day', day, { outside: day < month || day >= next })); }
rows.push({ label: `W${isoWeek(week)}`, week, cells });
}
return { view, rows, columns: 7, header: ACTIVITY_DAY_NAMES, title: `${['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][d.getMonth()]} ${d.getFullYear()}`,
step: ['month', 1], from: rows[0].cells[0].start, to: rows[rows.length - 1].cells[6].end };
}
const day = periodStart('day', anchor), d = new Date(day);
for (let q = 0; q < 4; q++) {
const cells = [];
for (let i = 0; i < 6; i++) { const t = new Date(day); t.setHours(q * 6 + i, 0, 0, 0); cells.push(cellFor('hour', t.getTime())); }
rows.push({ label: ACTIVITY_QUARTERS[q][0].toUpperCase() + ACTIVITY_QUARTERS[q].slice(1), cells });
}
return { view, rows, columns: 6, title: `${ACTIVITY_DAY_NAMES[(d.getDay() + 6) % 7]} ${dayText(day)} ${d.getFullYear()}`,
step: ['day', 1], from: day, to: periodShift('day', day, 1) };
}
// Sums the server's buckets ([start, size, ms, [[manga, ms]…]]) and completions ([t, manga, label])
// into each cell's segments. A bucket split across segments is shared by overlap.
function activitySums(data, cells) {
const segs = [];
for (const cell of cells) {
cell.bounds = segmentBounds(cell.level, cell.start);
const n = cell.bounds.length - 1;
cell.time = new Array(n).fill(0); cell.chapters = new Array(n).fill(0);
cell.manga = new Map(); cell.done = [];
for (let i = 0; i < n; i++) segs.push({ s: cell.bounds[i], e: cell.bounds[i + 1], cell, i });
}
segs.sort((a, b) => a.s - b.s);
const firstEndingAfter = t => { let lo = 0, hi = segs.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (segs[mid].e > t) hi = mid; else lo = mid + 1; } return lo; };
for (const [start, size, ms, mangas] of (data && data.buckets) || []) {
const end = start + size;
for (let k = firstEndingAfter(start); k < segs.length && segs[k].s < end; k++) {
const seg = segs[k], share = (Math.min(seg.e, end) - Math.max(seg.s, start)) / size;
if (share <= 0) continue;
seg.cell.time[seg.i] += ms * share;
for (const [manga, mangaMs] of mangas || []) seg.cell.manga.set(manga, (seg.cell.manga.get(manga) || 0) + mangaMs * share);
}
}
for (const item of (data && data.done) || []) {
const k = firstEndingAfter(item[0]), seg = segs[k];
if (!seg || seg.s > item[0]) continue;
seg.cell.chapters[seg.i]++;
seg.cell.done.push(item);
}
for (const cell of cells) {
cell.totalTime = cell.time.reduce((a, b) => a + b, 0);
cell.totalChapters = cell.chapters.reduce((a, b) => a + b, 0);
}
return cells;
}
// 0…1 glow, square-root scaled so one long evening does not flatten everything else.
const activityGlow = (value, max) => (value > 0 && max > 0 ? Math.min(1, Math.sqrt(value / max)) : 0);
// Where a click on a cell leads: month → its days, week → the days of its month (week marked), day → hours.
function activityDrill(cell) {
if (cell.level === 'month') return { view: 'day', anchor: cell.start };
if (cell.level === 'week') return { view: 'day', anchor: periodShift('day', cell.start, 3), mark: cell.start };
if (cell.level === 'day') return { view: 'hour', anchor: cell.start };
return null;
}
if (typeof module !== 'undefined') module.exports = {
ACTIVITY_VIEWS, periodStart, periodShift, isoWeek, segmentBounds, segmentText, activityLayout, activitySums, activityGlow, activityDrill,
};
+172
View File
@@ -0,0 +1,172 @@
'use strict';
/* Settings → Reading activity: a grid of months, weeks, days or hours. Each cell glows with its total
and carries a strip from its start to its end, so you can see *when* inside the period you read.
Click drills down (month/week → days → hours); hover, focus or a first tap shows the details. */
function activityCard() {
const VIEWS = { month: 'Months', week: 'Weeks', day: 'Days', hour: 'Hours' };
const UNIT = { month: 'days', week: 'quarter days', day: 'hours', hour: '6 minutes' };
const saved = (() => { try { return JSON.parse(store.get('activity') || '{}'); } catch (e) { return {}; } })();
const state = { view: VIEWS[saved.view] ? saved.view : 'week', metric: saved.metric === 'chapters' ? 'chapters' : 'time', anchor: Date.now(), mark: null };
let data = null, armed = null, lastPointer = 'mouse';
const remember = () => store.set('activity', JSON.stringify({ view: state.view, metric: state.metric }));
const card = h('section', { class: 'card activity-card' });
const notice = h('p', { class: 'muted small', hidden: true });
const summary = h('p', { class: 'activity-summary' });
const grid = h('div', { class: 'activity-grid', role: 'grid' });
const tip = h('div', { class: 'activity-tip', role: 'tooltip', hidden: true });
const viewSeg = h('div', { class: 'seg', role: 'group', 'aria-label': 'Grid' });
const metricSeg = h('div', { class: 'seg', role: 'group', 'aria-label': 'Measure' });
const title = h('b', { class: 'activity-title' });
const legend = h('p', { class: 'muted small activity-legend' });
card.append(
h('div', { class: 'activity-toolbar' }, viewSeg, metricSeg),
h('div', { class: 'activity-nav' },
h('button', { class: 'btn small', 'aria-label': 'Earlier', onclick: () => move(-1) }, '‹'),
title,
h('button', { class: 'btn small', 'aria-label': 'Later', onclick: () => move(1) }, '›'),
h('button', { class: 'btn small', onclick: () => { state.anchor = Date.now(); state.mark = null; draw(); } }, 'Today')),
notice, summary, grid, legend, tip);
const duration = ms => readingDuration(ms, true);
const valueText = (metric, value) => metric === 'time' ? duration(value) : `${value} chapter${value === 1 ? '' : 's'}`;
const mangaTitle = index => data.mangas[index]?.title || '?';
function move(direction) {
const layout = activityLayout(state.view, state.anchor);
state.anchor = periodShift(layout.step[0], periodStart(layout.step[0], state.anchor), direction * layout.step[1]);
state.mark = null;
draw();
}
function setView(view, anchor = state.anchor, mark = null) {
state.view = view; state.anchor = anchor; state.mark = mark;
remember(); draw();
}
function drawControls() {
viewSeg.replaceChildren(...Object.entries(VIEWS).map(([key, label]) =>
h('button', { class: key === state.view ? 'on' : '', 'aria-pressed': String(key === state.view), onclick: () => setView(key) }, label)));
metricSeg.replaceChildren(...[['time', 'Reading time'], ['chapters', 'Chapters finished']].map(([key, label]) =>
h('button', { class: key === state.metric ? 'on' : '', 'aria-pressed': String(key === state.metric), onclick: () => { state.metric = key; remember(); draw(); } }, label)));
}
function details(cell) {
const time = cell.totalTime, chapters = cell.totalChapters;
const lines = [h('b', {}, cell.title)];
if (cell.start > Date.now()) { lines.push(h('div', { class: 'muted' }, 'Still ahead')); return lines; }
lines.push(h('div', {}, time >= 30000 ? duration(time) + ' reading' : 'No reading time', ' · ', valueText('chapters', chapters) + ' finished'));
const values = state.metric === 'time' ? cell.time : cell.chapters, active = values.filter(v => v > 0).length;
if (active) {
const peak = values.indexOf(Math.max(...values));
lines.push(h('div', { class: 'muted' }, `Active in ${active} of ${values.length} ${UNIT[cell.level]} · busiest ${segmentText(cell.level, cell.bounds[peak], cell.bounds[peak + 1])}`));
}
const top = [...cell.manga].filter(([, ms]) => ms >= 30000).sort((a, b) => b[1] - a[1]).slice(0, 3);
if (top.length) lines.push(h('ul', {}, top.map(([index, ms]) => h('li', {}, mangaTitle(index), h('span', { class: 'muted' }, ' · ' + duration(ms))))));
if (cell.level === 'hour' && cell.done.length) {
lines.push(h('ul', {}, cell.done.slice(0, 6).map(([t, index, label]) => h('li', {}, `✓ ${mangaTitle(index)} · ${label}`,
h('span', { class: 'muted' }, ' · ' + new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))))));
} else if (!top.length && cell.done.length) {
const counts = new Map();
for (const [, index] of cell.done) counts.set(index, (counts.get(index) || 0) + 1);
lines.push(h('ul', {}, [...counts].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([index, n]) => h('li', {}, mangaTitle(index), h('span', { class: 'muted' }, ` · ${n} ✓`)))));
}
if (activityDrill(cell)) lines.push(h('div', { class: 'muted small' }, lastPointer === 'touch' ? 'Tap again to open' : 'Click to open'));
return lines;
}
function showTip(cell, el) {
tip.replaceChildren(...details(cell));
tip.hidden = false;
const box = el.getBoundingClientRect(), width = tip.offsetWidth, height = tip.offsetHeight;
const left = Math.max(8, Math.min(window.innerWidth - width - 8, box.left + box.width / 2 - width / 2));
const top = box.top - height - 8 > 8 ? box.top - height - 8 : box.bottom + 8;
tip.style.left = left + 'px'; tip.style.top = top + 'px';
}
const hideTip = () => { tip.hidden = true; armed = null; };
function cellNode(cell, maxCell, maxSeg) {
const values = state.metric === 'time' ? cell.time : cell.chapters, total = state.metric === 'time' ? cell.totalTime : cell.totalChapters;
const now = Date.now(), future = cell.start > now;
const glow = activityGlow(total, maxCell);
const strip = h('span', { class: 'activity-strip', 'aria-hidden': 'true' }, values.map((v, i) =>
h('i', { class: cell.bounds[i] > now ? 'ahead' : '', style: `--a:${activityGlow(v, maxSeg).toFixed(3)}` })));
const node = h('button', {
type: 'button', role: 'gridcell',
class: 'activity-cell' + (cell.outside ? ' outside' : '') + (future ? ' future' : '') + (glow >= 0.85 ? ' hot' : '')
+ (state.mark != null && cell.level === 'day' && periodStart('week', cell.start) === state.mark ? ' marked' : '')
+ (cell.start <= now && now < cell.end ? ' now' : ''),
style: `--g:${glow.toFixed(3)}`,
'aria-label': `${cell.title}: ${future ? 'still ahead' : valueText(state.metric, total)}`,
onmouseenter: e => { if (lastPointer !== 'touch') showTip(cell, e.currentTarget); },
onmouseleave: () => { if (lastPointer !== 'touch') hideTip(); },
onfocus: e => showTip(cell, e.currentTarget),
onblur: hideTip,
onpointerdown: e => { lastPointer = e.pointerType || 'mouse'; },
onclick: e => {
// On touch the first tap shows the details; the second opens the next level.
if (lastPointer === 'touch' && armed !== cell) { armed = cell; showTip(cell, e.currentTarget); return; }
const next = activityDrill(cell);
if (next && !future) { hideTip(); setView(next.view, next.anchor, next.mark ?? null); }
},
},
h('span', { class: 'activity-label' }, cell.label, cell.sub ? h('small', {}, cell.sub) : null),
!future && total && (state.metric === 'chapters' || total >= 30000)
? h('span', { class: 'activity-value' }, state.metric === 'chapters' ? String(total)
// Month cells are narrow; whole hours are enough there, the tooltip has the minutes.
: cell.level === 'month' && total >= 36e5 ? Math.round(total / 36e5) + ' h' : duration(total)) : null,
strip);
return node;
}
function draw() {
drawControls();
hideTip();
const layout = activityLayout(state.view, state.anchor);
title.textContent = layout.title;
if (!data) { grid.replaceChildren(h('p', { class: 'muted' }, 'Loading your activity…')); return; }
const cells = layout.rows.flatMap(row => row.cells);
activitySums(data, cells);
const counted = cells.filter(cell => !cell.outside);
const key = state.metric === 'time' ? 'totalTime' : 'totalChapters', seg = state.metric === 'time' ? 'time' : 'chapters';
const maxCell = Math.max(0, ...counted.map(cell => cell[key]));
const maxSeg = Math.max(0, ...counted.flatMap(cell => cell[seg]));
grid.style.setProperty('--cols', layout.columns);
grid.className = 'activity-grid view-' + state.view;
grid.replaceChildren(
...(layout.header ? [h('div', { class: 'activity-row head', role: 'row' }, h('span', { class: 'activity-row-label' }),
h('div', { class: 'activity-cells' }, layout.header.map(name => h('span', { class: 'activity-head' }, name))))] : []),
...layout.rows.map(row => h('div', { class: 'activity-row' + (state.mark != null && row.week === state.mark ? ' marked' : ''), role: 'row' },
h('span', { class: 'activity-row-label' }, row.label),
h('div', { class: 'activity-cells' }, row.cells.map(cell => cellNode(cell, maxCell, maxSeg))))));
const time = counted.reduce((sum, cell) => sum + cell.totalTime, 0), chapters = counted.reduce((sum, cell) => sum + cell.totalChapters, 0);
const manga = new Map();
for (const cell of counted) for (const [index, ms] of cell.manga) manga.set(index, (manga.get(index) || 0) + ms);
const top = [...manga].sort((a, b) => b[1] - a[1])[0];
summary.replaceChildren(
h('b', {}, time >= 30000 ? duration(time) : 'No reading time'), ' · ', valueText('chapters', chapters), ' finished',
...(top && top[1] >= 30000 ? [' · most time with ', h('b', {}, mangaTitle(top[0]))] : []));
const coarse = layout.from < Date.now() - data.fineDays * 864e5 && state.view === 'hour';
legend.textContent = `Each cell runs from left (its start) to right (its end) in ${UNIT[layout.rows[0].cells[0].level]}; brighter means more ${state.metric === 'time' ? 'reading time' : 'finished chapters'}.`
+ (state.view === 'hour' ? '' : ' Click a cell to look closer.')
+ (coarse ? ` Time older than ${data.fineDays} days is kept per hour, so it is spread evenly inside the hour.` : '');
}
async function load() {
try {
data = await api('api/reading/activity');
notice.hidden = data.readingClock;
notice.replaceChildren('The Active Reading Clock is off, so no new reading time is recorded; finished chapters still show. Turn it on under ',
h('a', { href: 'settings.html#user/reading-time' }, 'Reading time & privacy'), '.');
if (!data.buckets.length && saved.metric == null) state.metric = 'chapters';
draw();
} catch (error) { grid.replaceChildren(h('p', { class: 'error', role: 'alert' }, `Could not load your activity: ${error.message}`)); }
}
window.addEventListener('scroll', () => { if (!tip.hidden) hideTip(); }, { passive: true });
draw();
return { element: card, activate: load, deactivate: hideTip };
}
+128
View File
@@ -94,7 +94,18 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
.prog i { display: block; height: 100%; background: var(--accent); } .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; } .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; } .empty { text-align: center; padding: 60px 10px; }
/* One fixed row; tiles sized like a six-column .grid, extra tiles scroll sideways instead of wrapping. */
.continue-row { margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
.continue-banner {
display: inline-flex; gap: 6px; align-items: center; margin-bottom: 9px; padding: 2px 9px; border-radius: 10px;
background: color-mix(in srgb, var(--accent) 22%, var(--card)); color: #ffd8de; font-size: 12px; font-weight: 650;
}
.continue-tiles {
display: grid; grid-auto-flow: column; grid-auto-columns: max(140px, calc((100% - 80px) / 6)); gap: 16px;
overflow-x: auto; padding-bottom: 4px; scrollbar-width: thin;
}
@media (max-width: 560px) { @media (max-width: 560px) {
.continue-tiles { grid-auto-columns: calc((100% - 12px) / 2); gap: 12px; }
main { padding: 12px; } main { padding: 12px; }
.toolbar { flex-wrap: wrap; } .toolbar { flex-wrap: wrap; }
.toolbar input { flex-basis: 100%; } .toolbar input { flex-basis: 100%; }
@@ -376,6 +387,8 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
.login input { width: 100%; } .login input { width: 100%; }
.saved { color: #7fd3a0; font-size: 13px; } .saved { color: #7fd3a0; font-size: 13px; }
.reading-data { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--line); } .reading-data { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--line); }
.reader-sessions { list-style: none; margin: 12px 0 0; padding: 0; }
.reader-sessions li { display: grid; gap: 4px; padding: 10px 0; border-top: 1px solid var(--line); overflow-wrap: anywhere; }
.reading-data > summary { cursor: pointer; font-weight: 600; } .reading-data > summary { cursor: pointer; font-weight: 600; }
.reading-danger { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); } .reading-danger { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
.reading-danger p { margin: 3px 0 8px; } .reading-danger p { margin: 3px 0 8px; }
@@ -418,6 +431,14 @@ body.reader { position: fixed; top: 0; left: 0; right: 0; bottom: 0; overflow: h
.reading-gate-message[data-tone="retry"] { border-color: #9b7233; background: #382b1a; color: #ffdaa0; } .reading-gate-message[data-tone="retry"] { border-color: #9b7233; background: #382b1a; color: #ffdaa0; }
.reading-gate-message[data-tone="error"] { border-color: #944c55; background: #3c2025; color: #ffc0c6; } .reading-gate-message[data-tone="error"] { border-color: #944c55; background: #3c2025; color: #ffc0c6; }
@media (max-width: 660px) { .reading-gate { margin: 28px 12px 100px; padding: 18px; } } @media (max-width: 660px) { .reading-gate { margin: 28px 12px 100px; padding: 18px; } }
/* Stale reading session: a newer position was saved elsewhere since this page was opened. */
.stale-session { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center;
padding: 16px; background: rgba(0, 0, 0, .6); }
.stale-box { box-sizing: border-box; width: min(440px, 100%); padding: 20px; border-radius: 12px; background: var(--card);
border: 1px solid var(--line); box-shadow: 0 12px 40px rgba(0, 0, 0, .6); }
.stale-box h2 { margin: 0 0 8px; font-size: 18px; }
.stale-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0 10px; }
.stale-actions .btn { flex: 1 1 auto; justify-content: center; text-align: center; }
.pg { position: absolute; overflow: hidden; background: #19191c; } .pg { position: absolute; overflow: hidden; background: #19191c; }
.pg img { display: block; width: 100%; height: 100%; -webkit-user-drag: none; } .pg img { display: block; width: 100%; height: 100%; -webkit-user-drag: none; }
#canvas[data-page-tone="inverted"] .pg img { filter: invert(1); } #canvas[data-page-tone="inverted"] .pg img { filter: invert(1); }
@@ -491,3 +512,110 @@ body.nobars #bot { transform: translateY(100%); opacity: 0; pointer-events: none
.seg button { padding: 7px 10px; } #bot .btn { padding: 6px 10px; } .wide-only { display: none; } .seg button { padding: 7px 10px; } #bot .btn { padding: 6px 10px; } .wide-only { display: none; }
.clock-panel { bottom: calc(124px + env(safe-area-inset-bottom)); } .clock-panel { bottom: calc(124px + env(safe-area-inset-bottom)); }
} }
/* ---------------- moments (BL-0055) ---------------- */
.moment-btn.on { color: var(--accent); border-color: var(--accent); }
.moment-btn.on svg path:first-child { fill: currentColor; }
.moment-dialog { width: min(640px, calc(100vw - 16px)); max-height: calc(100vh - 16px); overflow: auto; color: var(--fg); background: var(--bg);
border: 1px solid var(--line); border-radius: 12px; padding: 16px; }
.moment-dialog::backdrop { background: #000c; }
.moment-dialog h2 { margin: 0; font-size: 18px; }
.moment-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.moment-head > :first-child { flex: 1; min-width: 0; }
.moment-context { font-weight: 600; }
.moment-preview-wrap { display: flex; justify-content: center; margin: 12px 0 8px; }
.moment-preview { position: relative; line-height: 0; }
.moment-composite { position: relative; }
.moment-page { position: absolute; top: 0; overflow: hidden; background: #19191c; }
.moment-page img { display: block; width: 100%; height: 100%; -webkit-user-drag: none; user-select: none; }
.region-layer { position: absolute; inset: 0; cursor: crosshair; touch-action: none; overflow: hidden; }
.region-box { position: absolute; border: 2px solid #fff; outline: 1px solid #0008; box-shadow: 0 0 0 9999px #0009; pointer-events: none; }
.moment-region-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 32px; }
.moment-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
.moment-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 18px; border: 1px solid var(--line);
background: var(--card); color: var(--fg); font: inherit; cursor: pointer; }
.moment-chip:hover { background: var(--card2); }
.moment-chip[aria-checked="true"] { border-color: var(--cat); background: color-mix(in srgb, var(--cat) 22%, var(--card)); box-shadow: 0 0 0 1px var(--cat); }
.moment-dialog input[type=text] { width: 100%; }
.moment-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
.moment-dialog .btn[disabled] { opacity: .45; pointer-events: none; }
.moment-crop { position: relative; overflow: hidden; border-radius: 6px; background: #19191c; flex: none; }
.moment-crop.missing { display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 13px; line-height: 1.2; text-align: center; }
.moment-viewer { width: auto; max-width: calc(100vw - 16px); }
.moment-viewer-body { display: flex; align-items: center; justify-content: center; gap: 8px; }
.moment-viewer-stage { display: flex; justify-content: center; touch-action: pan-y; user-select: none; }
.moment-viewer-info { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; }
.moment-tag { display: inline-flex; align-items: center; gap: 5px; padding: 2px 10px; border-radius: 12px; font-size: 13px;
border: 1px solid var(--cat); background: color-mix(in srgb, var(--cat) 18%, var(--card)); }
.moment-note { flex-basis: 100%; color: var(--muted); font-style: italic; }
.moment-nav { font-size: 22px; min-width: 40px; }
@media (max-width: 600px) {
.moment-viewer-body { position: relative; }
.moment-nav { position: absolute; z-index: 1; background: #000a; }
.moment-nav.prev { left: 2px; } .moment-nav.next { right: 2px; }
}
.moments-card { max-width: none; }
.moments-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
.moments-card h2 { margin: 0; }
.moment-filters { display: flex; gap: 7px; overflow-x: auto; padding-bottom: 6px; }
.moment-filter { display: inline-flex; align-items: center; gap: 5px; }
.moment-strip { display: flex; gap: 10px; overflow-x: auto; padding: 4px 2px 10px; scrollbar-width: thin; }
.moment-thumb { position: relative; flex: none; display: flex; flex-direction: column; gap: 4px; padding: 0; border: 0; background: none;
color: var(--fg); font: inherit; cursor: pointer; text-align: left; }
.moment-thumb .moment-crop { transition: transform .15s; }
.moment-thumb:hover .moment-crop, .moment-thumb:focus-visible .moment-crop { transform: scale(1.03); }
.moment-thumb-cat { position: absolute; top: 5px; left: 5px; display: flex; padding: 3px; border-radius: 50%; background: #000b; }
.moment-thumb-label { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px; }
.key-group h3 { margin: 4px 0 8px; font-size: 15px; }
.key-group + .key-group { margin-top: 16px; }
.key-table { border-collapse: collapse; width: 100%; }
.key-table td { padding: 5px 8px; border-top: 1px solid var(--line); vertical-align: top; }
.key-table td:first-child { width: 1%; white-space: nowrap; }
kbd { display: inline-block; min-width: 24px; padding: 1px 7px; border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 5px;
background: var(--card2); font: 13px/1.5 ui-monospace, monospace; text-align: center; }
@media (max-width: 480px) { .moment-dialog { padding: 12px; } .moment-actions .moment-hint { flex-basis: 100%; } .moment-actions .moment-hint:empty { display: none; } }
.moment-extend { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-bottom: 8px; }
/* Reading activity grid (Settings → Reading activity). --g: cell glow 0…1, --a: strip segment glow 0…1. */
.activity-toolbar { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; }
.activity-nav { display: flex; align-items: center; gap: 8px; margin: 12px 0 4px; }
.activity-title { min-width: 11em; text-align: center; }
.activity-summary { margin: 6px 0 12px; }
.activity-grid { display: flex; flex-direction: column; gap: 6px; }
.activity-row { display: grid; grid-template-columns: 5.5em 1fr; gap: 8px; align-items: stretch; border-radius: 10px; }
.activity-row.marked { background: color-mix(in srgb, var(--accent) 12%, transparent); outline: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); }
.activity-row-label { color: var(--muted); font-size: 13px; align-self: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-cells { display: grid; grid-template-columns: repeat(var(--cols), minmax(0, 1fr)); gap: 6px; }
.activity-head { color: var(--muted); font-size: 12px; text-align: center; }
.activity-cell { position: relative; display: flex; flex-direction: column; justify-content: space-between; gap: 6px; min-height: 58px; padding: 6px 7px 7px;
border: 1px solid var(--line); border-radius: 8px; color: var(--fg); font: inherit; text-align: left; cursor: pointer;
background: color-mix(in srgb, var(--accent) calc(var(--g) * 42%), var(--card2));
box-shadow: 0 0 calc(var(--g) * 12px) color-mix(in srgb, var(--accent) calc(var(--g) * 55%), transparent);
transition: transform .12s, border-color .12s; }
.activity-cell:hover, .activity-cell:focus-visible { border-color: var(--accent); transform: translateY(-1px); outline: none; }
.activity-cell.hot { border-color: color-mix(in srgb, var(--accent) 70%, #fff); }
.activity-cell.now { border-color: var(--fg); }
.activity-cell.outside { opacity: .38; }
.activity-cell.future { cursor: default; background: transparent; border-style: dashed; box-shadow: none; transform: none; }
.view-hour .activity-cell { cursor: default; }
.activity-label { font-size: 13px; font-weight: 600; line-height: 1.1; display: flex; flex-direction: column; }
.activity-label small { font-weight: 400; color: var(--muted); font-size: 11px; }
.activity-value { position: absolute; top: 6px; right: 7px; font-size: 11px; color: color-mix(in srgb, var(--fg) 80%, transparent); }
.view-day .activity-value, .view-month .activity-value { position: static; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-strip { display: flex; height: 9px; border-radius: 3px; overflow: hidden; background: color-mix(in srgb, var(--bg) 70%, transparent); }
.activity-strip i { flex: 1; background: var(--accent); opacity: var(--a); }
.activity-strip i.ahead { background: transparent; }
.activity-strip i + i { box-shadow: inset 1px 0 0 color-mix(in srgb, var(--bg) 35%, transparent); }
.view-month .activity-strip i + i, .view-week .activity-strip i + i { box-shadow: none; }
.activity-tip { position: fixed; z-index: 50; max-width: 300px; padding: 10px 12px; border-radius: 10px; pointer-events: none;
background: var(--card); border: 1px solid var(--line); box-shadow: 0 8px 24px rgba(0, 0, 0, .45); font-size: 13px; line-height: 1.45; }
.activity-tip ul { margin: 4px 0 0; padding-left: 16px; }
.activity-legend { margin-top: 12px; }
@media (max-width: 760px) {
.activity-row { grid-template-columns: 1fr; gap: 3px; }
.activity-row.head .activity-row-label { display: none; }
.view-month .activity-cells { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.activity-cell { min-height: 48px; padding: 5px; }
.activity-value { display: none; }
.activity-cells { gap: 4px; }
}
+1 -1
View File
@@ -8,7 +8,7 @@
h('div', { class: 'info' }, h('h1', {}, c.title), h('p', { class: 'muted' }, `${c.members.length} manga · ${c.read}/${c.chapters} chapters read · ${c.unread} new`), h('div', { class: 'info' }, h('h1', {}, c.title), h('p', { class: 'muted' }, `${c.members.length} manga · ${c.read}/${c.chapters} chapters read · ${c.unread} new`),
c.description ? h('p', {}, c.description) : null, c.description ? h('p', {}, c.description) : null,
c.reading.activeMs ? h('p', {}, `${readingDuration(c.reading.activeMs)} together`) : null, c.reading.activeMs ? h('p', {}, `${readingDuration(c.reading.activeMs)} together`) : null,
h('div', { class: 'actions' }, h('a', { class: 'btn primary', href: readUrl(c.continue.m, c.continue.c, c.continue.v) }, c.continue.text), h('div', { class: 'actions' }, h('a', { class: 'btn primary', href: readUrl(c.continue.m, c.continue.c, c.continue.v, c.continue.at) }, c.continue.text),
me.admin ? h('a', { class: 'btn', href: 'collections.html?' + qsa({ id: c.id }) }, 'Edit collection') : null)))); me.admin ? h('a', { class: 'btn', href: 'collections.html?' + qsa({ id: c.id }) }, 'Edit collection') : null))));
main.append(h('h2', {}, 'Reading order'), h('div', { class: 'collection-members' }, c.members.map(m => main.append(h('h2', {}, 'Reading order'), h('div', { class: 'collection-members' }, c.members.map(m =>
h('a', { class: 'collection-member', href: mangaUrl(m.id) }, m.cover ? h('img', { src: m.cover, alt: '', loading: 'lazy' }) : null, h('a', { class: 'collection-member', href: mangaUrl(m.id) }, m.cover ? h('img', { src: m.cover, alt: '', loading: 'lazy' }) : null,
+16 -1
View File
@@ -39,6 +39,20 @@ const store = {
get(k) { try { return localStorage.getItem('mc.' + k); } catch (e) { return null; } }, get(k) { try { return localStorage.getItem('mc.' + k); } catch (e) { return null; } },
set(k, v) { try { localStorage.setItem('mc.' + k, v); } catch (e) { /* private mode */ } }, set(k, v) { try { localStorage.setItem('mc.' + k, v); } catch (e) { /* private mode */ } },
}; };
// One id per browser tab (it survives navigation and reloads in that tab): progress saves carry it, so
// a reader can tell its own saves from those of another tab or device.
const tabId = (() => {
let id = null;
return () => {
if (id) return id;
try { id = sessionStorage.getItem('mc.tab'); } catch (e) { /* storage blocked */ }
if (!/^[a-zA-Z0-9_-]{8,40}$/.test(id || '')) {
id = Array.from(crypto.getRandomValues(new Uint8Array(12)), b => b.toString(16).padStart(2, '0')).join('');
try { sessionStorage.setItem('mc.tab', id); } catch (e) { /* this page keeps it in memory */ }
}
return id;
};
})();
function ago(t) { function ago(t) {
const s = (Date.now() - t) / 1000; const s = (Date.now() - t) / 1000;
@@ -60,7 +74,8 @@ function readingDuration(ms, compact = false) {
} }
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 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 }); // `at`: the manga's latest progress time the linking page knew, so the reader can tell it opened from a stale page.
const readUrl = (m, c, v, at) => 'read.html?' + qsa({ m, c, v, at: at || null });
const mangaUrl = m => 'manga.html?' + qsa({ m }); const mangaUrl = m => 'manga.html?' + qsa({ m });
const libraryUrlForTags = tags => { const libraryUrlForTags = tags => {
const p = new URLSearchParams(); const p = new URLSearchParams();
+4 -2
View File
@@ -65,8 +65,10 @@
function cover(item) { function cover(item) {
const box = h('span', { class: 'greatest-cover' }, h('span', { class: 'greatest-cover-fallback', 'aria-hidden': 'true' }, item.title.slice(0, 1))); const box = h('span', { class: 'greatest-cover' }, h('span', { class: 'greatest-cover-fallback', 'aria-hidden': 'true' }, item.title.slice(0, 1)));
if (item.cover && safeHref(item.cover)) { // Covers come from the server (cached catalog covers, owned library covers) as relative URLs.
const image = h('img', { src: item.cover, alt: '', loading: 'lazy' }); const src = /^(api\/greatest-cover\?|lib\/)/.test(item.cover || '') ? item.cover : safeHref(item.cover);
if (src) {
const image = h('img', { src, alt: '', loading: 'lazy' });
image.onerror = () => image.remove(); image.onerror = () => image.remove();
box.append(image); box.append(image);
} }
+35
View File
@@ -0,0 +1,35 @@
'use strict';
/*
* Keyboard shortcuts. The reader dispatches keys through READER_KEYS, and Settings and the reader's "?" list
* are drawn from the same tables, so the documentation cannot drift from what the keys do.
*/
const READER_KEYS = [
{ keys: ['ArrowLeft', 'ArrowRight'], label: '← →', action: 'turn', text: 'Previous / next page, following the reading direction' },
{ keys: [' ', 'PageDown'], label: 'Space · Page Down', action: 'forward', text: 'Next page (Shift + Space: previous) · Page and Book mode' },
{ keys: ['PageUp'], label: 'Page Up', action: 'back', text: 'Previous page · Page and Book mode' },
{ keys: ['+', '='], label: '+', action: 'zoomIn', text: 'Zoom in 5 %' },
{ keys: ['-'], label: '−', action: 'zoomOut', text: 'Zoom out 5 %' },
{ keys: ['0'], label: '0', action: 'zoomFit', text: 'Fit the screen (100 %)' },
{ keys: ['m', 'M'], label: 'M', action: 'moment', text: 'Save a moment of the page you are on' },
{ keys: ['i', 'I'], label: 'I', action: 'bars', text: 'Show or hide the controls' },
{ keys: ['Escape'], label: 'Esc', action: 'bars', text: 'Show or hide the controls (closes an open dialog first)' },
{ keys: ['f', 'F'], label: 'F', action: 'fullscreen', text: 'Fullscreen' },
{ keys: ['?'], label: '?', action: 'help', text: 'Show these shortcuts' },
];
const MOMENT_VIEWER_KEYS = [
{ keys: ['ArrowLeft', 'ArrowRight'], label: '← →', action: 'step', text: 'Previous / next moment' },
{ keys: ['Escape'], label: 'Esc', action: 'close', text: 'Close the viewer' },
];
const KEY_GROUPS = [
{ title: 'Reader', keys: READER_KEYS },
{ title: 'Moments viewer (manga page)', keys: MOMENT_VIEWER_KEYS },
];
const keyAction = (table, key) => (table.find(entry => entry.keys.includes(key)) || {}).action || null;
function keyTable(groups = KEY_GROUPS) {
return groups.map(group => h('section', { class: 'key-group' }, h('h3', {}, group.title),
h('table', { class: 'key-table' }, h('tbody', {}, group.keys.map(entry =>
h('tr', {}, h('td', {}, h('kbd', {}, entry.label)), h('td', {}, entry.text)))))));
}
if (typeof module !== 'undefined') module.exports = { READER_KEYS, MOMENT_VIEWER_KEYS, KEY_GROUPS, keyAction };
+25 -13
View File
@@ -55,6 +55,13 @@
h('option', { value: 'grouped' }, 'Group collections'), h('option', { value: 'expanded' }, 'Expand collections'), h('option', { value: 'individual' }, 'Individual manga')); h('option', { value: 'grouped' }, 'Group collections'), h('option', { value: 'expanded' }, 'Expand collections'), h('option', { value: 'individual' }, 'Individual manga'));
grouping.value = ['grouped', 'expanded', 'individual'].includes(store.get('grouping')) ? store.get('grouping') : 'grouped'; grouping.value = ['grouped', 'expanded', 'individual'].includes(store.get('grouping')) ? store.get('grouping') : 'grouped';
grouping.onchange = render; grouping.onchange = render;
// Started manga with chapters left after the last one read (first read or reread, server `left`),
// most recently read first; the same manga still appear in the grid.
const continueItems = data.mangas.filter(m => m.lastRead && m.left > 0)
.sort((a, b) => b.lastRead - a.lastRead).slice(0, 5);
const continueRow = h('section', { class: 'continue-row', 'aria-label': 'Continue where you left off', hidden: true },
h('div', { class: 'continue-banner' }, h('span', { 'aria-hidden': 'true' }, '▶'), 'Continue where you left off'),
h('div', { class: 'continue-tiles' }, continueItems.map(m => tile(m))));
if (storyAvailable) { if (storyAvailable) {
const most = data.mangas.find(manga => manga.id === data.reading.mostTimeMangaId); const most = data.mangas.find(manga => manga.id === data.reading.mostTimeMangaId);
const latest = data.mangas.find(manga => manga.id === data.reading.lastMangaId); const latest = data.mangas.find(manga => manga.id === data.reading.lastMangaId);
@@ -66,7 +73,7 @@
data.reading.rereads ? h('span', { class: 'story-pill' }, `${data.reading.rereads} reread${data.reading.rereads === 1 ? '' : 's'}`) : null), data.reading.rereads ? h('span', { class: 'story-pill' }, `${data.reading.rereads} reread${data.reading.rereads === 1 ? '' : 's'}`) : null),
h('div', { class: 'reading-summary-links' }, h('div', { class: 'reading-summary-links' },
most ? h('a', { href: mangaUrl(most.id) }, `Most time with ${most.title}`) : null, most ? h('a', { href: mangaUrl(most.id) }, `Most time with ${most.title}`) : null,
latest && data.reading.lastChapterId ? h('a', { href: readUrl(latest.id, data.reading.lastChapterId) }, `Continue ${latest.title} →`) : null))); latest && data.reading.lastChapterId ? h('a', { href: readUrl(latest.id, data.reading.lastChapterId, null, latest.lastRead) }, `Continue ${latest.title} →`) : null)));
} }
main.append( main.append(
h('div', { class: 'library-controls' }, h('div', { class: 'library-controls' },
@@ -76,7 +83,7 @@
h('div', { class: 'filter-heading' }, h('b', {}, 'Tags'), h('span', { class: 'muted small' }, 'Manga must match every selected tag')), h('div', { class: 'filter-heading' }, h('b', {}, 'Tags'), h('span', { class: 'muted small' }, 'Manga must match every selected tag')),
tagBar) : null, tagBar) : null,
resultCount), resultCount),
grid); continueRow, grid);
function syncTags() { function syncTags() {
store.set('tags', JSON.stringify([...selectedTags])); store.set('tags', JSON.stringify([...selectedTags]));
@@ -95,8 +102,24 @@
return direction * (at - bt) || a.title.localeCompare(b.title); return direction * (at - bt) || a.title.localeCompare(b.title);
} }
function tile(m) {
const cover = m.isCollection && m.coverMode === 'mosaic' && m.covers.length
? h('div', { class: 'cv collection-mosaic' }, m.covers.map(src => h('img', { loading: 'lazy', src, alt: '' })))
: h('div', { class: 'cv' }, m.cover ? h('img', { loading: 'lazy', src: m.cover, alt: '' }) : null);
return h('a', { class: 'tile', href: m.isCollection ? 'collection.html?' + qsa({ id: m.id }) : mangaUrl(m.id) },
cover, m.isCollection ? h('span', { class: 'collection-count' }, `${m.members.length} manga`) : null,
m.unread ? h('span', { class: 'badge' }, m.unread + ' new') : null,
h('div', { class: 't' }, m.title),
h('div', { class: 's' }, [m.firstRelease, m.status, m.read ? `${m.read} / ${m.chapters} read` : `${m.chapters} chapters`].filter(Boolean).join(' · ')),
m.read ? h('div', { class: 'prog' }, h('i', { style: `width:${Math.round((100 * m.read) / Math.max(1, m.chapters))}%` })) : null);
}
function render() { function render() {
store.set('filter', filter.value); store.set('sort', sort.value); store.set('status', status.value); store.set('grouping', grouping.value); store.set('filter', filter.value); store.set('sort', sort.value); store.set('status', status.value); store.set('grouping', grouping.value);
// Only on the plain overview: a search or status filter means the reader is looking for something else,
// and "Recently read" already puts these first. Tags and grouping keep it.
continueRow.hidden = !me.settings.continueRow || !continueItems.length || sort.value === 'read'
|| !!filter.value.trim() || !!status.value;
const q = filter.value.trim().toLowerCase(); const q = filter.value.trim().toLowerCase();
const groups = (data.collections || []).filter(c => c.grouped); const groups = (data.collections || []).filter(c => c.grouped);
const membership = new Set(groups.flatMap(c => c.members.map(m => m.id))); const membership = new Set(groups.flatMap(c => c.members.map(m => m.id)));
@@ -125,17 +148,6 @@
} }
resultCount.textContent = `${list.length} results · ${data.mangas.length} manga in the library`; resultCount.textContent = `${list.length} results · ${data.mangas.length} manga in the library`;
grid.textContent = ''; grid.textContent = '';
const tile = m => {
const cover = m.isCollection && m.coverMode === 'mosaic' && m.covers.length
? h('div', { class: 'cv collection-mosaic' }, m.covers.map(src => h('img', { loading: 'lazy', src, alt: '' })))
: h('div', { class: 'cv' }, m.cover ? h('img', { loading: 'lazy', src: m.cover, alt: '' }) : null);
return h('a', { class: 'tile', href: m.isCollection ? 'collection.html?' + qsa({ id: m.id }) : mangaUrl(m.id) },
cover, m.isCollection ? h('span', { class: 'collection-count' }, `${m.members.length} manga`) : null,
m.unread ? h('span', { class: 'badge' }, m.unread + ' new') : null,
h('div', { class: 't' }, m.title),
h('div', { class: 's' }, [m.firstRelease, m.status, m.read ? `${m.read} / ${m.chapters} read` : `${m.chapters} chapters`].filter(Boolean).join(' · ')),
m.read ? h('div', { class: 'prog' }, h('i', { style: `width:${Math.round((100 * m.read) / Math.max(1, m.chapters))}%` })) : null);
};
if (grouping.value === 'expanded') { if (grouping.value === 'expanded') {
const visible = new Set(list.map(m => m.id)); const visible = new Set(list.map(m => m.id));
for (const c of groups) { for (const c of groups) {
+2
View File
@@ -9,6 +9,8 @@
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#121214"> <meta name="theme-color" content="#121214">
<script src="common.js" defer></script> <script src="common.js" defer></script>
<script src="keys.js" defer></script>
<script src="moments.js" defer></script>
<script src="manga.js" defer></script> <script src="manga.js" defer></script>
</head> </head>
<body> <body>
+55 -6
View File
@@ -48,7 +48,7 @@
h('div', { class: 'chips' }, tags.map(tag => h('a', { class: 'chip chip-link', href: libraryUrlForTags([tag]), title: `Show manga tagged ${tag}` }, tag))), h('div', { class: 'chips' }, tags.map(tag => h('a', { class: 'chip chip-link', href: libraryUrlForTags([tag]), title: `Show manga tagged ${tag}` }, tag))),
meta.description ? h('p', { class: 'desc' }, meta.description) : null, meta.description ? h('p', { class: 'desc' }, meta.description) : null,
h('div', { class: 'actions' }, 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, M.continue ? h('a', { class: 'btn primary', href: readUrl(M.id, M.continue.c, M.continue.v, M.continue.at) }, M.continue.text) : null,
follow)))); follow))));
let storyRhythm = null; let storyRhythm = null;
@@ -73,7 +73,7 @@
storyStat(M.reading.typicalChapterMs ? readingDuration(M.reading.typicalChapterMs) : '—', 'typical chapter'), storyStat(M.reading.typicalChapterMs ? readingDuration(M.reading.typicalChapterMs) : '—', 'typical chapter'),
storyStat(M.reading.rereads, `reread${M.reading.rereads === 1 ? '' : 's'}`)), storyStat(M.reading.rereads, `reread${M.reading.rereads === 1 ? '' : 's'}`)),
lastChapter ? h('p', { class: 'story-last muted' }, 'Last timed ', lastChapter ? h('p', { class: 'story-last muted' }, 'Last timed ',
h('a', { href: readUrl(M.id, lastChapter.id) }, `${lastChapter.label}${lastChapter.title ? ' · ' + lastChapter.title : ''}`), h('a', { href: readUrl(M.id, lastChapter.id, null, M.continue?.at) }, `${lastChapter.label}${lastChapter.title ? ' · ' + lastChapter.title : ''}`),
` · ${ago(M.reading.updatedAt)}`) : null, ` · ${ago(M.reading.updatedAt)}`) : null,
h('div', { class: 'story-rhythm-head' }, h('b', {}, 'Reading rhythm'), h('span', { class: 'muted small' }, 'Completed chapters · active time')), h('div', { class: 'story-rhythm-head' }, h('b', {}, 'Reading rhythm'), h('span', { class: 'muted small' }, 'Completed chapters · active time')),
storyRhythm, storyRhythm,
@@ -110,7 +110,7 @@
const passes = chapter.reading.completedPasses; const passes = chapter.reading.completedPasses;
const title = `${label} · ${readingDuration(value)}${passes > 1 ? ` · ${passes} timed reads · ${readingDuration(chapter.reading.totalActiveMs)} total` : ''}`; const title = `${label} · ${readingDuration(value)}${passes > 1 ? ` · ${passes} timed reads · ${readingDuration(chapter.reading.totalActiveMs)} total` : ''}`;
storyRhythm.append(h('a', { storyRhythm.append(h('a', {
class: 'rhythm-bar', role: 'listitem', href: readUrl(M.id, chapter.id), title, class: 'rhythm-bar', role: 'listitem', href: readUrl(M.id, chapter.id, null, M.continue?.at), title,
'aria-label': title, style: `height:${height}px`, 'aria-label': title, style: `height:${height}px`,
})); }));
} }
@@ -164,6 +164,7 @@
main.append(h('details', { class: 'metadata card', open: metadataOpen }, main.append(h('details', { class: 'metadata card', open: metadataOpen },
h('summary', { class: 'metadata-summary' }, h('h2', {}, 'Metadata')), h('summary', { class: 'metadata-summary' }, h('h2', {}, 'Metadata')),
metadata, rawMeta)); metadata, rawMeta));
main.append(momentsGallery());
const volumeButtons = new Map(); const volumeButtons = new Map();
const volumeBar = h('div', { class: 'volume-filter', 'aria-label': 'Filter chapters by volume' }); const volumeBar = h('div', { class: 'volume-filter', 'aria-label': 'Filter chapters by volume' });
@@ -220,7 +221,7 @@
if (done) { await api('api/unread', { m: M.id, c: ch.id }); ch.progress = null; ch.readAt = null; } if (done) { await api('api/unread', { m: M.id, c: ch.id }); ch.progress = null; ch.readAt = null; }
else { else {
const version = ch.versions[0]; const version = ch.versions[0];
await api('api/progress', { m: M.id, c: ch.id, v: version.id, p: version.pages - 1, n: version.pages, done: true }); await api('api/progress', { m: M.id, c: ch.id, v: version.id, p: version.pages - 1, n: version.pages, done: true, tab: tabId() });
ch.progress = { v: version.id, p: version.pages - 1, n: version.pages, done: true }; ch.progress = { v: version.id, p: version.pages - 1, n: version.pages, done: true };
ch.readAt = { at: Date.now(), approx: false }; ch.readAt = { at: Date.now(), approx: false };
} }
@@ -233,7 +234,7 @@
const timing = chapterTiming(ch), released = chapterRelease(ch.dates); const timing = chapterTiming(ch), released = chapterRelease(ch.dates);
wrap.textContent = ''; wrap.textContent = '';
wrap.append(h('div', { class: 'ch' + (done ? ' read' : '') }, wrap.append(h('div', { class: 'ch' + (done ? ' read' : '') },
h('a', { class: 'ch-main' + (timing ? ' has-time' : '') + (M.chapterDates?.release ? ' has-date' : ''), href: readUrl(M.id, ch.id) }, h('a', { class: 'ch-main' + (timing ? ' has-time' : '') + (M.chapterDates?.release ? ' has-date' : ''), href: readUrl(M.id, ch.id, null, M.continue?.at) },
h('span', { class: 'ch-label' }, ch.label), h('span', { class: 'ch-label' }, ch.label),
h('span', { class: 'ch-pages' }, `${pages} p.`), h('span', { class: 'ch-pages' }, `${pages} p.`),
M.chapterDates?.release ? h('span', { class: 'ch-date' + (released ? ' tier-' + released.tier : ''), title: released ? released.title : 'No date known', 'aria-label': released ? released.title : 'No date known' }, M.chapterDates?.release ? h('span', { class: 'ch-date' + (released ? ' tier-' + released.tier : ''), title: released ? released.title : 'No date known', 'aria-label': released ? released.title : 'No date known' },
@@ -258,7 +259,7 @@
h('button', { class: version.my > 0 ? 'on' : '', title: 'Better', onclick: () => vote(1) }, '▲'), h('button', { class: version.my > 0 ? 'on' : '', title: 'Better', onclick: () => vote(1) }, '▲'),
h('b', {}, version.score), h('b', {}, version.score),
h('button', { class: version.my < 0 ? 'on' : '', title: 'Worse', onclick: () => vote(-1) }, '▼')), h('button', { class: version.my < 0 ? 'on' : '', title: 'Worse', onclick: () => vote(-1) }, '▼')),
h('a', { class: 'btn small', href: readUrl(M.id, ch.id, version.id) }, 'Read'))); h('a', { class: 'btn small', href: readUrl(M.id, ch.id, version.id, M.continue?.at) }, 'Read')));
}); });
} }
draw(); draw();
@@ -316,6 +317,54 @@
return { text: `◷ ${readingDuration(active, true)}`, title: `${readingDuration(active)} active; current pass in progress` }; return { text: `◷ ${readingDuration(active, true)}`, title: `${readingDuration(active)} active; current pass in progress` };
} }
// Your moments (BL-0055): hidden until there is at least one. Only the user's own are ever returned.
function momentsGallery() {
const sortKey = `moments-sort.${me.name}`;
let list = [], sort = store.get(sortKey) === 'recent' ? 'recent' : 'story', filter = null;
const section = h('section', { class: 'card moments-card', hidden: true });
const count = h('span', { class: 'muted small' });
const sortButtons = ['story', 'recent'].map(key => h('button', { type: 'button', 'data-sort': key,
onclick: () => { sort = key; store.set(sortKey, key); draw(); } }, key === 'story' ? 'Story' : 'Recent'));
const filters = h('div', { class: 'moment-filters', role: 'group', 'aria-label': 'Filter moments by category' });
const strip = h('div', { class: 'moment-strip' });
section.append(h('div', { class: 'moments-head' }, h('h2', {}, 'Your moments'), count, h('div', { class: 'grow' }),
h('div', { class: 'seg', role: 'group', 'aria-label': 'Sort moments' }, sortButtons)), filters, strip);
const shown = () => {
const items = list.filter(m => !filter || m.cat === filter);
return sort === 'recent' ? items.sort((a, b) => b.t - a.t) : items.sort((a, b) => {
for (let k = 0; k < 3; k++) if (a.order[k] !== b.order[k]) return a.order[k] - b.order[k];
return a.t - b.t;
});
};
function draw() {
section.hidden = !list.length;
count.textContent = `${list.length} saved`;
for (const b of sortButtons) b.classList.toggle('on', b.dataset.sort === sort);
// Category filters only help once there are a few moments in more than one category.
const cats = MOMENT_CATS.filter(c => list.some(m => m.cat === c.id));
if (filter && !cats.some(c => c.id === filter)) filter = null;
filters.hidden = list.length < 5 || cats.length < 2;
filters.replaceChildren(...cats.map(c => h('button', { type: 'button', class: 'filter-chip moment-filter' + (filter === c.id ? ' on' : ''),
'aria-pressed': String(filter === c.id), onclick: () => { filter = filter === c.id ? null : c.id; draw(); } },
momentIcon(c.id, 14), `${c.label} ${list.filter(m => m.cat === c.id).length}`)));
const items = shown();
strip.replaceChildren(...items.map((m, k) => h('button', { type: 'button', class: 'moment-thumb', title: `${momentCat(m.cat).label} · ${m.chapter.label} · ${m.missing ? 'page moved' : momentPageLabel(m.pages)}${m.note ? '\n' + m.note : ''}`,
onclick: () => momentViewer({ mangaId: M.id, mangaTitle: M.title, list: items, index: k, onChange: changed }) },
momentCrop(m.pages, m.region, 200, 120, m.layout),
h('span', { class: 'moment-thumb-cat' }, momentIcon(m.cat, 14)),
h('span', { class: 'moment-thumb-label' }, `${m.chapter.label} · ${m.missing ? 'moved' : momentPageLabel(m.pages)}`))));
}
// The viewer edits its (sorted, filtered) copy; fold the changes back into the full list.
function changed(items) {
const byId = new Map(items.map(m => [m.id, m]));
const visible = new Set(shown().map(m => m.id));
list = list.filter(m => !visible.has(m.id) || byId.has(m.id)).map(m => byId.get(m.id) || m);
draw();
}
api('api/moments?' + qsa({ m: M.id })).then(data => { list = data.moments; draw(); }).catch(() => {});
return section;
}
drawVolumeFilter(); drawVolumeFilter();
drawChapters(); drawChapters();
})(); })();
+302
View File
@@ -0,0 +1,302 @@
'use strict';
/*
* Moments (BL-0055): a page, or a region of one or two facing pages, saved with exactly one category.
* Shared by the reader (save sheet) and the manga page (gallery and viewer). Crops are drawn by positioning the
* full page images inside a clipping box, so no thumbnails are generated on the server.
*/
const MOMENT_CATS = [
{ id: 'twist', label: 'Twist', color: '#a29bf2', svg: '<path d="M12 1.5L13.8 4.6L17.3 3.2L17.4 6.8L21 7.6L19.2 10.8L22 13.2L18.8 14.9L19.6 18.5L16 18.2L14.6 21.6L12 19.2L9.4 21.6L8 18.2L4.4 18.5L5.2 14.9L2 13.2L4.8 10.8L3 7.6L6.6 6.8L6.7 3.2L10.2 4.6Z"/><path d="M9.3 8.3v4.4"/><path d="M12.6 9.2a1.9 1.9 0 1 1 2.6 1.8c-.6.3-.8.7-.8 1.5"/><circle cx="9.3" cy="15.3" r=".5" fill="currentColor"/><circle cx="14.4" cy="15.3" r=".5" fill="currentColor"/>' },
{ id: 'hot', label: 'Hot', color: '#f0845c', svg: '<path d="M12 2.5c.5 3 3 4.5 4.5 7a6.5 6.5 0 1 1-11 1.5c.8 1.2 1.8 1.8 2.8 1.8C7.5 9 9.5 5.5 12 2.5z"/><path d="M12 21a3 3 0 0 1-3-3c0-1.8 1.5-3 3-5c1.5 2 3 3.2 3 5a3 3 0 0 1-3 3z"/><path d="M19.5 3l-1 1.6M21.5 5.5l-1.6.8"/>' },
{ id: 'awesome', label: 'Awesome', color: '#f2b443', svg: '<path d="M12 5.5L13.5 10.5L18.5 12L13.5 13.5L12 18.5L10.5 13.5L5.5 12L10.5 10.5Z"/><path d="M3 3l2.6 2.6M21 3l-2.6 2.6M3 21l2.6-2.6M21 21l-2.6-2.6M12 1.5v1.8M12 20.7v1.8M1.5 12h1.8M20.7 12h1.8"/>' },
{ id: 'cute', label: 'Cute', color: '#f07aa5', svg: '<path d="M11 21.5s-7-4.4-7-9.5a4 4 0 0 1 7-2.6a4 4 0 0 1 7 2.6c0 5.1-7 9.5-7 9.5z"/><path d="M8 13.5l1 1M13 13.5l1 1"/><path d="M19.5 2v4M17.5 4h4"/><path d="M4 3.5v2.4M2.8 4.7h2.4"/>' },
{ id: 'funny', label: 'Funny', color: '#4fcfa0', svg: '<circle cx="11" cy="13" r="8"/><path d="M6.8 11.6l1.6-1.6l1.6 1.6M12 11.6l1.6-1.6l1.6 1.6"/><path d="M7.3 14.5h7.4a3.7 3.7 0 0 1-7.4 0z"/><path d="M20.2 2.5c-1 1.6-1.6 2.6-1.6 3.4a1.6 1.6 0 0 0 3.2 0c0-.8-.6-1.8-1.6-3.4z"/>' },
{ id: 'feels', label: 'Feels', color: '#6aa8ec', svg: '<path d="M2.5 10c2.5-4 6-5.5 9.5-5.5S19 6 21.5 10"/><path d="M5 11.5a7 7 0 0 0 14 0"/><circle cx="12" cy="11" r="4"/><circle cx="13.5" cy="9.5" r="1" fill="currentColor"/><path d="M18 16.5c-1 1.5-1.5 2.4-1.5 3.1a1.5 1.5 0 0 0 3 0c0-.7-.5-1.6-1.5-3.1z"/>' },
];
const momentCat = id => MOMENT_CATS.find(c => c.id === id) || MOMENT_CATS[0];
function momentIcon(id, size = 18) {
const el = icon(`<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="${size < 20 ? 2 : 1.6}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${momentCat(id).svg}</svg>`);
el.style.color = momentCat(id).color;
return el;
}
const momentPageLabel = pages => {
const ns = pages.map(p => p.i + 1).sort((a, b) => a - b);
return 'p. ' + (ns.length > 1 ? ns[0] + '–' + ns[ns.length - 1] : ns[0]);
};
// Displayed size after rotation, as in the reader.
const shapeOf = p => {
const turned = (p.r || 0) % 180, w = (turned ? p.h : p.w) || 1000, hh = (turned ? p.w : p.h) || 1414;
return { ...p, a: w / hh };
};
const FULL = { x: 0, y: 0, w: 1, h: 1 };
// Each page's box in units: side by side ("row", Page/Book) pages share a height of 1,
// stacked ("column", Flow) pages share a width of 1.
function arrange(pages, layout) {
const column = layout === 'column';
let at = 0;
const boxes = pages.map(shapeOf).map((p, k) => {
const b = column ? { k, p, x: 0, y: at, w: 1, h: 1 / p.a } : { k, p, x: at, y: 0, w: p.a, h: 1 };
at += column ? b.h : b.w;
return b;
});
return { boxes, w: column ? 1 : at, h: column ? at : 1 };
}
// The pages put together at `scale` px per unit.
function composite(pages, layout, scale) {
const { boxes, w, h: hh } = arrange(pages, layout), el = h('div', { class: 'moment-composite' });
for (const b of boxes) {
const W = b.w * scale, H = b.h * scale, r = b.p.r || 0;
const img = h('img', { src: b.p.u, alt: '', draggable: 'false', decoding: 'async', loading: 'lazy' });
if (r) img.style.cssText = `position:absolute;left:50%;top:50%;max-width:none;width:${r % 180 ? H : W}px;height:${r % 180 ? W : H}px;transform:translate(-50%,-50%) rotate(${r}deg)`;
el.append(h('div', { class: 'moment-page', style: `left:${b.x * scale}px;top:${b.y * scale}px;width:${W}px;height:${H}px` }, img));
}
el.style.width = w * scale + 'px';
el.style.height = hh * scale + 'px';
return el;
}
// A box of at most maxW × maxH showing `region` (or everything) of the pages.
function momentCrop(pages, region, maxW, maxH, layout = 'row') {
if (!pages.length) return h('div', { class: 'moment-crop missing', style: `width:${Math.round(maxH * 0.7)}px;height:${maxH}px` }, 'Page moved');
const r = region || FULL, { w: TW, h: TH } = arrange(pages, layout), aspect = (r.w * TW) / (r.h * TH);
const boxW = Math.min(maxW, maxH * aspect), boxH = boxW / aspect, scale = boxW / (r.w * TW);
const inner = composite(pages, layout, scale);
inner.style.transform = `translate(${-r.x * TW * scale}px, ${-r.y * TH * scale}px)`;
return h('div', { class: 'moment-crop', style: `width:${Math.round(boxW)}px;height:${Math.round(boxH)}px` }, inner);
}
// Drag a rectangle over `target` (position: relative). Coordinates are fractions of the target box.
function regionPicker(target, initial, onChange) {
let region = initial || null, start = null;
const box = h('div', { class: 'region-box', hidden: true });
const layer = h('div', { class: 'region-layer' }, box);
const draw = () => {
box.hidden = !region;
if (region) Object.assign(box.style, { left: region.x * 100 + '%', top: region.y * 100 + '%', width: region.w * 100 + '%', height: region.h * 100 + '%' });
layer.classList.toggle('has-region', !!region);
};
const at = e => {
const rect = layer.getBoundingClientRect();
return { x: Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)), y: Math.min(1, Math.max(0, (e.clientY - rect.top) / rect.height)) };
};
layer.addEventListener('pointerdown', e => { e.preventDefault(); layer.setPointerCapture(e.pointerId); start = at(e); });
layer.addEventListener('pointermove', e => {
if (!start) return;
const p = at(e);
region = { x: Math.min(start.x, p.x), y: Math.min(start.y, p.y), w: Math.abs(p.x - start.x), h: Math.abs(p.y - start.y) };
draw();
});
const end = () => {
if (!start) return;
start = null;
if (region && (region.w < 0.03 || region.h < 0.03)) region = null; // a tap, not a selection
draw();
onChange(region);
};
layer.addEventListener('pointerup', end);
layer.addEventListener('pointercancel', end);
target.append(layer);
draw();
return { get: () => region, clear() { region = null; draw(); onChange(null); } };
}
// Pages the region does not touch are dropped and the region is mapped onto the pages that remain,
// so a selection inside one page of a spread or a stack saves that page alone.
function trimPages(pages, region, layout = 'row') {
if (pages.length < 2 || !region) return { pages, region };
const column = layout === 'column', { boxes, w: TW, h: TH } = arrange(pages, layout), total = column ? TH : TW;
const from = (column ? region.y : region.x) * total, to = from + (column ? region.h : region.w) * total, eps = 0.002 * total;
const startOf = b => (column ? b.y : b.x), endOf = b => startOf(b) + (column ? b.h : b.w);
const keep = boxes.filter(b => endOf(b) > from + eps && startOf(b) < to - eps);
if (!keep.length || keep.length === boxes.length) return { pages, region };
const s0 = startOf(keep[0]), len = endOf(keep[keep.length - 1]) - s0;
const a = Math.max(0, (from - s0) / len), b = Math.min(1, (to - s0) / len);
return { pages: keep.map(x => pages[x.k]), region: column ? { ...region, y: a, h: b - a } : { ...region, x: a, w: b - a } };
}
// `dialog.done()` closes and cleans up at once. The "close" event (Esc) does the same, but it is a queued task
// that a backgrounded page may run late, so the app never waits for it.
function openDialog(className, onClose) {
const dialog = h('dialog', { class: 'moment-dialog ' + className });
let finished = false;
dialog.done = () => {
if (finished) return;
finished = true;
if (dialog.open) dialog.close();
dialog.remove();
if (onClose) onClose();
};
dialog.addEventListener('close', dialog.done);
dialog.addEventListener('click', e => { if (e.target === dialog) dialog.done(); }); // click on the backdrop
document.body.append(dialog);
return dialog;
}
/*
* The save / edit sheet. `context` [manga title, chapter text]; `initial` {region, cat, note}.
* Pages come either fixed (`pages` in displayed order, for an edit) or from `sequence` (pages in reading order)
* with the shown `range` [first, last], which "+ Previous page" / "+ Next page" widen up to four pages.
* `layout` "row" puts them side by side (right to left when `rtl`), "column" stacks them as in Flow.
* `save({pages, layout, region, cat, note})` returns a promise. Resolves with its result, or null when cancelled.
*/
function momentSheet({ heading, context, pages: fixed, sequence, range, layout = 'row', rtl = false, initial = {}, save }) {
return new Promise(resolve => {
let result = null, cat = initial.cat || null, region = initial.region || null, picker = null;
let [lo, hi] = range || [0, 0];
const start = [lo, hi], MAX = 4;
const dialog = openDialog('moment-sheet', () => resolve(result));
const pagesNow = () => {
if (fixed) return fixed;
const run = sequence.slice(lo, hi + 1);
return layout === 'row' && rtl ? run.reverse() : run;
};
const wrap = h('div', { class: 'moment-preview-wrap' });
const where = h('div', { class: 'muted small' });
const whole = h('button', { class: 'btn small', type: 'button', hidden: true, onclick: () => picker.clear() }, 'Whole selection');
const help = h('span', { class: 'muted small' });
const earlier = h('button', { class: 'btn small', type: 'button', onclick: () => { lo--; drawPreview(); } }, '+ Previous page');
const later = h('button', { class: 'btn small', type: 'button', onclick: () => { hi++; drawPreview(); } }, '+ Next page');
const reset = h('button', { class: 'btn small', type: 'button', onclick: () => { [lo, hi] = start; drawPreview(); } }, 'Reset pages');
const extend = h('div', { class: 'moment-extend', hidden: !sequence },
layout === 'row' && rtl ? [later, reset, earlier] : [earlier, reset, later]);
// Changing the pages changes the geometry, so a selection starts over.
function drawPreview() {
const pages = pagesNow(), { w: TW, h: TH } = arrange(pages, layout);
const maxW = Math.min(560, innerWidth - 48);
const maxH = Math.min(innerHeight * (layout === 'column' ? 0.62 : innerWidth < 600 ? 0.42 : 0.5), layout === 'column' ? 900 : 520);
const preview = h('div', { class: 'moment-preview' }, composite(pages, layout, Math.min(maxW / TW, maxH / TH)));
if (picker) region = null;
picker = regionPicker(preview, region, r => { region = r; whole.hidden = !r; });
whole.hidden = !region;
wrap.replaceChildren(preview);
where.textContent = `${context[1]} · ${momentPageLabel(pages)}`;
help.textContent = 'Drag over the picture to keep just a part; pages it does not touch are left out. Without a selection everything shown is saved.';
if (sequence) {
const count = hi - lo + 1;
earlier.disabled = lo === 0 || count >= MAX;
later.disabled = hi === sequence.length - 1 || count >= MAX;
reset.hidden = lo === start[0] && hi === start[1];
}
}
const hint = h('span', { class: 'muted small moment-hint' });
const saveBtn = h('button', { class: 'btn primary', type: 'submit' }, 'Save moment');
const chips = MOMENT_CATS.map(c => {
const chip = h('button', { class: 'moment-chip', type: 'button', role: 'radio', 'data-cat': c.id, style: `--cat:${c.color}` }, momentIcon(c.id, 18), c.label);
chip.onclick = () => { cat = c.id; drawChips(); };
return chip;
});
const drawChips = () => {
for (const chip of chips) chip.setAttribute('aria-checked', String(chip.dataset.cat === cat));
saveBtn.disabled = !cat;
hint.textContent = cat ? '' : 'Pick a category to save';
};
const note = h('input', { type: 'text', maxlength: '200', placeholder: 'Optional note', 'aria-label': 'Note', value: initial.note || '' });
const error = h('p', { class: 'error small', hidden: true });
const form = h('form', { method: 'dialog' },
h('div', { class: 'moment-head' }, h('h2', {}, heading),
h('button', { class: 'btn icon', type: 'button', 'aria-label': 'Close', onclick: () => dialog.done() }, '×')),
h('div', { class: 'moment-context' }, context[0]), where,
wrap, extend,
h('div', { class: 'moment-region-row' }, help, whole),
h('div', { class: 'moment-chips', role: 'radiogroup', 'aria-label': 'Category' }, chips),
note, error,
h('div', { class: 'moment-actions' }, hint, h('div', { class: 'grow' }),
h('button', { class: 'btn', type: 'button', onclick: () => dialog.done() }, 'Cancel'), saveBtn));
form.addEventListener('submit', async e => {
e.preventDefault();
if (!cat) return;
saveBtn.disabled = true; error.hidden = true;
try {
const kept = fixed ? { pages: fixed, region } : trimPages(pagesNow(), region, layout);
result = await save({ pages: kept.pages, layout, region: kept.region, cat, note: note.value });
dialog.done();
} catch (err) { error.textContent = err.message; error.hidden = false; saveBtn.disabled = false; }
});
dialog.append(form);
drawPreview();
drawChips();
dialog.showModal();
});
}
const momentReadUrl = (mangaId, m) => 'read.html?' + qsa({ m: mangaId, c: m.c, v: m.v, p: m.pages.length ? Math.min(...m.pages.map(p => p.i)) : null });
/*
* The large viewer: `list` of moment views, starting at `index`. `onChange(list)` after an edit or a delete.
*/
function momentViewer({ mangaId, mangaTitle, list, index, onChange }) {
let k = index, full = false;
const dialog = openDialog('moment-viewer', () => removeEventListener('resize', draw));
const stage = h('div', { class: 'moment-viewer-stage' });
const info = h('div', { class: 'moment-viewer-info' });
const prevBtn = h('button', { class: 'btn icon moment-nav prev', type: 'button', 'aria-label': 'Previous moment', onclick: () => step(-1) }, '‹');
const nextBtn = h('button', { class: 'btn icon moment-nav next', type: 'button', 'aria-label': 'Next moment', onclick: () => step(1) }, '›');
const counter = h('span', { class: 'muted small' });
const fullBtn = h('button', { class: 'btn small', type: 'button', onclick: () => { full = !full; draw(); } });
const readLink = h('a', { class: 'btn primary small' }, 'Open in reader');
const actions = h('div', { class: 'moment-actions' }, fullBtn,
h('button', { class: 'btn small', type: 'button', onclick: edit }, 'Edit'),
h('button', { class: 'btn small danger', type: 'button', onclick: remove }, 'Delete'),
h('div', { class: 'grow' }), readLink);
dialog.append(
h('div', { class: 'moment-head' }, info, counter, h('button', { class: 'btn icon', type: 'button', 'aria-label': 'Close', onclick: () => dialog.done() }, '×')),
h('div', { class: 'moment-viewer-body' }, prevBtn, stage, nextBtn), actions);
function step(d) {
if (!list.length) return;
k = (k + d + list.length) % list.length; full = false; draw();
}
function draw() {
if (!list.length) return dialog.done();
const m = list[k], c = momentCat(m.cat);
const maxW = Math.min(1100, innerWidth - (innerWidth < 600 ? 32 : 140)), maxH = innerHeight - (innerWidth < 600 ? 230 : 200);
stage.replaceChildren(momentCrop(m.pages, full ? null : m.region, maxW, maxH, m.layout));
info.replaceChildren(h('span', { class: 'moment-tag', style: `--cat:${c.color}` }, momentIcon(m.cat, 16), c.label),
h('span', {}, `${m.chapter.label}${m.chapter.title ? ' · ' + m.chapter.title : ''} · ${m.missing ? 'page moved' : momentPageLabel(m.pages)}`),
h('span', { class: 'muted small' }, ago(m.t)),
...(m.note ? [h('span', { class: 'moment-note' }, m.note)] : []));
counter.textContent = `${k + 1} / ${list.length}`;
prevBtn.hidden = nextBtn.hidden = list.length < 2;
fullBtn.hidden = !m.region || m.missing;
fullBtn.textContent = full ? 'Show selection' : (m.pages.length > 1 ? 'Show all pages' : 'Show full page');
readLink.href = momentReadUrl(mangaId, m); // a moved page still opens its chapter
}
async function edit() {
const m = list[k];
if (m.missing) return;
const saved = await momentSheet({ heading: 'Edit moment', context: [mangaTitle, `${m.chapter.label}${m.chapter.title ? ' · ' + m.chapter.title : ''}`],
pages: m.pages, layout: m.layout, initial: m,
save: async ({ region, cat, note }) => (await api('api/moments/update', { id: m.id, region, cat, note })).moment });
if (!saved) return;
list[k] = saved; draw(); onChange(list);
}
async function remove() {
if (!confirm('Delete this moment?')) return;
await api('api/moments/delete', { id: list[k].id });
list.splice(k, 1);
k = Math.min(k, list.length - 1);
onChange(list);
draw();
}
dialog.addEventListener('keydown', e => {
if (e.target.closest('input, textarea') || dialog.querySelector('dialog[open]')) return;
const action = keyAction(MOMENT_VIEWER_KEYS, e.key);
if (action === 'step') { e.preventDefault(); step(e.key === 'ArrowRight' ? 1 : -1); }
});
let swipe = null;
stage.addEventListener('pointerdown', e => { swipe = { x: e.clientX, y: e.clientY, t: Date.now() }; });
stage.addEventListener('pointerup', e => {
const s = swipe; swipe = null;
if (!s || Date.now() - s.t > 800) return;
const dx = e.clientX - s.x, dy = e.clientY - s.y;
if (Math.abs(dx) >= 50 && Math.abs(dx) > 2 * Math.abs(dy)) step(dx < 0 ? 1 : -1);
});
addEventListener('resize', draw);
draw();
dialog.showModal();
}
if (typeof module !== 'undefined') module.exports = { MOMENT_CATS, trimPages, arrange, momentPageLabel };
+72
View File
@@ -0,0 +1,72 @@
'use strict';
/*
* Reader-side half of the performance trace (docs/performance.md). Only started when the server says its
* trace is on (`perf: true` in /api/chapter). It reports, in batches to POST api/perf:
* - `res` the browser's resource timing of every library image: queueing, connection, time to first byte,
* download, size, the server's own `stat` time and whether the connection was reused;
* - `view` a page turn (page/book mode): how long the new page(s) took to appear, and whether they were
* preloaded already;
* - `stall` flow mode: a page scrolled into view before its image had loaded, and how long it stayed empty.
*/
window.PerfTrace = {
start(pageName) {
let queue = [], on = true;
const push = e => { if (on) queue.push(e); };
const rel = url => { try { return decodeURIComponent(new URL(url).pathname); } catch { return String(url); } };
const flush = beacon => {
if (!queue.length || !on) return;
const body = JSON.stringify({ page: pageName, events: queue.splice(0, 300) });
if (beacon && navigator.sendBeacon) navigator.sendBeacon('api/perf', new Blob([body], { type: 'application/json' }));
else fetch('api/perf', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, keepalive: true })
.then(r => r.json()).then(r => { if (r.on === false) on = false; }).catch(() => {});
};
setInterval(() => flush(false), 5000);
addEventListener('pagehide', () => flush(true));
push({ e: 'hello', ua: navigator.userAgent, conn: navigator.connection ? navigator.connection.effectiveType : '',
dpr: devicePixelRatio, vw: innerWidth, vh: innerHeight });
if (window.PerformanceObserver) {
new PerformanceObserver(list => {
for (const r of list.getEntries()) {
if (!/\/lib\//.test(r.name)) continue;
const st = (r.serverTiming || []).find(x => x.name === 'stat');
push({ e: 'res', url: rel(r.name), at: r.startTime,
queue: r.requestStart ? r.requestStart - r.startTime : null, // waiting for a free connection
connect: r.connectEnd - r.connectStart, dns: r.domainLookupEnd - r.domainLookupStart,
ttfb: r.responseStart && r.requestStart ? r.responseStart - r.requestStart : null,
download: r.responseStart ? r.responseEnd - r.responseStart : null,
total: r.duration, size: r.transferSize, body: r.encodedBodySize, server: st ? st.duration : null,
proto: r.nextHopProtocol || '', cached: r.transferSize === 0 && r.encodedBodySize > 0 });
}
}).observe({ type: 'resource', buffered: true });
}
return {
// Called right after a page turn put new <img> elements on screen.
view(imgs, info) {
const t0 = performance.now(), waiting = imgs.filter(img => !img.complete);
const event = { e: 'view', ...info, n: imgs.length, preloaded: !waiting.length };
if (!waiting.length) { push({ ...event, wait: 0 }); return; }
Promise.all(waiting.map(img => (img.decode ? img.decode() : Promise.resolve()).catch(() => {})))
.then(() => requestAnimationFrame(() => push({ ...event, wait: performance.now() - t0,
url: rel(waiting[0].currentSrc || waiting[0].src) })));
},
// Flow mode: watch pages enter the real viewport (not the preload margin) before their image is there.
watchFlow(root, els) {
if (!window.IntersectionObserver) return;
const io = new IntersectionObserver(entries => {
for (const en of entries) {
const img = en.target.firstChild;
if (!en.isIntersecting || (img.complete && img.naturalWidth)) continue;
const t0 = performance.now(), hadSrc = !!img.getAttribute('src');
const done = () => push({ e: 'stall', wait: performance.now() - t0, hadSrc, url: rel(img.currentSrc || img.src) });
img.addEventListener('load', done, { once: true });
io.unobserve(en.target);
}
}, { root });
for (const el of els) io.observe(el);
return io;
},
};
},
};
+4
View File
@@ -14,6 +14,9 @@
<script src="common.js" defer></script> <script src="common.js" defer></script>
<script src="reading-clock-core.js" defer></script> <script src="reading-clock-core.js" defer></script>
<script src="reading-clock.js" defer></script> <script src="reading-clock.js" defer></script>
<script src="keys.js" defer></script>
<script src="moments.js" defer></script>
<script src="perf.js" defer></script>
<script src="reader.js" defer></script> <script src="reader.js" defer></script>
</head> </head>
<body class="reader"> <body class="reader">
@@ -23,6 +26,7 @@
<a id="back" class="btn icon" href="./" title="Back to the manga" aria-label="Back">‹</a> <a id="back" class="btn icon" href="./" title="Back to the manga" aria-label="Back">‹</a>
<div id="title" class="grow"></div> <div id="title" class="grow"></div>
<select id="chsel" title="Jump to a chapter" aria-label="Chapter"></select> <select id="chsel" title="Jump to a chapter" aria-label="Chapter"></select>
<button id="moment" class="btn icon moment-btn" title="Save a moment of this page (M)" aria-label="Save a moment"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3.5h10v17l-5-3.6l-5 3.6z"/><path d="M19.5 2.5v5M17 5h5"/></svg></button>
<button id="fs" class="btn icon" title="Fullscreen" aria-label="Fullscreen"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg></button> <button id="fs" class="btn icon" title="Fullscreen" aria-label="Fullscreen"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg></button>
</div> </div>
+135 -18
View File
@@ -74,6 +74,9 @@
D = payload; D = payload;
} }
catch (e) { canvas.textContent = ''; canvas.append(h('p', { class: 'loading' }, e.message + ' · ', h('a', { href: './' }, 'Library'))); return; } catch (e) { canvas.textContent = ''; canvas.append(h('p', { class: 'loading' }, e.message + ' · ', h('a', { href: './' }, 'Library'))); return; }
// Timings for the server's performance trace, only while an admin has it switched on.
const perf = D.perf && window.PerfTrace ? PerfTrace.start('read') : null;
let perfFlow = null;
// An already-open chapter also closes when a window ends or an unlock expires. // An already-open chapter also closes when a window ends or an unlock expires.
const checkReadingAccess = async () => { const checkReadingAccess = async () => {
@@ -149,6 +152,57 @@
const k = list.findIndex(x => x.i >= p); const k = list.findIndex(x => x.i >= p);
cur = k < 0 ? list.length - 1 : k; cur = k < 0 ? list.length - 1 : k;
} }
// A link to a page (e.g. "Open in reader" from a moment) wins over the saved position.
if (P.get('p') != null) { const k = list.findIndex(x => x.i >= +P.get('p')); if (k >= 0) cur = k; }
// Stale reading sessions: a tab left open (or restored by the browser) on another device must not move
// the "Continue" point back. `seen` is the newest progress time this tab knows: the link's `at`, kept
// current in the address bar, so a reload or restored tab still knows it. Without `at` the open was a
// deliberate one (bookmark, notification) and starts from the current head.
const tab = tabId();
let seen = Number(P.get('at')) || (D.latest ? D.latest.t : 0), stale = null, staleCard = null;
const chapterUrl = (c, v) => readUrl(D.manga.id, c, v, seen);
const newer = latest => !!latest && latest.t > seen && latest.w !== tab;
function markSeen(t) {
seen = Math.max(seen, t || 0);
const q = new URLSearchParams(location.search);
q.set('at', seen);
history.replaceState(history.state, '', '?' + q);
}
// Another tab or device saved a newer position. The same place is no conflict; anything else asks.
function checkLatest(latest) {
if (!newer(latest)) return;
if (latest.c === D.chapter.id && list[cur] && latest.p === list[cur].i) { markSeen(latest.t); return; }
showStale(latest);
}
function showStale(latest) {
stale = latest;
clearTimeout(saveTimer);
if (staleCard) staleCard.remove();
const where = latest.done ? `${latest.label} (finished)` : `${latest.label} · page ${latest.p + 1}`;
const go = h('a', { class: 'btn primary', href: readUrl(D.manga.id, latest.continue.c, latest.continue.v, latest.t) }, latest.continue.text);
const stay = h('button', { class: 'btn', type: 'button' }, 'Keep reading here');
stay.onclick = () => {
staleCard.remove(); staleCard = null; stale = null;
markSeen(latest.t);
saveProgress();
stage.focus({ preventScroll: true });
};
staleCard = h('div', { class: 'stale-session', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'stale-title' },
h('div', { class: 'stale-box' },
h('h2', { id: 'stale-title' }, 'You have read further since'),
h('p', {}, 'This page is an older reading session. Your newest reading position is ', h('b', {}, where), ` (${ago(latest.t)}).`),
h('div', { class: 'stale-actions' }, go, stay),
h('p', { class: 'muted small' }, `Keeping ${D.chapter.label} makes it your “Continue” point again.`)));
document.body.append(staleCard);
go.focus();
}
async function recheck() {
if (stale || document.hidden) return;
try { checkLatest((await api('api/progress/latest?' + qsa({ m: D.manga.id }))).latest); } catch { /* offline: the next save checks again */ }
}
document.addEventListener('visibilitychange', recheck);
window.addEventListener('pageshow', e => { if (e.persisted) recheck(); });
// Book mode: pages pair up inside each run of single pages. Runs restart after every double page / cover, // 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. // so one missing pair never shifts the rest of the chapter. "Shift" makes a run start with a lone page.
@@ -235,7 +289,7 @@
const n = D.next; const n = D.next;
return h('div', { class: 'end' }, h('div', { class: 'endbox' }, return h('div', { class: 'end' }, h('div', { class: 'endbox' },
h('div', { class: 'muted' }, 'End of ' + D.chapter.label + (D.chapter.title ? ' · ' + D.chapter.title : '')), 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 : ''} ›`) n ? h('a', { class: 'btn primary', href: chapterUrl(n.id), onclick: e => { e.currentTarget.href = chapterUrl(n.id); saveProgress(true); } }, `Next: ${n.label}${n.title ? ' · ' + n.title : ''} ›`)
: h('div', {}, 'You are up to date.'), : h('div', {}, 'You are up to date.'),
h('a', { class: 'btn', href: mangaUrl(D.manga.id) }, 'Back to ' + D.manga.title))); h('a', { class: 'btn', href: mangaUrl(D.manga.id) }, 'Back to ' + D.manga.title)));
} }
@@ -248,11 +302,13 @@
if (mode === 'flow') { if (mode === 'flow') {
els = list.map(p => canvas.appendChild(pageEl(p, true))); els = list.map(p => canvas.appendChild(pageEl(p, true)));
endEl = canvas.appendChild(endCard()); endEl = canvas.appendChild(endCard());
if (perf) { if (perfFlow) perfFlow.disconnect(); perfFlow = perf.watchFlow(stage, els); }
} else if (atEnd) { } else if (atEnd) {
endEl = canvas.appendChild(endCard()); endEl = canvas.appendChild(endCard());
} else { } else {
shown = mode === 'page' ? [cur] : spreads[spreadIdx]; shown = mode === 'page' ? [cur] : spreads[spreadIdx];
els = shown.map(k => canvas.appendChild(pageEl(list[k]))); els = shown.map(k => canvas.appendChild(pageEl(list[k])));
if (perf) perf.view(els.map(el => el.firstChild), { mode, page: list[shown[0]].i });
// The pages on screen get the bandwidth first, then the next ones are fetched. // 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); const token = ++buildToken, waiting = els.map(el => el.firstChild).filter(img => !img.complete);
Promise.all(waiting.map(img => new Promise(done => { Promise.all(waiting.map(img => new Promise(done => {
@@ -480,18 +536,69 @@
else toggleBars(); else toggleBars();
}); });
// Keys are looked up in READER_KEYS (keys.js), the same table Settings and "?" show.
const keyActions = {
turn: e => { e.preventDefault(); (e.key === (rtl ? 'ArrowLeft' : 'ArrowRight') ? next : prev)(); },
forward: e => { if (mode === 'flow') return; e.preventDefault(); (e.shiftKey ? prev : next)(); },
back: e => { if (mode === 'flow') return; e.preventDefault(); prev(); },
zoomIn: () => stepZoom(1),
zoomOut: () => stepZoom(-1),
zoomFit: () => setZoom(1),
moment: e => { e.preventDefault(); saveMoment(); },
bars: () => toggleBars(),
fullscreen: () => toggleFullscreen(),
help: () => showKeys(),
};
addEventListener('keydown', e => { addEventListener('keydown', e => {
if ((e.target.closest && e.target.closest('input, select, textarea')) || e.ctrlKey || e.metaKey || e.altKey) return; if ((e.target.closest && e.target.closest('input, select, textarea')) || e.ctrlKey || e.metaKey || e.altKey) return;
const k = e.key; if (document.querySelector('dialog[open]')) return; // an open dialog handles its own keys (Esc closes it)
if (k === 'ArrowLeft' || k === 'ArrowRight') { e.preventDefault(); (k === (rtl ? 'ArrowLeft' : 'ArrowRight') ? next : prev)(); } const action = keyAction(READER_KEYS, e.key);
else if ((k === ' ' || k === 'PageDown') && mode !== 'flow') { e.preventDefault(); (e.shiftKey ? prev : next)(); } if (action) keyActions[action](e);
else if (k === 'PageUp' && mode !== 'flow') { e.preventDefault(); prev(); }
else if (k === '+' || k === '=') stepZoom(1);
else if (k === '-') stepZoom(-1);
else if (k === '0') setZoom(1);
else if (k === 'm' || k === 'Escape') toggleBars();
else if (k === 'f') toggleFullscreen();
}); });
function showKeys() {
const dialog = openDialog('key-dialog', () => stage.focus({ preventScroll: true }));
dialog.append(
h('div', { class: 'moment-head' }, h('h2', {}, 'Keyboard shortcuts'),
h('button', { class: 'btn icon', type: 'button', 'aria-label': 'Close', onclick: () => dialog.done() }, '×')),
keyTable([{ title: 'Reader', keys: READER_KEYS }]),
h('p', { class: 'muted small' }, 'Also listed in Settings → Keyboard shortcuts.'));
dialog.showModal();
}
// ---------- moments ----------
const momentPages = new Set((D.moments || []).flatMap(x => x.pages));
const momentPage = p => ({ i: p.i, u: p.u, w: D.pages[p.i].w, h: D.pages[p.i].h, r: p.r });
// Positions in `list` of the page(s) on screen, in reading order. In Flow the page at the reading line.
function currentRange() {
if (atEnd && mode !== 'flow') return null;
const ks = (mode === 'book' ? shown : [cur]).filter(k => list[k]);
return ks.length ? [Math.min(...ks), Math.max(...ks)] : null;
}
function currentPages() {
const range = currentRange();
return range ? list.slice(range[0], range[1] + 1).map(momentPage) : [];
}
let momentOpen = false;
async function saveMoment() {
const range = currentRange();
if (!range) return toast('No page here');
if (momentOpen) return;
momentOpen = true;
const chapter = D.chapters.find(c => c.id === D.chapter.id), book = D.books.length > 1 && chapter && D.books.find(b => b.key === chapter.book);
// A picture can run over several pages: the sheet can add neighbours, stacked in Flow, side by side in Page/Book.
const saved = await momentSheet({
heading: 'Save moment', sequence: list.map(momentPage), range, layout: mode === 'flow' ? 'column' : 'row', rtl,
context: [D.manga.title, [book && book.label, D.chapter.label + (D.chapter.title ? ' · ' + D.chapter.title : '')].filter(Boolean).join(' · ')],
save: async v => (await api('api/moments', { m: D.manga.id, c: D.chapter.id, v: D.version, pages: v.pages.map(p => p.i), layout: v.layout,
region: v.region, cat: v.cat, note: v.note })).moment,
});
momentOpen = false;
stage.focus({ preventScroll: true });
if (!saved) return;
for (const p of saved.pages) momentPages.add(p.i);
updateUI();
toast(`Saved · ${momentCat(saved.cat).label} moment`, 1500);
}
let scrollRaf = 0; let scrollRaf = 0;
stage.addEventListener('scroll', () => { stage.addEventListener('scroll', () => {
@@ -531,7 +638,7 @@
function goChapter(c) { function goChapter(c) {
if (!c) return toast('You are up to date'); if (!c) return toast('You are up to date');
saveProgress(true); saveProgress(true);
location.href = readUrl(D.manga.id, c.id); location.href = chapterUrl(c.id);
} }
// Flow: jump to the top of the next page, but never further than ~one screen. // Flow: jump to the top of the next page, but never further than ~one screen.
function flowStep(dir) { function flowStep(dir) {
@@ -598,8 +705,8 @@
const key = mode === 'flow' ? 'hint.flow' : 'hint.paged'; const key = mode === 'flow' ? 'hint.flow' : 'hint.paged';
if (store.get(key)) return; if (store.get(key)) return;
store.set(key, '1'); store.set(key, '1');
toast(mode === 'flow' ? 'Controls hidden · tap the middle of the screen or beside the pages to show them' toast(mode === 'flow' ? 'Controls hidden · tap the middle of the screen or beside the pages (or press I) to show them'
: 'Controls hidden · tap the middle of the page or beside it to show them', 3500); : 'Controls hidden · tap the middle of the page or beside it (or press I) to show them', 3500);
} }
let toastTimer = 0; let toastTimer = 0;
@@ -625,6 +732,9 @@
sh.hidden = mode !== 'book'; sh.hidden = mode !== 'book';
sh.classList.toggle('on', shown.length > 0 && !solo(list[shown[0]]) && shift.has(list[runStart(shown[0])].i)); sh.classList.toggle('on', shown.length > 0 && !solo(list[shown[0]]) && shift.has(list[runStart(shown[0])].i));
$('#zoom').textContent = Math.round(zoom() * 100) + '%'; $('#zoom').textContent = Math.round(zoom() * 100) + '%';
const hasMoment = currentPages().some(p => momentPages.has(p.i));
$('#moment').classList.toggle('on', hasMoment);
$('#moment').title = (hasMoment ? 'This page has a moment · save another' : 'Save a moment of this page') + ' (M)';
} }
let saveTimer = 0; let saveTimer = 0;
@@ -640,8 +750,13 @@
} }
function saveProgress(keepalive) { function saveProgress(keepalive) {
clearTimeout(saveTimer); 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] }); if (stale) return; // nothing is saved until the reader chose where to go on
fetch('api/progress', { method: 'POST', keepalive: !!keepalive, headers: { 'Content-Type': 'application/json' }, body }).catch(() => {}); 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], tab, seen });
fetch('api/progress', { method: 'POST', keepalive: !!keepalive, headers: { 'Content-Type': 'application/json' }, body })
.then(r => r.json()).then(r => {
if (r.stale) checkLatest(r.latest);
else if (r.ok && r.t) markSeen(r.t);
}).catch(() => {});
} }
addEventListener('pagehide', () => saveProgress(true)); addEventListener('pagehide', () => saveProgress(true));
@@ -656,19 +771,20 @@
groups[c.book].append(h('option', { value: c.id }, `${c.label}${c.title ? ' · ' + c.title : ''}${c.extra ? ' (extra)' : ''}${c.done ? ' ✓' : ''}`)); 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.value = D.chapter.id;
chsel.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, chsel.value); }; chsel.onchange = () => { saveProgress(true); location.href = chapterUrl(chsel.value); };
$('#back').href = mangaUrl(D.manga.id); $('#back').href = mangaUrl(D.manga.id);
for (const [id, c] of [['#prevch', D.prev], ['#nextch', D.next]]) { for (const [id, c] of [['#prevch', D.prev], ['#nextch', D.next]]) {
const a = $(id); 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); } if (c) { a.href = chapterUrl(c.id); a.title = c.label + (c.title ? ' · ' + c.title : ''); a.onclick = () => { a.href = chapterUrl(c.id); saveProgress(true); }; }
else a.classList.add('disabled'); 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})` : ''))); 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.value = D.version;
ver.hidden = D.versions.length < 2; ver.hidden = D.versions.length < 2;
ver.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, D.chapter.id, ver.value); }; ver.onchange = () => { saveProgress(true); location.href = chapterUrl(D.chapter.id, ver.value); };
for (const b of modeBtns) b.onclick = () => { if (b.dataset.mode !== mode) setMode(b.dataset.mode, true); }; for (const b of modeBtns) b.onclick = () => { if (b.dataset.mode !== mode) setMode(b.dataset.mode, true); };
toneButton.onclick = cycleTone; toneButton.onclick = cycleTone;
$('#moment').onclick = saveMoment;
$('#shift').onclick = toggleShift; $('#shift').onclick = toggleShift;
$('#zoomout').onclick = () => stepZoom(-1); $('#zoomout').onclick = () => stepZoom(-1);
$('#zoom').onclick = () => setZoom(1); $('#zoom').onclick = () => setZoom(1);
@@ -686,6 +802,7 @@
for (const bar of [$('#top'), $('#bot')]) bar.addEventListener('click', e => { if (e.target.closest('button')) stage.focus({ preventScroll: true }); }); for (const bar of [$('#top'), $('#bot')]) bar.addEventListener('click', e => { if (e.target.closest('button')) stage.focus({ preventScroll: true }); });
setMode(mode, false); setMode(mode, false);
if (newer(D.latest)) checkLatest(D.latest); else markSeen(seen);
if (S.readingClock) readingClock = createReadingClock({ if (S.readingClock) readingClock = createReadingClock({
button: $('#readclock'), button: $('#readclock'),
panel: $('#clockpanel'), panel: $('#clockpanel'),
+2
View File
@@ -5,6 +5,8 @@ const SETTINGS_SECTIONS = [
{ id: 'user/reading', group: 'User', title: 'Reading', hint: 'Reader modes, page spacing and zoom.' }, { id: 'user/reading', group: 'User', title: 'Reading', hint: 'Reader modes, page spacing and zoom.' },
{ id: 'user/reading-hours', group: 'User', title: 'Reading hours', hint: 'When this server allows chapter reading.' }, { id: 'user/reading-hours', group: 'User', title: 'Reading hours', hint: 'When this server allows chapter reading.' },
{ id: 'user/reading-time', group: 'User', title: 'Reading time & privacy', hint: 'Your clock, Reading Story and personal data.' }, { id: 'user/reading-time', group: 'User', title: 'Reading time & privacy', hint: 'Your clock, Reading Story and personal data.' },
{ id: 'user/activity', group: 'User', title: 'Reading activity', hint: 'When you read: by month, week, day or hour. Private to your account.' },
{ id: 'user/shortcuts', group: 'User', title: 'Keyboard shortcuts', hint: 'Every key the reader understands. Press ? in the reader for the same list.' },
{ id: 'user/notifications', group: 'User', title: 'Notifications', hint: 'Chapter alerts and your optional webhook.' }, { id: 'user/notifications', group: 'User', title: 'Notifications', hint: 'Chapter alerts and your optional webhook.' },
{ id: 'user/account', group: 'User', title: 'Account', hint: 'Your account and password.' }, { id: 'user/account', group: 'User', title: 'Account', hint: 'Your account and password.' },
{ id: 'admin/scraper', group: 'Admin', title: 'Scraper', hint: 'Live activity, downloads, update checks and sources.' }, { id: 'admin/scraper', group: 'Admin', title: 'Scraper', hint: 'Live activity, downloads, update checks and sources.' },
+3
View File
@@ -9,7 +9,10 @@
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#121214"> <meta name="theme-color" content="#121214">
<script src="common.js" defer></script> <script src="common.js" defer></script>
<script src="keys.js" defer></script>
<script src="settings-nav.js" defer></script> <script src="settings-nav.js" defer></script>
<script src="activity-core.js" defer></script>
<script src="activity.js" defer></script>
<script src="settings.js" defer></script> <script src="settings.js" defer></script>
</head> </head>
<body> <body>
+68
View File
@@ -15,15 +15,68 @@
const saved = h('span', { class: 'saved', hidden: true }, 'Saved'); const saved = h('span', { class: 'saved', hidden: true }, 'Saved');
shell.titleRow.append(saved); shell.titleRow.append(saved);
let savedTimer = 0; let savedTimer = 0;
let refreshSessions = () => {};
async function save(patch) { async function save(patch) {
Object.assign(S, (await api('api/settings', patch)).settings); Object.assign(S, (await api('api/settings', patch)).settings);
saved.hidden = false; saved.hidden = false;
clearTimeout(savedTimer); clearTimeout(savedTimer);
savedTimer = setTimeout(() => { saved.hidden = true; }, 1500); savedTimer = setTimeout(() => { saved.hidden = true; }, 1500);
refreshSessions();
}
// Which login session holds which reader settings (reader-sessions.js on the server).
function readerSessionsCard() {
const TONE = { original: 'Original', inverted: 'Inverted', sepia: 'Sepia', dim: 'Dim', 'extra-dim': 'Extra dim' };
const START = { last: 'last mode', flow: 'always Flow', page: 'always Page', book: 'always Book' };
const summary = r => [`${LABEL[r.lastMode] || r.lastMode} (opens: ${START[r.startMode] || r.startMode})`, `tone ${TONE[r.pageTone] || r.pageTone}`,
'zoom ' + (Object.keys(r.zoom || {}).length ? Object.entries(r.zoom).map(([k, z]) => `${LABEL[k] || k} ${Math.round(z * 100)}%`).join(', ') : 'fit'),
r.skipExtras ? 'skips extras' : null, r.skipCovers ? 'skips covers' : null, r.flowPageGap === false ? 'no Flow gap' : null].filter(Boolean).join(' · ');
const state = h('div', {}, h('p', { class: 'muted' }, 'Loading sessions…'));
const message = h('span', { class: 'small' });
async function act(id, action, current) {
try {
draw(await api('api/reader-sessions', { id, action }));
// This page's controls show this session's settings; reload them when they changed.
if (current && action === 'follow') location.reload();
} catch (error) { message.textContent = error.message; message.className = 'small error'; }
}
function draw(o) {
const current = o.sessions.find(s => s.current), source = o.sessions.find(s => s.id === o.latestFrom);
const latestText = !o.latestFrom ? 'one shared set (changed while per-session settings were off, or never changed)'
: source ? (source.current ? 'this session' : `${source.device}, active ${ago(source.seen)}`) : 'a session that has since signed out';
const intent = !o.enabled ? 'Off: every session uses the same reader settings, and a change applies everywhere.'
: current?.locked ? 'This session has its own reader settings. Changes here stay here and become the starting point for new sessions.'
: 'This session follows the latest reader settings. Your first change here gives it its own copy, which stays put when other sessions change theirs.';
state.replaceChildren(
h('p', {}, h('b', {}, 'Now: '), intent),
o.enabled ? h('p', { class: 'muted small' }, 'New sessions start from the latest settings: ', h('b', {}, summary(o.latest)), ` — from ${latestText}.`)
: h('p', { class: 'muted small' }, 'Shared settings: ', h('b', {}, summary(o.latest))),
h('ul', { class: 'reader-sessions' }, o.sessions.map(s => h('li', {},
h('div', {}, h('b', {}, s.device), s.current ? ' (this session)' : '', ' · ',
h('span', { class: 'muted small' }, `signed in ${new Date(s.created).toLocaleDateString()}, active ${ago(s.seen)}`)),
h('div', { class: 'small' }, !o.enabled ? 'Uses the shared settings' + (s.locked ? ' (its own copy is kept for when this is on again)' : '')
: s.locked ? 'Own settings: ' + summary(s.reader) : 'Follows the latest settings'),
o.enabled ? h('div', { class: 'row' },
s.locked ? h('button', { class: 'btn small', onclick: () => act(s.id, 'follow', s.current) }, 'Follow latest again') : null,
s.locked && s.id !== o.latestFrom ? h('button', { class: 'btn small', onclick: () => act(s.id, 'latest', s.current) }, 'Use for new sessions') : null) : null))),
message);
}
refreshSessions = () => api('api/reader-sessions').then(draw, error => { message.textContent = error.message; message.className = 'small error'; });
refreshSessions();
return h('section', { class: 'card' },
h('h2', {}, 'Reader settings per session'),
// The controls above switch between this session's set and the shared one, so reload them.
h('label', { class: 'check' },
h('input', { type: 'checkbox', checked: !!S.readerPerSession, onchange: async e => { await save({ readerPerSession: e.target.checked }); location.reload(); } }),
h('span', {}, 'Keep reader settings per session', h('div', { class: 'muted small' }, 'Each sign-in (device or browser) keeps its own reader mode, zoom, tone and the other reader preferences above once you change one there. A new sign-in copies the settings of the session you used last. Turn this off for one shared set.'))),
state);
} }
const check = (key, text, hint) => h('label', { class: 'check' }, const check = (key, text, hint) => h('label', { class: 'check' },
h('input', { type: 'checkbox', checked: !!S[key], onchange: e => save({ [key]: e.target.checked }) }), 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)); h('span', {}, text, hint ? h('div', { class: 'muted small' }, hint) : null));
const number = (key, text, max) => h('label', { class: 'field' }, h('span', {}, text),
h('input', { type: 'number', min: 0, max, step: 1, value: S[key], style: 'width:6em',
onchange: e => { const n = Math.round(+e.target.value); if (n >= 0 && n <= max) save({ [key]: n }); else e.target.value = S[key]; } }));
const startMode = h('select', { onchange: e => save({ startMode: e.target.value }) }, const startMode = h('select', { onchange: e => save({ startMode: e.target.value }) },
h('option', { value: 'last' }, 'The mode I used last'), h('option', { value: 'last' }, 'The mode I used last'),
@@ -90,6 +143,16 @@
h('button', { class: 'btn small', onclick: async () => { await save({ zoom: {} }); zoomText.textContent = zooms(); } }, 'Reset to fit'), 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.')))); 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.'))));
section('user/reading').append(readerSessionsCard());
section('user/reading').append(h('section', { class: 'card' },
h('h2', {}, 'Library page'),
check('continueRow', 'Show "Continue where you left off" at the top',
'One row with the five manga you read most recently that still have chapters left after the last one you read. It hides itself while you search, filter by status, or sort by "Recently read".'),
h('p', { class: 'muted small' }, 'Rereading: later chapters you finished in an earlier pass count as left again when either rule applies. 0 turns a rule off.'),
number('rereadDays', 'Finished more than this many days before my last read', 3650),
number('rereadChapters', 'Or I have since read more than this many chapters in a row', 1000)));
section('user/reading').append(h('section', { class: 'card' }, section('user/reading').append(h('section', { class: 'card' },
h('h2', {}, 'Chapter list dates'), h('h2', {}, 'Chapter list dates'),
h('label', { class: 'field' }, h('span', {}, 'When I read a chapter'), readDate, h('label', { class: 'field' }, h('span', {}, 'When I read a chapter'), readDate,
@@ -121,6 +184,10 @@
'Shows your private chapter times and personal summaries on manga and library pages. Hiding it does not delete existing history.'), 'Shows your private chapter times and personal summaries on manga and library pages. Hiding it does not delete existing history.'),
readingData)); readingData));
const activity = activityCard();
section('user/activity').append(activity.element);
controllers.set('user/activity', activity);
const hook = h('input', { type: 'url', placeholder: 'https://ntfy.sh/my-manga-topic', value: S.webhook || '', style: 'width:100%' }); 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' }); const hookMsg = h('div', { class: 'small' });
section('user/notifications').append(h('section', { class: 'card' }, section('user/notifications').append(h('section', { class: 'card' },
@@ -135,6 +202,7 @@
const oldPw = h('input', { type: 'password', autocomplete: 'current-password', placeholder: 'Current password' }); 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 newPw = h('input', { type: 'password', autocomplete: 'new-password', placeholder: 'New password' });
const pwMsg = h('span', { class: 'small' }); const pwMsg = h('span', { class: 'small' });
section('user/shortcuts').append(h('section', { class: 'card' }, keyTable()));
section('user/account').append(h('section', { class: 'card' }, section('user/account').append(h('section', { class: 'card' },
h('h2', {}, 'Account · ' + me.name), h('h2', {}, 'Account · ' + me.name),
h('div', { class: 'row' }, oldPw, newPw, h('button', { class: 'btn', onclick: async () => { h('div', { class: 'row' }, oldPw, newPw, h('button', { class: 'btn', onclick: async () => {
+88
View File
@@ -0,0 +1,88 @@
'use strict';
/* Reader settings per login session (docs/architecture.md, "Reader settings per session").
*
* The user's own reader settings (`user.settings`) are the "latest" set. A session that never changed a
* reader setting follows the latest set; the first change copies the latest set into the session and
* locks it there, and also makes it the new latest set. Using a locked session makes its set the latest
* again, so a new login starts from the last active session. With `readerPerSession: false` there is
* one set per user, as before; locked sets are kept and return when the switch is turned on again. */
const crypto = require('crypto');
const READER_KEYS = ['startMode', 'lastMode', 'adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'pageTone', 'zoom'];
const perSession = settings => settings.readerPerSession !== false;
const publicId = key => crypto.createHash('sha256').update('reader-session:' + key).digest('hex').slice(0, 16);
function pick(settings) {
const out = {};
for (const k of READER_KEYS) if (settings[k] !== undefined) out[k] = k === 'zoom' ? { ...settings[k] } : settings[k];
return out;
}
// `base` is the user's merged settings (defaults + user.settings); the session's locked set wins over it.
function effective(base, session) {
if (!perSession(base) || !session?.reader) return base;
return { ...base, ...pick(session.reader), zoom: { ...session.reader.zoom } };
}
// Splits a validated settings patch: reader keys go to the session (locking it) and to the latest set.
function applyPatch({ user, base, session, key, patch }) {
const s = (user.settings = user.settings || {});
const reader = pick(patch), rest = Object.fromEntries(Object.entries(patch).filter(([k]) => !READER_KEYS.includes(k)));
Object.assign(s, rest);
if (!Object.keys(reader).length) return;
if (perSession({ ...base, ...rest }) && session) {
session.reader = { ...pick(effective(base, session)), ...reader };
Object.assign(s, pick(session.reader));
user.readerFrom = publicId(key);
} else {
Object.assign(s, reader);
user.readerFrom = null;
}
}
// A request from a locked session makes its set the latest one. Returns true when something changed.
function noteActive(user, base, session, key) {
if (!perSession(base) || !session?.reader) return false;
const id = publicId(key);
if (user.readerFrom === id) return false;
user.settings = { ...(user.settings || {}), ...pick(session.reader) };
user.readerFrom = id;
return true;
}
function deviceLabel(agent) {
const a = String(agent || '');
if (!a) return 'Unknown device';
const os = /iPhone/.test(a) ? 'iPhone' : /iPad/.test(a) ? 'iPad' : /Android/.test(a) ? 'Android'
: /Windows/.test(a) ? 'Windows' : /Mac OS X|Macintosh/.test(a) ? 'Mac' : /CrOS/.test(a) ? 'ChromeOS' : /Linux/.test(a) ? 'Linux' : '';
const browser = /Edg\//.test(a) ? 'Edge' : /OPR\//.test(a) ? 'Opera' : /Firefox\//.test(a) ? 'Firefox'
: /Chrome\//.test(a) ? 'Chrome' : /Safari\//.test(a) ? 'Safari' : '';
return [browser, os].filter(Boolean).join(' on ') || a.slice(0, 60);
}
// What Settings shows: every login session of the user with its state and effective reader set.
function overview(user, base, sessions, currentKey) {
const list = Object.entries(sessions).filter(([, s]) => s.u === user.name).map(([key, s]) => ({
id: publicId(key), device: deviceLabel(s.agent), created: s.t, seen: s.seen || s.t,
current: key === currentKey, locked: !!s.reader, reader: pick(effective(base, s)),
})).sort((a, b) => b.current - a.current || b.seen - a.seen);
return { enabled: perSession(base), current: currentKey ? publicId(currentKey) : null,
latestFrom: user.readerFrom || null, latest: pick(base), sessions: list };
}
// Settings actions on one of the user's sessions: 'follow' drops its lock, 'latest' makes its set the latest.
function act(user, base, sessions, id, action) {
const entry = Object.entries(sessions).find(([key, s]) => s.u === user.name && publicId(key) === id);
if (!entry) return false;
const [key, session] = entry;
if (action === 'follow') { delete session.reader; if (user.readerFrom === id) user.readerFrom = null; return true; }
if (action === 'latest') {
user.settings = { ...(user.settings || {}), ...pick(effective(base, session)) };
user.readerFrom = session.reader ? publicId(key) : null;
return true;
}
return false;
}
module.exports = { READER_KEYS, publicId, pick, effective, applyPatch, noteActive, deviceLabel, overview, act };
+114
View File
@@ -0,0 +1,114 @@
'use strict';
// Reading activity: active reader time in small UTC time buckets, for the activity grid in Settings.
// Buckets hold time and which manga it went to, never pages, input events or a raw event log.
// Recent time keeps 6-minute buckets (a tenth of an hour); older time folds into hours.
const FINE_MS = 6 * 60 * 1000, HOUR_MS = 60 * 60 * 1000, DAY_MS = 24 * HOUR_MS;
const FINE_DAYS = 120, KEEP_DAYS = 3 * 366;
const whole = value => Math.max(0, Math.round(Number(value) || 0));
function emptyActivity() { return { schema: 1, fine: {}, hourly: {}, backfilled: false }; }
function normalizeActivity(activity) {
const out = activity && typeof activity === 'object' ? activity : emptyActivity();
if (!out.fine || typeof out.fine !== 'object') out.fine = {};
if (!out.hourly || typeof out.hourly !== 'object') out.hourly = {};
out.schema = 1;
return out;
}
function credit(map, key, mangaId, ms) {
const bucket = (map[key] = map[key] || { a: 0, m: {} });
bucket.a += ms;
bucket.m[mangaId] = (bucket.m[mangaId] || 0) + ms;
}
// Active time `ms` that ended at `end`, spread over the buckets it covers.
function addActive(activity, mangaId, ms, end) {
const act = normalizeActivity(activity);
let left = whole(ms), at = whole(end);
if (!left || !mangaId) return act;
while (left > 0) {
const key = Math.floor((at - 1) / FINE_MS), share = Math.min(left, at - key * FINE_MS);
credit(act.fine, key, mangaId, share);
left -= share; at -= share;
}
return act;
}
// Fine buckets older than FINE_DAYS become hourly; anything older than KEEP_DAYS is dropped.
function fold(activity, now = Date.now()) {
const act = normalizeActivity(activity);
const fineLimit = Math.floor((now - FINE_DAYS * DAY_MS) / FINE_MS), keepLimit = Math.floor((now - KEEP_DAYS * DAY_MS) / HOUR_MS);
for (const [key, bucket] of Object.entries(act.fine)) {
if (+key >= fineLimit) continue;
const hour = Math.floor(+key * FINE_MS / HOUR_MS);
for (const [mangaId, ms] of Object.entries(bucket.m)) credit(act.hourly, hour, mangaId, ms);
delete act.fine[key];
}
for (const key of Object.keys(act.hourly)) if (+key < keepLimit) delete act.hourly[key];
return act;
}
// One-time seed from the visits the Reading Clock still remembers (the last eight per chapter).
// A visit's active time is placed just before its last save; older visits are gone and stay gone.
function backfill(activity, readingByManga, now = Date.now()) {
const act = normalizeActivity(activity);
if (act.backfilled) return act;
for (const [mangaId, chapters] of Object.entries(readingByManga || {})) {
for (const record of Object.values(chapters || {})) {
for (const receipt of record?.recentSessions || []) {
const pass = receipt?.pass, ms = whole(pass?.activeMs), end = whole(receipt?.at);
if (!ms || !end || end > now) continue;
const start = Math.max(whole(pass.startedAt) || end - ms, end - ms);
addActive(act, mangaId, Math.min(ms, end - start), end);
}
}
}
act.backfilled = true;
return fold(act, now);
}
function forgetManga(activity, mangaId) {
const act = normalizeActivity(activity);
for (const map of [act.fine, act.hourly]) {
for (const [key, bucket] of Object.entries(map)) {
const ms = bucket.m[mangaId];
if (!ms) continue;
delete bucket.m[mangaId];
bucket.a = Math.max(0, bucket.a - ms);
if (!bucket.a) delete map[key];
}
}
return act;
}
// Compact payload for the grid: [startMs, sizeMs, activeMs, [[mangaIndex, ms], …]] per bucket,
// chapter completions as [doneAt, mangaIndex, chapterLabel]. The browser groups them in local time.
function activitySeries(activity, progressByManga, { titleOf = id => id, labelOf = (m, c) => c } = {}) {
const act = normalizeActivity(activity);
const mangas = [], index = new Map();
const ref = id => {
if (!index.has(id)) { index.set(id, mangas.length); mangas.push({ id, title: titleOf(id) || id }); }
return index.get(id);
};
const buckets = [];
for (const [map, size] of [[act.hourly, HOUR_MS], [act.fine, FINE_MS]]) {
for (const [key, bucket] of Object.entries(map)) {
if (!bucket.a) continue;
buckets.push([+key * size, size, whole(bucket.a), Object.entries(bucket.m).map(([id, ms]) => [ref(id), whole(ms)])]);
}
}
buckets.sort((a, b) => a[0] - b[0]);
const done = [];
for (const [mangaId, chapters] of Object.entries(progressByManga || {})) {
for (const [chapterId, p] of Object.entries(chapters || {})) {
if (p && p.done && whole(p.doneAt)) done.push([whole(p.doneAt), ref(mangaId), labelOf(mangaId, chapterId)]);
}
}
done.sort((a, b) => a[0] - b[0]);
return { schema: 1, fineMs: FINE_MS, fineDays: FINE_DAYS, mangas, buckets, done };
}
module.exports = { FINE_MS, HOUR_MS, FINE_DAYS, KEEP_DAYS, emptyActivity, addActive, fold, backfill, forgetManga, activitySeries };
+1
View File
@@ -35,6 +35,7 @@ async function body(req) {
if (url.pathname === '/health') return json(res, 200, { ok: true, status: service.state.status }); if (url.pathname === '/health') return json(res, 200, { ok: true, status: service.state.status });
if (TOKEN && req.headers.authorization !== `Bearer ${TOKEN}`) return json(res, 401, { error: 'Unauthorized' }); if (TOKEN && req.headers.authorization !== `Bearer ${TOKEN}`) return json(res, 401, { error: 'Unauthorized' });
if (req.method === 'GET' && url.pathname === '/api/state') return json(res, 200, await service.publicState()); if (req.method === 'GET' && url.pathname === '/api/state') return json(res, 200, await service.publicState());
if (req.method === 'GET' && url.pathname === '/api/revision') return json(res, 200, service.revision());
if (req.method !== 'POST') return json(res, 404, { error: 'Not found' }); if (req.method !== 'POST') return json(res, 404, { error: 'Not found' });
const input = await body(req); const input = await body(req);
if (url.pathname === '/api/queue') return json(res, 200, { item: await service.enqueue(input), state: await service.publicState() }); if (url.pathname === '/api/queue') return json(res, 200, { item: await service.enqueue(input), state: await service.publicState() });
+15 -4
View File
@@ -16,6 +16,7 @@ const { writeJsonAtomic } = require('./lib/json-file');
const { ReleaseDateSources, refreshMangaReleaseDates } = require('./metadata/release-dates'); const { ReleaseDateSources, refreshMangaReleaseDates } = require('./metadata/release-dates');
const RATE_KEYS = ['minMs', 'maxMs', 'chapterMinMs', 'chapterMaxMs']; const RATE_KEYS = ['minMs', 'maxMs', 'chapterMinMs', 'chapterMaxMs'];
const INVENTORY_SUMMARY_MS = 15 * 60000; // status summary age limit; decisions still scan (libraryInventory)
const FAST_RATE_SETTINGS = { const FAST_RATE_SETTINGS = {
minMs: 1000, maxMs: 4000, chapterMinMs: 5000, chapterMaxMs: 15000, minMs: 1000, maxMs: 4000, chapterMinMs: 5000, chapterMaxMs: 15000,
}; };
@@ -401,7 +402,7 @@ class ScraperService {
} catch (error) { await this.event('warning', `Release dates for ${manga.title} failed: ${error.message}`, { mangaId: manga.id }); } } catch (error) { await this.event('warning', `Release dates for ${manga.title} failed: ${error.message}`, { mangaId: manga.id }); }
} }
// The reader reconciles fully when the revision rises without a visible folder change. // The reader reconciles fully when the revision rises without a visible folder change.
if (written) { this.state.publishedRevision = Number(this.state.publishedRevision || 0) + 1; await this.persist(); } if (written) { this.invalidateInventory(); this.state.publishedRevision = Number(this.state.publishedRevision || 0) + 1; await this.persist(); }
await this.event('info', `Release dates: ${written} chapter${written === 1 ? '' : 's'} dated in ${checked} ongoing series`, { reason, checked, written }); await this.event('info', `Release dates: ${written} chapter${written === 1 ? '' : 's'} dated in ${checked} ongoing series`, { reason, checked, written });
return { checked, written }; return { checked, written };
} }
@@ -583,6 +584,15 @@ class ScraperService {
} catch { return null; } } catch { return null; }
} }
inventoryStale() {
return this.inventoryCacheGeneration !== this.inventoryGeneration || Date.now() - this.inventoryCachedAt >= INVENTORY_SUMMARY_MS;
}
// What the reader polls every few seconds: no library walk, no event log read.
revision() {
return { publishedRevision: this.state.publishedRevision || 0, status: this.state.status, paused: this.state.paused };
}
async publicState() { async publicState() {
const activeItems = this.state.queue.filter(item => Object.values(this.state.currentIds || {}).includes(item.id)); const activeItems = this.state.queue.filter(item => Object.values(this.state.currentIds || {}).includes(item.id));
const active = await Promise.all(activeItems.map(async current => ({ const active = await Promise.all(activeItems.map(async current => ({
@@ -593,8 +603,9 @@ class ScraperService {
const job = active[0]?.job || null; const job = active[0]?.job || null;
// Status and watchdog probes must not wait for thousands of bind-mount reads. // Status and watchdog probes must not wait for thousands of bind-mount reads.
// Share one background scan, serve the previous summary, and surface its age/error. // Share one background scan, serve the previous summary, and surface its age/error.
if (!this.inventoryRefresh && (this.inventoryCacheGeneration !== this.inventoryGeneration || Date.now() - this.inventoryCachedAt >= 60000) // The scraper is the library's only writer and invalidates the summary on every publish, so the age
&& Date.now() - this.inventoryAttemptedAt >= 10000) { // limit only catches outside edits. A walk reads every chapter.json: on a NAS that is ~10,000 opens.
if (!this.inventoryRefresh && this.inventoryStale() && Date.now() - this.inventoryAttemptedAt >= 10000) {
this.refreshInventory().catch(() => {}); this.refreshInventory().catch(() => {});
} }
const inventory = this.inventoryCache; const inventory = this.inventoryCache;
@@ -609,7 +620,7 @@ class ScraperService {
updateChecks: Object.values(this.state.updateChecks || {}), updateChecks: Object.values(this.state.updateChecks || {}),
libraryStatus: { libraryStatus: {
ready: !!inventory, refreshing: !!this.inventoryRefresh, ready: !!inventory, refreshing: !!this.inventoryRefresh,
stale: this.inventoryCacheGeneration !== this.inventoryGeneration || Date.now() - this.inventoryCachedAt >= 60000, stale: this.inventoryStale(),
updated: this.inventoryCachedAt ? new Date(this.inventoryCachedAt).toISOString() : null, updated: this.inventoryCachedAt ? new Date(this.inventoryCachedAt).toISOString() : null,
error: this.inventoryError, error: this.inventoryError,
}, },
+17
View File
@@ -80,6 +80,23 @@ test('background inventory failures are visible without breaking controls or lau
assert.equal(scans, 2); assert.equal(scans, 2);
}); });
test('status polling walks the library again only after a change or after 15 minutes', async () => {
let scans = 0;
const service = fixture(async () => { scans++; return inventory('Demo'); });
await service.libraryInventory(true);
service.inventoryCachedAt = Date.now() - 10 * 60000; service.inventoryAttemptedAt = 0;
for (let n = 0; n < 5; n++) await service.publicState();
assert.equal(scans, 1);
assert.deepEqual(service.revision(), { publishedRevision: 0, status: 'paused', paused: true });
assert.equal(scans, 1);
service.invalidateInventory();
await service.publicState(); await service.inventoryRefresh;
assert.equal(scans, 2);
service.inventoryCachedAt = Date.now() - 16 * 60000; service.inventoryAttemptedAt = 0;
await service.publicState(); await service.inventoryRefresh;
assert.equal(scans, 3);
});
test('dashboard polling reuses a recent snapshot while decisions still force a fresh scan', async () => { test('dashboard polling reuses a recent snapshot while decisions still force a fresh scan', async () => {
let scans = 0; let scans = 0;
const service = fixture(async () => { scans++; return inventory('Demo'); }); const service = fixture(async () => { scans++; return inventory('Demo'); });
+277 -42
View File
@@ -12,6 +12,8 @@
* SCRAPER_URL=http://127.0.0.1:8091 scraper daemon API * SCRAPER_URL=http://127.0.0.1:8091 scraper daemon API
* SCRAPER_TOKEN=... shared daemon token (optional on loopback) * SCRAPER_TOKEN=... shared daemon token (optional on loopback)
* BACKUP=/backup folder for built-in DATA backups (unset = backups off) * BACKUP=/backup folder for built-in DATA backups (unset = backups off)
* READ_AHEAD=1 read an opened chapter's pages (then the next chapter's) into the OS cache, 0 = off
* PERF_TRACE=1 performance trace to DATA/perf/ (or touch DATA/perf/trace.on at runtime; docs/performance.md)
*/ */
const http = require('http'), fs = require('fs'), path = require('path'), crypto = require('crypto'); const http = require('http'), fs = require('fs'), path = require('path'), crypto = require('crypto');
const { scanLibrary, scanChangedLibrary, libraryStamps, snapshotIndex, restoreIndex } = require('./library'); const { scanLibrary, scanChangedLibrary, libraryStamps, snapshotIndex, restoreIndex } = require('./library');
@@ -19,6 +21,7 @@ const { Catalog, CatalogError, collectionViews, TYPES, ROLES } = require('./cata
const { RATINGS: GREATEST_RATINGS, loadCatalog: loadGreatestCatalog, findOwned: findGreatestOwned, const { RATINGS: GREATEST_RATINGS, loadCatalog: loadGreatestCatalog, findOwned: findGreatestOwned,
countsFor: greatestCounts, scoreFor: greatestScore } = require('./greatest'); countsFor: greatestCounts, scoreFor: greatestScore } = require('./greatest');
const { hosterLinks } = require('./greatest/hosters'); const { hosterLinks } = require('./greatest/hosters');
const { GreatestCovers } = require('./greatest/covers');
const { secret } = require('./runtime/config'); const { secret } = require('./runtime/config');
const { PeeringState, requirePeer } = require('./peering/state'); const { PeeringState, requirePeer } = require('./peering/state');
const peerClient = require('./peering/client'); const peerClient = require('./peering/client');
@@ -31,9 +34,14 @@ const { shouldRunNightly, Harvester } = require('./discovery/harvest');
const { const {
applyPulse, applyVisit, chapterInsight, earliestCompletion, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary, applyPulse, applyVisit, chapterInsight, earliestCompletion, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary,
} = require('./reading-analytics'); } = require('./reading-analytics');
const readingActivity = require('./reading-activity');
const { chapterDates } = require('./chapter-dates'); const { chapterDates } = require('./chapter-dates');
const { parseSchedule, publicSchedule, loadQuestionBank, createReadingAccess } = require('./reading-access'); const { parseSchedule, publicSchedule, loadQuestionBank, createReadingAccess } = require('./reading-access');
const { BackupService, BackupError } = require('./backup'); const { BackupService, BackupError } = require('./backup');
const moments = require('./moments');
const { Tracer, requestTrace } = require('./perf/trace');
const { ReadAhead } = require('./perf/read-ahead');
const readerSessions = require('./reader-sessions');
const PORT = +process.env.PORT || 8080; const PORT = +process.env.PORT || 8080;
const LIB = path.resolve(process.env.LIBRARY || 'library'); const LIB = path.resolve(process.env.LIBRARY || 'library');
@@ -62,11 +70,18 @@ const MIME = {
// ---------- storage: one JSON file, written atomically ---------- // ---------- storage: one JSON file, written atomically ----------
fs.mkdirSync(DATA, { recursive: true }); fs.mkdirSync(DATA, { recursive: true });
const tracer = new Tracer({ dir: path.join(DATA, 'perf') }).start();
const readAhead = process.env.READ_AHEAD === '0' ? null
: new ReadAhead({ onBatch: batch => tracer.log({ k: 'ahead', ...batch }) });
const catalog = new Catalog(DATA); const catalog = new Catalog(DATA);
const greatestCatalog = loadGreatestCatalog(); const greatestCatalog = loadGreatestCatalog();
const greatestById = new Map(greatestCatalog.entries.map(entry => [entry.id, entry])); const greatestById = new Map(greatestCatalog.entries.map(entry => [entry.id, entry]));
const greatestCovers = new GreatestCovers(path.join(DATA, 'greatest-covers'));
// The versioned URL lets browsers keep a cover for a year; a new catalog URL changes `v`.
const greatestCoverUrl = entry => entry.cover
? `api/greatest-cover?id=${encodeURIComponent(entry.id)}&v=${greatestCovers.version(entry.cover)}` : null;
const DB_FILE = path.join(DATA, 'db.json'), CACHE_FILE = path.join(DATA, 'scan-cache.json'), INDEX_FILE = path.join(DATA, 'library-index.json'); const DB_FILE = path.join(DATA, 'db.json'), CACHE_FILE = path.join(DATA, 'scan-cache.json'), INDEX_FILE = path.join(DATA, 'library-index.json');
const db = { users: {}, sessions: {}, progress: {}, reading: {}, readingAccess: {}, follows: {}, notes: {}, votes: {}, greatestRatings: {}, discoveryFeedback: {}, known: {} }; const db = { users: {}, sessions: {}, progress: {}, reading: {}, activity: {}, readingAccess: {}, follows: {}, notes: {}, votes: {}, greatestRatings: {}, discoveryFeedback: {}, moments: {}, known: {} };
// A corrupt db.json stops the server instead of silently starting empty and overwriting it. // 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'))); 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); } function writeAtomic(file, text) { fs.writeFileSync(file + '.tmp', text); fs.renameSync(file + '.tmp', file); }
@@ -75,6 +90,7 @@ function save() { saveTimer = saveTimer || setTimeout(flush, 500); }
function flush() { clearTimeout(saveTimer); saveTimer = null; writeAtomic(DB_FILE, JSON.stringify(db)); } 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); }); for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { flush(); process.exit(0); });
db.readingAccess ||= {}; db.readingAccess ||= {};
db.activity ||= {};
// The reader owns backups: it alone mounts all of DATA and holds db.json in memory. // The reader owns backups: it alone mounts all of DATA and holds db.json in memory.
const backup = new BackupService({ dataRoot: DATA, backupRoot: process.env.BACKUP || '', snapshotDb: () => JSON.stringify(db), const backup = new BackupService({ dataRoot: DATA, backupRoot: process.env.BACKUP || '', snapshotDb: () => JSON.stringify(db),
scraper: { state: () => scraperRequest('/api/state'), control: action => scraperRequest('/api/control', { action }) } }).start(); scraper: { state: () => scraperRequest('/api/state'), control: action => scraperRequest('/api/control', { action }) } }).start();
@@ -106,8 +122,10 @@ function checkPw(pw, stored) {
} }
const DUMMY_HASH = hashPw('x'); const DUMMY_HASH = hashPw('x');
const DEFAULTS = { startMode: 'last', lastMode: 'page', adapt: true, skipExtras: false, skipCovers: false, flowPageGap: true, pageTone: 'original', readingClock: false, readingStory: true, const DEFAULTS = { startMode: 'last', lastMode: 'page', adapt: true, skipExtras: false, skipCovers: false, flowPageGap: true, pageTone: 'original', readingClock: false, readingStory: true,
readDate: 'hour', releaseDate: true, discoveryEnabled: false, discoveryReadingActivity: false, discoveryMature: false, zoom: {}, webhook: '' }; continueRow: true, rereadDays: 7, rereadChapters: 5, readDate: 'hour', releaseDate: true, discoveryEnabled: false, discoveryReadingActivity: false, discoveryMature: false, zoom: {}, webhook: '', readerPerSession: true };
const settingsOf = u => ({ ...DEFAULTS, ...u.settings, zoom: { ...(u.settings && u.settings.zoom) } }); const userSettingsOf = u => ({ ...DEFAULTS, ...u.settings, zoom: { ...(u.settings && u.settings.zoom) } });
// With a login session, its locked reader settings (reader-sessions.js) win over the user's latest set.
const settingsOf = (u, sess) => readerSessions.effective(userSettingsOf(u), sess);
function createUser(name, pw, admin, replace) { function createUser(name, pw, admin, replace) {
name = String(name || '').trim().toLowerCase(); pw = String(pw || ''); name = String(name || '').trim().toLowerCase(); pw = String(pw || '');
@@ -119,13 +137,21 @@ function createUser(name, pw, admin, replace) {
save(); save();
return db.users[name]; return db.users[name];
} }
function startSession(res, user) { function startSession(req, res, user) {
const token = crypto.randomBytes(32).toString('base64url'); const token = crypto.randomBytes(32).toString('base64url');
db.sessions[sha(token)] = { u: user.name, t: Date.now() }; db.sessions[sha(token)] = { u: user.name, t: Date.now(), agent: String(req.headers['user-agent'] || '').slice(0, 300) };
save(); save();
res.setHeader('Set-Cookie', `sid=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${SECURE_COOKIE ? '; Secure' : ''}`); 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; }; const tokenOf = req => { const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || ''); return m ? m[1] : null; };
function sessionKeyOf(req) { const t = tokenOf(req); return t ? sha(t) : null; }
// Last-seen time (coarse, to keep db.json writes rare) and device label of a login session.
function touchSession(u, sess, key, req) {
let changed = false;
if (!sess.agent && req.headers['user-agent']) { sess.agent = String(req.headers['user-agent']).slice(0, 300); changed = true; }
if (Date.now() - (sess.seen || 0) > 5 * 60e3) { sess.seen = Date.now(); changed = true; }
if (changed) save();
}
function userOf(req) { const t = tokenOf(req), s = t && db.sessions[sha(t)]; return (s && db.users[s.u]) || 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]; for (const [k, s] of Object.entries(db.sessions)) if (Date.now() - s.t > 365 * 864e5 || !db.users[s.u]) delete db.sessions[k];
@@ -169,8 +195,10 @@ async function performScan(mode) {
scanState = { at: Date.now(), ms: Date.now() - t0, mode, changedMangas: changed.length, scanState = { at: Date.now(), ms: Date.now() - t0, mode, changedMangas: changed.length,
mangas: lib.mangas.length, chapters, pages, stale: false, error: null }; mangas: lib.mangas.length, chapters, pages, stale: false, error: null };
console.log(`[scan] ${mode}: ${changed.length} changed manga, ${lib.mangas.length} manga, ${chapters} chapters, ${pages} pages in ${scanState.ms} ms`); console.log(`[scan] ${mode}: ${changed.length} changed manga, ${lib.mangas.length} manga, ${chapters} chapters, ${pages} pages in ${scanState.ms} ms`);
tracer.log({ k: 'scan', mode, ms: scanState.ms, changed: changed.length });
return scanState; return scanState;
} catch (e) { } catch (e) {
tracer.log({ k: 'scan', mode, ms: Date.now() - t0, error: e.message });
scanState = { ...scanState, at: Date.now(), error: e.message }; scanState = { ...scanState, at: Date.now(), error: e.message };
console.error('[scan] failed:', e.message); console.error('[scan] failed:', e.message);
return scanState; return scanState;
@@ -225,7 +253,28 @@ const chapterOf = (m, id) => m.chapterById.get(String(id)) || bad(404, 'Chapter
const progressOf = (u, m) => (db.progress[u.name] && db.progress[u.name][m.id]) || {}; const progressOf = (u, m) => (db.progress[u.name] && db.progress[u.name][m.id]) || {};
const mangaReadingOf = (u, m) => (db.reading[u.name] && db.reading[u.name][m.id]) || {}; const mangaReadingOf = (u, m) => (db.reading[u.name] && db.reading[u.name][m.id]) || {};
const readingOf = (u, m, ch) => (db.reading[u.name] && db.reading[u.name][m.id] && db.reading[u.name][m.id][ch.id]) || null; const readingOf = (u, m, ch) => (db.reading[u.name] && db.reading[u.name][m.id] && db.reading[u.name][m.id][ch.id]) || null;
const activityPayload = u => readingActivity.activitySeries(activityOf(u), db.progress[u.name], {
titleOf: id => lib.byId.get(id)?.title,
labelOf: (mangaId, chapterId) => lib.byId.get(mangaId)?.chapterById.get(chapterId)?.label || chapterId,
});
// The activity grid's buckets; the first use seeds them from the visits the clock still remembers.
function activityOf(u, now = Date.now()) {
if (!db.activity[u.name]) { db.activity[u.name] = readingActivity.backfill(readingActivity.emptyActivity(), db.reading[u.name], now); save(); }
const act = db.activity[u.name];
// Folding old buckets into hours is cheap, but there is no need to do it on every pulse.
if (Math.floor(now / readingActivity.HOUR_MS) !== act.foldedHour) { readingActivity.fold(act, now); act.foldedHour = Math.floor(now / readingActivity.HOUR_MS); }
return act;
}
const unreadOf = u => (db.notes[u.name] || []).filter(n => !n.read).length; const unreadOf = u => (db.notes[u.name] || []).filter(n => !n.read).length;
const momentsOf = u => (db.moments[u.name] ||= []);
const momentOf = (u, id) => momentsOf(u).find(x => x.id === String(id || '')) || bad(404, 'Moment not found');
const fileSha = async rel => sha256File(path.join(LIB, rel)).catch(() => null);
function sha256File(file) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
fs.createReadStream(file).on('error', reject).on('data', d => hash.update(d)).on('end', () => resolve(hash.digest('hex')));
});
}
const admin = u => u.admin || bad(403, 'Admins only'); const admin = u => u.admin || bad(403, 'Admins only');
const READING_LEASE_MS = 15000; const READING_LEASE_MS = 15000;
const readingLeases = new Map(); const readingLeases = new Map();
@@ -238,9 +287,14 @@ async function scraperRequest(endpoint, body) {
options.method = 'POST'; headers['content-type'] = 'application/json'; options.body = JSON.stringify(body); options.method = 'POST'; headers['content-type'] = 'application/json'; options.body = JSON.stringify(body);
} }
let response; let response;
const t0 = performance.now();
try { response = await fetch(SCRAPER_URL + endpoint, options); } try { response = await fetch(SCRAPER_URL + endpoint, options); }
catch (error) { throw new HttpError(503, `Scraper service unavailable: ${error.message}`); } catch (error) {
tracer.log({ k: 'scraper', endpoint, ms: Math.round(performance.now() - t0), error: error.message });
throw new HttpError(503, `Scraper service unavailable: ${error.message}`);
}
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
tracer.log({ k: 'scraper', endpoint, ms: Math.round(performance.now() - t0), status: response.status });
if (!response.ok) throw new HttpError(response.status >= 500 ? 502 : response.status, data.error || `Scraper HTTP ${response.status}`); if (!response.ok) throw new HttpError(response.status >= 500 ? 502 : response.status, data.error || `Scraper HTTP ${response.status}`);
return data; return data;
} }
@@ -409,14 +463,58 @@ function readAtOf(progress, record) {
if (progress.doneAt) return { at: progress.doneAt, approx: false }; if (progress.doneAt) return { at: progress.doneAt, approx: false };
return earliestCompletion(record); return earliestCompletion(record);
} }
function continueOf(m, prog, s) { // The most recently saved chapter position of a manga: the head that "Continue" follows.
const ok = c => !(s.skipExtras && c.extra); function lastOf(m, prog) {
let last = null; 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 }; } 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 }; } return last;
if (!last.p.done) return { c: last.ch.id, v: last.p.v, text: `Continue · ${last.ch.label} · page ${last.p.p + 1}` }; }
// `at` is the head's time; reader links carry it so a reader opened from a stale page notices a newer head.
function continueOf(m, prog, s) {
const ok = c => !(s.skipExtras && c.extra);
const last = lastOf(m, prog);
if (!last) { const first = m.chapters.find(ok) || m.chapters[0]; return { c: first.id, at: 0, text: 'Start reading · ' + first.label }; }
const at = last.p.t;
if (!last.p.done) return { c: last.ch.id, v: last.p.v, at, text: `Continue · ${last.ch.label} · page ${last.p.p + 1}` };
const next = m.chapters.slice(m.chapters.indexOf(last.ch) + 1).find(ok); 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 }; return next ? { c: next.id, at, text: 'Read next · ' + next.label } : { c: last.ch.id, v: last.p.v, at, text: 'Read again · ' + last.ch.label };
}
// Chapters still ahead of the head: never finished, or last read in an earlier pass. 0 = caught up.
// A done chapter after the head belongs to an earlier pass (the head is a reread) when it was saved
// more than `rereadDays` before the head, or before a run of more than `rereadChapters` chapters read
// in order up to the head. 0 turns either rule off.
function leftOf(m, prog, s) {
const last = lastOf(m, prog);
if (!last) return 0;
const chapters = m.chapters.filter(c => c === last.ch || !(s.skipExtras && c.extra)), at = chapters.indexOf(last.ch);
let runStart = last.p.t, run = 1;
for (let i = at - 1; i >= 0; i--) {
const p = prog[chapters[i].id];
if (!p || !p.done || p.t > runStart) break;
runStart = p.t; run++;
}
const gap = s.rereadDays > 0 ? s.rereadDays * 24 * 3600 * 1000 : Infinity;
const passStart = s.rereadChapters > 0 && run > s.rereadChapters ? runStart : -Infinity;
return chapters.slice(at + 1).filter(c => { const p = prog[c.id]; return !p || !p.done || last.p.t - p.t > gap || p.t < passStart; }).length
+ (last.p.done ? 0 : 1);
}
// What a reader tab compares itself with (stale reading sessions, docs/architecture.md).
function latestOf(m, prog, s) {
const last = lastOf(m, prog);
if (!last) return null;
const { t, w, v, p, n, done } = last.p;
return { t, w: w || '', c: last.ch.id, label: last.ch.label, v, p, n, done, continue: continueOf(m, prog, s) };
}
// From the saved page on, then the pages before it; afterwards the next chapter's preferred version.
function warmChapter(u, m, ch, v, progress, s) {
const from = Math.min(Math.max(0, +(progress && progress.v === v.id && progress.p) || 0), v.pages.length);
const pages = v.pages.slice(from).concat(v.pages.slice(0, from)).map(p => path.join(LIB, v.rel, p.f));
readAhead.warm(pages, `${m.id}/${ch.id}`, u.name);
const next = neighbour(m, ch, 1, s), nextCh = next && m.chapterById.get(next.id);
if (nextCh) {
const nv = ranked(m, nextCh)[0].v;
readAhead.then(nv.pages.map(p => path.join(LIB, nv.rel, p.f)), `${m.id}/${nextCh.id}`, u.name);
}
} }
function neighbour(m, ch, step, s) { function neighbour(m, ch, step, s) {
for (let i = m.chapters.indexOf(ch) + step; i >= 0 && i < m.chapters.length; i += step) { for (let i = m.chapters.indexOf(ch) + step; i >= 0 && i < m.chapters.length; i += step) {
@@ -431,11 +529,11 @@ const PUBLIC = new Set(['/api/auth', '/api/login', '/api/signup']);
const routes = { const routes = {
'GET /api/health': () => ({ ok: !scanState.error, service: 'reader', uptime: Math.round(process.uptime()), scan: scanState }), 'GET /api/health': () => ({ ok: !scanState.error, service: 'reader', uptime: Math.round(process.uptime()), scan: scanState }),
'GET /api/collections': ({ u }) => ({ collections: routes['GET /api/library']({ u }).collections }), 'GET /api/collections': ({ u }) => ({ collections: routes['GET /api/library']({ u }).collections }),
'GET /api/collection': ({ u, q }) => { 'GET /api/collection': ({ u, q, sess }) => {
const view = routes['GET /api/library']({ u }).collections.find(c => c.id === q.get('id')) || bad(404, 'Collection not found'); const view = routes['GET /api/library']({ u }).collections.find(c => c.id === q.get('id')) || bad(404, 'Collection not found');
const next = view.members.find(m => m.read < m.chapters) || view.members[0]; const next = view.members.find(m => m.read < m.chapters) || view.members[0];
const manga = mangaOf(next.id); const manga = mangaOf(next.id);
return { ...view, continue: { m: next.id, ...continueOf(manga, progressOf(u, manga), settingsOf(u)) }, return { ...view, continue: { m: next.id, ...continueOf(manga, progressOf(u, manga), settingsOf(u, sess)) },
relations: catalog.state.relations.filter(r => r.state === 'confirmed' && view.members.some(m => m.id === r.from || m.id === r.to)) }; relations: catalog.state.relations.filter(r => r.state === 'confirmed' && view.members.some(m => m.id === r.from || m.id === r.to)) };
}, },
'GET /api/admin/catalog': ({ u }) => { admin(u); return { ...catalog.state, types: TYPES, roles: ROLES, mangas: lib.mangas.map(m => ({ id: m.id, title: m.title })) }; }, 'GET /api/admin/catalog': ({ u }) => { admin(u); return { ...catalog.state, types: TYPES, roles: ROLES, mangas: lib.mangas.map(m => ({ id: m.id, title: m.title })) }; },
@@ -483,16 +581,16 @@ const routes = {
}, },
'GET /api/auth': () => ({ firstRun: !Object.keys(db.users).length, signup: OPEN_SIGNUP }), 'GET /api/auth': () => ({ firstRun: !Object.keys(db.users).length, signup: OPEN_SIGNUP }),
'POST /api/signup': ({ b, res }) => { 'POST /api/signup': ({ req, b, res }) => {
const first = !Object.keys(db.users).length; const first = !Object.keys(db.users).length;
if (!first && !OPEN_SIGNUP) bad(403, 'Sign-up is closed. Ask an admin for an account.'); if (!first && !OPEN_SIGNUP) bad(403, 'Sign-up is closed. Ask an admin for an account.');
startSession(res, createUser(b.name, b.password, first)); startSession(req, res, createUser(b.name, b.password, first));
return { ok: true }; return { ok: true };
}, },
'POST /api/login': ({ b, res }) => { 'POST /api/login': ({ req, b, res }) => {
const user = db.users[String(b.name || '').trim().toLowerCase()], pw = String(b.password || ''); 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.'); if (!checkPw(pw, user ? user.hash : DUMMY_HASH) || !user) bad(403, 'Wrong user name or password.');
startSession(res, user); startSession(req, res, user);
return { ok: true }; return { ok: true };
}, },
'POST /api/logout': ({ req, res }) => { 'POST /api/logout': ({ req, res }) => {
@@ -501,7 +599,18 @@ const routes = {
res.setHeader('Set-Cookie', 'sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'); res.setHeader('Set-Cookie', 'sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
return { ok: true }; return { ok: true };
}, },
'GET /api/me': ({ u }) => ({ name: u.name, admin: !!u.admin, settings: settingsOf(u), unread: unreadOf(u) }), // Every page loads /api/me, so a page view marks its session as the last active one for reader settings.
'GET /api/me': ({ u, sess, sk }) => {
if (sess && readerSessions.noteActive(u, userSettingsOf(u), sess, sk)) save();
return { name: u.name, admin: !!u.admin, settings: settingsOf(u, sess), unread: unreadOf(u) };
},
'GET /api/reader-sessions': ({ u, sk }) => readerSessions.overview(u, userSettingsOf(u), db.sessions, sk),
'POST /api/reader-sessions': ({ u, b, sk }) => {
if (!['follow', 'latest'].includes(b.action)) bad(400, 'Unknown action');
if (!readerSessions.act(u, userSettingsOf(u), db.sessions, String(b.id || ''), b.action)) bad(404, 'Session not found');
save();
return readerSessions.overview(u, userSettingsOf(u), db.sessions, sk);
},
'GET /api/reading-schedule': () => publicSchedule(readingSchedule), 'GET /api/reading-schedule': () => publicSchedule(readingSchedule),
'GET /api/reading-access': ({ u, q }) => { mangaOf(q.get('m')); return readingAccess.status(u.name, true); }, 'GET /api/reading-access': ({ u, q }) => { mangaOf(q.get('m')); return readingAccess.status(u.name, true); },
@@ -518,22 +627,25 @@ const routes = {
return { ok: true }; return { ok: true };
}, },
'POST /api/settings': ({ u, b }) => { 'POST /api/settings': ({ u, b, sess, sk }) => {
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.'); 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 || {}); const base = userSettingsOf(u), s = {};
if (['last', 'flow', 'page', 'book'].includes(b.startMode)) s.startMode = b.startMode; if (['last', 'flow', 'page', 'book'].includes(b.startMode)) s.startMode = b.startMode;
if (['flow', 'page', 'book'].includes(b.lastMode)) s.lastMode = b.lastMode; if (['flow', 'page', 'book'].includes(b.lastMode)) s.lastMode = b.lastMode;
if (['original', 'inverted', 'sepia', 'dim', 'extra-dim'].includes(b.pageTone)) s.pageTone = b.pageTone; if (['original', 'inverted', 'sepia', 'dim', 'extra-dim'].includes(b.pageTone)) s.pageTone = b.pageTone;
if (['off', 'date', 'hour'].includes(b.readDate)) s.readDate = b.readDate; if (['off', 'date', 'hour'].includes(b.readDate)) s.readDate = b.readDate;
for (const k of ['adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'readingClock', 'readingStory', 'releaseDate', if (Number.isInteger(b.rereadDays) && b.rereadDays >= 0 && b.rereadDays <= 3650) s.rereadDays = b.rereadDays;
'discoveryEnabled', 'discoveryReadingActivity', 'discoveryMature']) if (typeof b[k] === 'boolean') s[k] = b[k]; if (Number.isInteger(b.rereadChapters) && b.rereadChapters >= 0 && b.rereadChapters <= 1000) s.rereadChapters = b.rereadChapters;
for (const k of ['adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'readingClock', 'readingStory', 'releaseDate', 'continueRow',
'discoveryEnabled', 'discoveryReadingActivity', 'discoveryMature', 'readerPerSession']) if (typeof b[k] === 'boolean') s[k] = b[k];
if (b.zoom && typeof b.zoom === 'object') { if (b.zoom && typeof b.zoom === 'object') {
s.zoom = {}; 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])); 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); if (typeof b.webhook === 'string') s.webhook = b.webhook.trim().slice(0, 500);
readerSessions.applyPatch({ user: u, base, session: sess, key: sk, patch: s });
save(); save();
return { settings: settingsOf(u) }; return { settings: settingsOf(u, sess) };
}, },
'POST /api/password': ({ u, b }) => { 'POST /api/password': ({ u, b }) => {
if (!checkPw(String(b.old || ''), u.hash)) bad(403, 'The current password is wrong.'); if (!checkPw(String(b.old || ''), u.hash)) bad(403, 'The current password is wrong.');
@@ -543,7 +655,7 @@ const routes = {
'GET /api/library': ({ u }) => { 'GET /api/library': ({ u }) => {
const prog = db.progress[u.name] || {}, fol = db.follows[u.name] || {}, notes = (db.notes[u.name] || []).filter(n => !n.read); const prog = db.progress[u.name] || {}, fol = db.follows[u.name] || {}, notes = (db.notes[u.name] || []).filter(n => !n.read);
const showStory = settingsOf(u).readingStory; const s = settingsOf(u), showStory = s.readingStory;
const mangas = lib.mangas.map(m => { const mangas = lib.mangas.map(m => {
const p = prog[m.id] || {}, meta = m.meta || {}, release = firstReleaseOf(meta); const p = prog[m.id] || {}, meta = m.meta || {}, release = firstReleaseOf(meta);
return { return {
@@ -553,7 +665,7 @@ const routes = {
tags: Array.isArray(meta.tags) ? meta.tags : (meta.tags ? [String(meta.tags)] : []), status: meta.status || '', year: meta.year || '', tags: Array.isArray(meta.tags) ? meta.tags : (meta.tags ? [String(meta.tags)] : []), status: meta.status || '', year: meta.year || '',
firstRelease: release.label, firstReleaseAt: release.time, firstRelease: release.label, firstReleaseAt: release.time,
read: m.chapters.filter(c => p[c.id] && p[c.id].done).length, 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), lastRead: Object.values(p).reduce((t, x) => Math.max(t, x.t || 0), 0), left: leftOf(m, p, s),
following: !!fol[m.id], unread: notes.filter(n => n.m === m.id).length, following: !!fol[m.id], unread: notes.filter(n => n.m === m.id).length,
reading: showStory ? mangaInsight(mangaReadingOf(u, m), m.chapters.map(ch => ch.id)) : null, reading: showStory ? mangaInsight(mangaReadingOf(u, m), m.chapters.map(ch => ch.id)) : null,
}; };
@@ -620,7 +732,7 @@ const routes = {
const counts = greatestCounts(entry.id, db.greatestRatings || {}, db.users); const counts = greatestCounts(entry.id, db.greatestRatings || {}, db.users);
return { return {
...entry, ...entry,
cover: owned?.cover ? url(owned.cover) : entry.cover, cover: owned?.cover ? url(owned.cover) : greatestCoverUrl(entry),
chapters: owned ? owned.chapters.length : entry.chapters, chapters: owned ? owned.chapters.length : entry.chapters,
status: owned?.meta?.status || entry.status, status: owned?.meta?.status || entry.status,
library: owned ? { id: owned.id, chapters: owned.chapters.length } : null, library: owned ? { id: owned.id, chapters: owned.chapters.length } : null,
@@ -661,8 +773,8 @@ const routes = {
return { results: await searchGreatestMangaDex(entry) }; return { results: await searchGreatestMangaDex(entry) };
}, },
'GET /api/manga': ({ u, q }) => { 'GET /api/manga': ({ u, q, sess }) => {
const m = mangaOf(q.get('m')), prog = progressOf(u, m), s = settingsOf(u), showStory = s.readingStory; const m = mangaOf(q.get('m')), prog = progressOf(u, m), s = settingsOf(u, sess), showStory = s.readingStory;
const records = mangaReadingOf(u, m); const records = mangaReadingOf(u, m);
return { return {
chapterDates: { read: s.readDate, release: s.releaseDate }, chapterDates: { read: s.readDate, release: s.releaseDate },
@@ -679,7 +791,7 @@ const routes = {
dates: s.releaseDate ? chapterDates(ch.versions) : null, dates: s.releaseDate ? chapterDates(ch.versions) : null,
})), })),
reading: showStory ? mangaInsight(records, m.chapters.map(ch => ch.id)) : null, reading: showStory ? mangaInsight(records, m.chapters.map(ch => ch.id)) : null,
continue: continueOf(m, prog, settingsOf(u)), continue: continueOf(m, prog, s),
}; };
}, },
@@ -703,57 +815,80 @@ const routes = {
user: u.name, user: u.name,
reading: libraryInsight(mangas), reading: libraryInsight(mangas),
mangas, mangas,
activity: activityPayload(u),
}; };
}, },
'GET /api/reading/activity': ({ u }) => ({ readingClock: !!settingsOf(u).readingClock, ...activityPayload(u) }),
'POST /api/reading/delete': ({ u, b }) => { 'POST /api/reading/delete': ({ u, b }) => {
if (b.all) { if (b.all) {
if (!checkPw(String(b.password || ''), u.hash)) bad(403, 'The current password is wrong.'); if (!checkPw(String(b.password || ''), u.hash)) bad(403, 'The current password is wrong.');
delete db.reading[u.name]; delete db.reading[u.name];
delete db.activity[u.name];
readingLeases.delete(u.name); readingLeases.delete(u.name);
save(); save();
return { ok: true, deleted: 'all' }; return { ok: true, deleted: 'all' };
} }
const m = mangaOf(b.m), all = db.reading[u.name]; const m = mangaOf(b.m), all = db.reading[u.name];
if (all) delete all[m.id]; if (all) delete all[m.id];
if (db.activity[u.name]) readingActivity.forgetManga(db.activity[u.name], m.id);
save(); save();
return { ok: true, deleted: m.id }; return { ok: true, deleted: m.id };
}, },
'GET /api/chapter': ({ u, q }) => { 'GET /api/chapter': ({ u, q, sess }) => {
const m = mangaOf(q.get('m')), ch = chapterOf(m, q.get('c')), s = settingsOf(u), prog = progressOf(u, m); const m = mangaOf(q.get('m')), ch = chapterOf(m, q.get('c')), s = settingsOf(u, sess), prog = progressOf(u, m);
if (!readingAccess.status(u.name).allowed) bad(423, 'Reading is outside the deployment schedule. Complete the five-question challenge to unlock it.'); if (!readingAccess.status(u.name).allowed) bad(423, 'Reading is outside the deployment schedule. Complete the five-question challenge to unlock it.');
const v = ch.versions.find(x => x.id === q.get('v')) || ranked(m, ch)[0].v; const v = ch.versions.find(x => x.id === q.get('v')) || ranked(m, ch)[0].v;
// Opening a chapter clears its "new chapter" notification. // Opening a chapter clears its "new chapter" notification.
let seen = false; 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; 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(); if (seen) save();
if (readAhead) warmChapter(u, m, ch, v, prog[ch.id], s);
return { return {
perf: tracer.on,
manga: { id: m.id, title: m.title }, manga: { id: m.id, title: m.title },
chapter: { id: ch.id, label: ch.label, title: ch.title, extra: ch.extra }, chapter: { id: ch.id, label: ch.label, title: ch.title, extra: ch.extra },
version: v.id, versions: versionsOf(m, ch, u), direction: v.direction, 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 })), 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), prev: neighbour(m, ch, -1, s), next: neighbour(m, ch, 1, s),
progress: prog[ch.id] || null, progress: prog[ch.id] || null,
latest: latestOf(m, prog, s),
reading: s.readingClock ? publicSummary(readingOf(u, m, ch), v.id, eligiblePages(v, s)) : null, reading: s.readingClock ? publicSummary(readingOf(u, m, ch), v.id, eligiblePages(v, s)) : null,
// the user's moments on this version, so the reader can mark pages that already have one
moments: momentsOf(u).filter(x => x.m === m.id && x.c === ch.id && x.v === v.id)
.map(x => ({ id: x.id, cat: x.cat, pages: (moments.locate(x, m) || { pages: [] }).pages })),
// for the chapter picker: the whole manga across all books // for the chapter picker: the whole manga across all books
books: m.books.map(b => ({ key: b.key, label: b.label, title: b.title })), 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) })), 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) })),
}; };
}, },
'GET /api/progress/latest': ({ u, q }) => {
const m = mangaOf(q.get('m'));
return { latest: latestOf(m, progressOf(u, m), settingsOf(u)) };
},
'POST /api/progress': ({ u, b }) => { 'POST /api/progress': ({ u, b }) => {
const m = mangaOf(b.m), ch = chapterOf(m, b.c); 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] || {}; const all = (db.progress[u.name] = db.progress[u.name] || {}), mp = (all[m.id] = all[m.id] || {}), old = mp[ch.id] || {};
const tab = /^[a-zA-Z0-9_-]{8,40}$/.test(String(b.tab || '')) ? String(b.tab) : '';
// A reader tab sends the head time it knows (`seen`). When another tab or device saved a newer
// position since, this tab is stale: it must not become the head until the reader chooses to stay.
if (Number.isFinite(b.seen)) {
const last = lastOf(m, mp);
if (last && last.p.t > b.seen && (!tab || last.p.w !== tab)) return { ok: false, stale: true, latest: latestOf(m, mp, settingsOf(u)) };
}
const now = Date.now(), done = !!(old.done || b.done); const now = Date.now(), done = !!(old.done || b.done);
// doneAt: when the chapter was first completed; kept until it is marked unread. // doneAt: when the chapter was first completed; kept until it is marked unread.
const doneAt = old.done ? old.doneAt : done ? now : undefined; const doneAt = old.done ? old.doneAt : done ? now : undefined;
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: now, done, ...(doneAt ? { doneAt } : {}), v: String(b.v || ''), p: Math.max(0, b.p | 0), n: Math.max(0, b.n | 0), t: now, done, ...(doneAt ? { doneAt } : {}),
shift: Array.isArray(b.shift) ? b.shift.filter(Number.isInteger).slice(0, 1000) : old.shift || [], shift: Array.isArray(b.shift) ? b.shift.filter(Number.isInteger).slice(0, 1000) : old.shift || [],
...(tab ? { w: tab } : {}),
}; };
save(); save();
return { ok: true }; return { ok: true, t: now };
}, },
// Separate endpoint: an older server on rollback cannot misread cumulative // Separate endpoint: an older server on rollback cannot misread cumulative
// visit totals as legacy deltas. Old open tabs retain their original protocol. // visit totals as legacy deltas. Old open tabs retain their original protocol.
@@ -790,7 +925,15 @@ const routes = {
const byUser = (db.reading[u.name] = db.reading[u.name] || {}); const byUser = (db.reading[u.name] = db.reading[u.name] || {});
const byManga = (byUser[m.id] = byUser[m.id] || {}); const byManga = (byUser[m.id] = byUser[m.id] || {});
const record = (byManga[ch.id] = readingOf(u, m, ch) || emptyRecord()); const record = (byManga[ch.id] = readingOf(u, m, ch) || emptyRecord());
// Seed the activity buckets from remembered visits before this visit changes them.
const activity = activityOf(u, now);
const visitMs = () => record.recentSessions.find(item => item.id === session)?.pass?.activeMs || 0, before = visitMs();
const result = (cumulative ? applyVisit : applyPulse)(record, b, { version: v.id, pageCount: v.pages.length, eligible, now }); const result = (cumulative ? applyVisit : applyPulse)(record, b, { version: v.id, pageCount: v.pages.length, eligible, now });
// The activity grid counts all active reader time, including visits below the 60% threshold.
if (!result.duplicate) {
const added = cumulative ? visitMs() - before : result.activeMs;
if (added > 0) readingActivity.addActive(activity, m.id, added, now);
}
if (b.release && readingLeases.get(u.name)?.session === session) readingLeases.delete(u.name); if (b.release && readingLeases.get(u.name)?.session === session) readingLeases.delete(u.name);
if (!result.duplicate) save(); if (!result.duplicate) save();
return { return {
@@ -822,6 +965,56 @@ const routes = {
save(); save();
return { following: !!f[m.id] }; return { following: !!f[m.id] };
}, },
// Moments (BL-0055): private to their user; one category each, no uncategorised saves.
'GET /api/moments': ({ u, q }) => {
const m = mangaOf(q.get('m'));
return { moments: momentsOf(u).filter(x => x.m === m.id).map(x => moments.view(x, m, url)).sort(moments.storyCompare) };
},
'POST /api/moments': async ({ 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 pages = moments.cleanPages(b.pages, v);
const moment = { id: crypto.randomBytes(6).toString('hex'), m: m.id, c: ch.id, v: v.id, i: pages, f: pages.map(i => v.pages[i].f),
layout: moments.cleanLayout(b.layout),
region: moments.cleanRegion(b.region), cat: moments.cleanCategory(b.cat), note: moments.cleanNote(b.note), t: Date.now() };
const list = momentsOf(u);
if (list.length >= moments.MAX_PER_USER) bad(409, `You already have ${moments.MAX_PER_USER} moments.`);
moment.sha = await Promise.all(moment.f.map(f => fileSha(v.rel + '/' + f)));
list.push(moment);
save();
return { moment: moments.view(moment, m, url) };
},
'POST /api/moments/update': ({ u, b }) => {
const moment = momentOf(u, b.id);
if ('cat' in b) moment.cat = moments.cleanCategory(b.cat);
if ('region' in b) moment.region = moments.cleanRegion(b.region);
if ('note' in b) moment.note = moments.cleanNote(b.note);
moment.edited = Date.now();
save();
return { moment: moments.view(moment, lib.byId.get(moment.m), url) };
},
'POST /api/moments/delete': ({ u, b }) => {
const list = momentsOf(u), moment = momentOf(u, b.id);
list.splice(list.indexOf(moment), 1);
save();
return { ok: true };
},
// Timings the reader page measured (resource timing, decode, page-turn waits) while the trace is on.
'POST /api/perf': ({ u, b }) => {
if (!tracer.on) return { on: false };
const events = Array.isArray(b.events) ? b.events.slice(0, 300) : [];
for (const e of events) if (e && typeof e === 'object' && !Array.isArray(e)) {
const clean = {};
for (const [key, value] of Object.entries(e).slice(0, 30)) {
if (key === 't' || key === 'k') continue; // the server's clock and kind
if (typeof value === 'number' && Number.isFinite(value)) clean[key] = Math.round(value * 10) / 10;
else if (typeof value === 'string' || typeof value === 'boolean') clean[key] = typeof value === 'string' ? value.slice(0, 300) : value;
}
tracer.log({ ...clean, k: 'client', user: u.name, page: b.page ? String(b.page).slice(0, 40) : undefined });
}
return { on: true };
},
'GET /api/notifications': ({ u }) => ({ items: (db.notes[u.name] || []).slice().reverse() }), 'GET /api/notifications': ({ u }) => ({ items: (db.notes[u.name] || []).slice().reverse() }),
'POST /api/notifications/read': ({ u, b }) => { 'POST /api/notifications/read': ({ u, b }) => {
for (const n of db.notes[u.name] || []) if (b.all || n.id === b.id) n.read = true; for (const n of db.notes[u.name] || []) if (b.all || n.id === b.id) n.read = true;
@@ -863,7 +1056,7 @@ const routes = {
admin(u); admin(u);
const name = String(b.name || ''); const name = String(b.name || '');
if (name === u.name) bad(400, "You can't delete your own account."); if (name === u.name) bad(400, "You can't delete your own account.");
delete db.users[name]; delete db.progress[name]; delete db.reading[name]; delete db.follows[name]; delete db.notes[name]; delete db.users[name]; delete db.progress[name]; delete db.reading[name]; delete db.activity[name]; delete db.follows[name]; delete db.notes[name]; delete db.moments[name];
if (db.discoveryFeedback) delete db.discoveryFeedback[name]; if (db.discoveryFeedback) delete db.discoveryFeedback[name];
if (db.greatestRatings) delete db.greatestRatings[name]; if (db.greatestRatings) delete db.greatestRatings[name];
for (const [k, s] of Object.entries(db.sessions)) if (s.u === name) delete db.sessions[k]; for (const [k, s] of Object.entries(db.sessions)) if (s.u === name) delete db.sessions[k];
@@ -1007,23 +1200,32 @@ async function handleApi(req, res, u, route, q) {
if (!/^application\/json/.test(req.headers['content-type'] || '') || req.headers['sec-fetch-site'] === 'cross-site') bad(403, 'Bad request origin'); if (!/^application\/json/.test(req.headers['content-type'] || '') || req.headers['sec-fetch-site'] === 'cross-site') bad(403, 'Bad request origin');
b = (await readBody(req)) || {}; b = (await readBody(req)) || {};
} }
sendJson(res, 200, await route({ req, res, u, b, q })); const sk = u && sessionKeyOf(req), sess = sk && db.sessions[sk];
if (sess) touchSession(u, sess, sk, req);
sendJson(res, 200, await route({ req, res, u, b, q, sess, sk }));
} }
function serveFile(req, res, root, rel, cacheControl, only) { function serveFile(req, res, root, rel, cacheControl, only, trace) {
let file; let file;
try { file = path.resolve(root, decodeURIComponent(rel)); } catch { return sendJson(res, 400, { error: 'Bad path' }); } 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' }); if (!file.startsWith(root + path.sep) || (only && !only.test(file))) return sendJson(res, 404, { error: 'Not found' });
fs.stat(file, (err, st) => { fs.stat(file, (err, st) => {
if (trace) trace.mark('stat');
if (err || !st.isFile()) return sendJson(res, 404, { error: 'Not found' }); if (err || !st.isFile()) return sendJson(res, 404, { error: 'Not found' });
const etag = `"${st.size.toString(36)}-${Math.round(st.mtimeMs).toString(36)}"`; 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(); } 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 }); const headers = { 'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream', 'Content-Length': st.size, ETag: etag, 'Cache-Control': cacheControl };
// The browser's resource timing shows this, so a slow page can be matched to the server's share.
if (trace && trace.on) headers['Server-Timing'] = `stat;dur=${trace.elapsed()}`;
res.writeHead(200, headers);
if (req.method === 'HEAD') return res.end(); if (req.method === 'HEAD') return res.end();
fs.createReadStream(file).on('error', () => res.destroy()).pipe(res); const stream = fs.createReadStream(file).on('error', () => res.destroy());
if (trace) trace.stream(stream);
stream.pipe(res);
}); });
} }
http.createServer(async (req, res) => { http.createServer(async (req, res) => {
const trace = requestTrace(tracer, req, res);
try { try {
const { pathname, searchParams } = new URL(req.url, 'http://localhost'); const { pathname, searchParams } = new URL(req.url, 'http://localhost');
const u = userOf(req); const u = userOf(req);
@@ -1034,6 +1236,16 @@ http.createServer(async (req, res) => {
if (!c?.customCover) bad(404, 'Cover not found'); if (!c?.customCover) bad(404, 'Cover not found');
return serveFile(req, res, path.join(DATA, 'collection-covers'), c.customCover, 'private, no-cache', IMG); return serveFile(req, res, path.join(DATA, 'collection-covers'), c.customCover, 'private, no-cache', IMG);
} }
if (pathname === '/api/greatest-cover' && req.method === 'GET') {
if (!u) bad(401, 'Please log in');
const entry = greatestById.get(searchParams.get('id'));
if (!entry?.cover) bad(404, 'Cover not found');
const file = greatestCovers.file(entry.cover);
if (file) return serveFile(req, res, greatestCovers.dir, path.basename(file), 'private, max-age=31536000, immutable', IMG);
// Not downloaded yet: the browser fetches the catalog's own URL this once.
res.writeHead(302, { Location: entry.cover, 'Cache-Control': 'no-store' });
return res.end();
}
if (pathname === '/api/peering/cover' && req.method === 'GET') { if (pathname === '/api/peering/cover' && req.method === 'GET') {
if (!u) bad(401, 'Please log in'); if (!u) bad(401, 'Please log in');
admin(u); admin(u);
@@ -1061,19 +1273,39 @@ http.createServer(async (req, res) => {
const parts = relative.split(path.sep); const parts = relative.split(path.sep);
if (parts.length >= 4 && !readingAccess.status(u.name).allowed) if (parts.length >= 4 && !readingAccess.status(u.name).allowed)
bad(423, 'Reading is outside the deployment schedule.'); bad(423, 'Reading is outside the deployment schedule.');
return serveFile(req, res, LIB, pathname.slice(5), readingSchedule && parts.length >= 4 ? 'private, no-store' : 'private, max-age=86400', IMG); trace.mark('access');
return serveFile(req, res, LIB, pathname.slice(5), readingSchedule && parts.length >= 4 ? 'private, no-store' : 'private, max-age=86400', IMG, trace);
} }
serveFile(req, res, PUB, pathname === '/' ? 'index.html' : pathname.slice(1), 'no-cache'); serveFile(req, res, PUB, pathname === '/' ? 'index.html' : pathname.slice(1), 'no-cache', null, trace);
} catch (e) { } catch (e) {
const expected = e instanceof HttpError || e instanceof CatalogError; const expected = e instanceof HttpError || e instanceof CatalogError || e instanceof moments.MomentError;
if (!expected) console.error(e); if (!expected) console.error(e);
if (!res.headersSent) sendJson(res, expected ? e.code : 500, expected ? { error: e.message, ...e.extra } : { error: 'Internal error' }); if (!res.headersSent) sendJson(res, expected ? e.code : 500, expected ? { error: e.message, ...e.extra } : { error: 'Internal error' });
} }
}).listen(PORT, () => console.log(`MangaCircus on http://localhost:${PORT} (library: ${LIB}, data: ${DATA})`)); }).listen(PORT, () => console.log(`MangaCircus on http://localhost:${PORT} (library: ${LIB}, data: ${DATA})`));
// The trace's storage probe reads a random page of the library (rarely in the OS cache).
tracer.setProbeSource(() => {
const m = lib.mangas[Math.floor(Math.random() * lib.mangas.length)];
const ch = m && m.chapters[Math.floor(Math.random() * m.chapters.length)], v = ch && ch.versions[0];
const p = v && v.pages[Math.floor(Math.random() * v.pages.length)];
return p ? path.join(LIB, v.rel, p.f) : null;
});
if (lib.mangas.length) refreshChanged(); if (lib.mangas.length) refreshChanged();
else rescan(); else rescan();
if (SCAN_MINUTES > 0) setInterval(() => refreshChanged(), SCAN_MINUTES * 60000).unref(); if (SCAN_MINUTES > 0) setInterval(() => refreshChanged(), SCAN_MINUTES * 60000).unref();
// Fetch missing Greatest covers shortly after start and retry failures once a day;
// covers already on disk cost nothing.
async function warmGreatestCovers() {
try {
const result = await greatestCovers.warm(greatestCatalog.entries.map(entry => entry.cover));
if (result.fetched || result.failed || result.stopped) console.log('[greatest] covers', JSON.stringify(result));
} catch (error) { console.error('[greatest] covers', error.message); }
}
if (process.env.GREATEST_COVER_WARM !== '0') {
setTimeout(warmGreatestCovers, 10000).unref();
setInterval(warmGreatestCovers, 86400000).unref();
}
// Persist the last reconciliation time. Restarting a healthy reader should not // Persist the last reconciliation time. Restarting a healthy reader should not
// trigger another full tree walk; overdue reconciliations run within a minute. // trigger another full tree walk; overdue reconciliations run within a minute.
if (FULL_SCAN_MINUTES > 0) setInterval(() => { if (FULL_SCAN_MINUTES > 0) setInterval(() => {
@@ -1086,10 +1318,13 @@ if (FULL_SCAN_MINUTES > 0) setInterval(() => {
// A completed scraper publish increments a durable revision. Polling it here keeps the // A completed scraper publish increments a durable revision. Polling it here keeps the
// reader index fresh without exposing an unauthenticated rescan endpoint to the daemon. // reader index fresh without exposing an unauthenticated rescan endpoint to the daemon.
let scraperRevision = null; let scraperRevision = null, revisionRoute = true;
async function pollScraper() { async function pollScraper() {
try { try {
const state = await scraperRequest('/api/state'); // /api/revision is a few bytes; the full state is ~100 KB and makes the scraper summarise the library.
let state;
if (revisionRoute) state = await scraperRequest('/api/revision').catch(error => { if (error.code !== 404) throw error; revisionRoute = false; });
if (!revisionRoute) state = await scraperRequest('/api/state'); // a scraper from before /api/revision
const revision = Number(state.publishedRevision || 0); const revision = Number(state.publishedRevision || 0);
if (scraperRevision === null || revision > scraperRevision) { if (scraperRevision === null || revision > scraperRevision) {
const checked = await refreshChanged(); const checked = await refreshChanged();
+2 -1
View File
@@ -21,7 +21,8 @@ test('only admins can promote or demote, nobody can demote themselves, and chang
const port = await freePort(), output = []; const port = await freePort(), output = [];
child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true, child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true,
env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0', env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0',
SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '' } }); SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '',
GREATEST_COVER_WARM: '0' } });
child.stdout.on('data', d => output.push(String(d))); child.stdout.on('data', d => output.push(String(d)));
child.stderr.on('data', d => output.push(String(d))); child.stderr.on('data', d => output.push(String(d)));
const base = `http://127.0.0.1:${port}`; const base = `http://127.0.0.1:${port}`;
+1 -1
View File
@@ -251,7 +251,7 @@ test('reader and scraper: admin backup API pauses and resumes the real queue', {
const base = `http://127.0.0.1:${rp}`, token = 'backup-test-only'; const base = `http://127.0.0.1:${rp}`, token = 'backup-test-only';
const env = { ...process.env, DATA: f.data, LIBRARY: library, SCRAPER_DATA: path.join(f.data, 'scraper'), BACKUP: f.target, const env = { ...process.env, DATA: f.data, LIBRARY: library, SCRAPER_DATA: path.join(f.data, 'scraper'), BACKUP: f.target,
PORT: String(rp), SCRAPER_PORT: String(sp), SCRAPER_HOST: '127.0.0.1', SCRAPER_URL: `http://127.0.0.1:${sp}`, SENTINEL_URL: 'http://127.0.0.1:9', PORT: String(rp), SCRAPER_PORT: String(sp), SCRAPER_HOST: '127.0.0.1', SCRAPER_URL: `http://127.0.0.1:${sp}`, SENTINEL_URL: 'http://127.0.0.1:9',
SCRAPER_TOKEN: token, SCRAPER_TOKEN_FILE: '', SCAN_INTERVAL: '0' }; SCRAPER_TOKEN: token, SCRAPER_TOKEN_FILE: '', SCAN_INTERVAL: '0', GREATEST_COVER_WARM: '0' };
for (const script of ['server.js', 'scraper/daemon.js']) { for (const script of ['server.js', 'scraper/daemon.js']) {
const child = spawn(process.execPath, [script], { cwd: root, env, windowsHide: true }); children.push(child); const child = spawn(process.execPath, [script], { cwd: root, env, windowsHide: true }); children.push(child);
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => output.push(chunk.toString())); for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => output.push(chunk.toString()));
+110
View File
@@ -0,0 +1,110 @@
'use strict';
// "Continue where you left off" only lists manga with something left after the last chapter read:
// unread chapters, or chapters last read in an earlier pass (a reread).
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net');
const { spawn } = require('child_process');
const { Canvas } = require('../tools/png');
async function freePort() {
const listener = net.createServer();
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve));
const port = listener.address().port;
await new Promise(resolve => listener.close(resolve));
return port;
}
async function start(library, data) {
const port = await freePort(), output = [];
const child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true,
env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0',
SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '', GREATEST_COVER_WARM: '0' } });
child.stdout.on('data', d => output.push(String(d)));
child.stderr.on('data', d => output.push(String(d)));
const base = `http://127.0.0.1:${port}`;
let ready = false;
for (let n = 0; n < 100 && !ready; n++) {
ready = await fetch(base + '/health').then(r => r.ok).catch(() => false);
if (!ready) await new Promise(resolve => setTimeout(resolve, 50));
}
assert.ok(ready, output.join(''));
return { child, base };
}
async function stop(child) {
if (child && child.exitCode === null) { child.kill(); await new Promise(resolve => child.once('exit', resolve)); }
}
test('the continue row counts chapters left after the head, rereads included', { timeout: 30000 }, async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-left-'));
let server;
try {
const library = path.join(root, 'library'), data = path.join(root, 'data');
for (const [title, count] of [['Moon Garden', 4], ['Sun Field', 4], ['Star Path', 8]])
for (let n = 1; n <= count; n++) {
const dir = path.join(library, title, 'v01', `c00${n}`);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, '001.png'), new Canvas(60, 90, [40, 60, 120]).png());
}
fs.mkdirSync(data);
server = await start(library, data);
let { base } = server;
const post = (cookie, url, body) => fetch(base + url, { method: 'POST',
headers: { 'content-type': 'application/json', cookie }, body: JSON.stringify(body) }).then(r => r.json());
const get = (cookie, url) => fetch(base + url, { headers: { cookie } }).then(r => r.json());
const signup = await fetch(base + '/api/signup', { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'alice', password: 'alice-password' }) });
const alice = signup.headers.get('set-cookie').split(';')[0];
await post(alice, '/api/admin/rescan', { full: true });
const ids = async m => (await get(alice, '/api/manga?m=' + encodeURIComponent(m))).chapters.map(c => ({ c: c.id, v: c.versions[0].id }));
const moon = await ids('Moon Garden'), sun = await ids('Sun Field');
const done = (m, ch) => post(alice, '/api/progress', { m, ...ch, p: 0, n: 1, done: true });
const left = async () => Object.fromEntries((await get(alice, '/api/library')).mangas.map(m => [m.id, m.left]));
// First read stopped after chapter 2: two chapters left. Finished: nothing left.
for (const ch of moon.slice(0, 2)) await done('Moon Garden', ch);
for (const ch of sun) await done('Sun Field', ch);
assert.deepEqual(await left(), { 'Moon Garden': 2, 'Sun Field': 0, 'Star Path': 0 });
// Read all 8 chapters of Star Path, then reread 1-6 right away: more than 5 in a row is a reread
// even within the time limit; with a longer run setting, or the run rule off, it is not.
const star = await ids('Star Path');
for (const ch of star) await done('Star Path', ch);
await new Promise(resolve => setTimeout(resolve, 5));
for (const ch of star.slice(0, 5)) await done('Star Path', ch);
assert.equal((await left())['Star Path'], 0, 'five in a row is not yet a reread');
await done('Star Path', star[5]);
assert.equal((await left())['Star Path'], 2);
await post(alice, '/api/settings', { rereadChapters: 6 });
assert.equal((await left())['Star Path'], 0);
await post(alice, '/api/settings', { rereadChapters: 0 });
assert.equal((await left())['Star Path'], 0);
await post(alice, '/api/settings', { rereadChapters: 5, rereadDays: -1 });
const settings = (await get(alice, '/api/me')).settings;
assert.equal(settings.rereadDays, 7, 'invalid values are ignored');
assert.equal((await left())['Star Path'], 2);
// Unfinished head: the rest of that chapter is still left.
await post(alice, '/api/progress', { m: 'Moon Garden', ...moon[2], p: 0, n: 1 });
assert.equal((await left())['Moon Garden'], 2);
// Glancing back at chapter 1 right after finishing is not a reread.
await done('Sun Field', sun[0]);
assert.equal((await left())['Sun Field'], 0);
// The finish was a month ago: rereading chapters 1-2 now leaves 3-4 from the earlier pass.
await new Promise(resolve => setTimeout(resolve, 700));
await stop(server.child);
const dbFile = path.join(data, 'db.json'), db = JSON.parse(fs.readFileSync(dbFile, 'utf8'));
const month = 30 * 24 * 3600 * 1000, sunProgress = db.progress.alice['Sun Field'];
for (const p of Object.values(sunProgress)) { p.t -= month; p.doneAt -= month; }
sunProgress[sun[0].c].t += month;
fs.writeFileSync(dbFile, JSON.stringify(db));
server = await start(library, data);
base = server.base;
await done('Sun Field', sun[1]);
assert.equal((await left())['Sun Field'], 2);
} finally {
await stop(server && server.child);
fs.rmSync(root, { recursive: true, force: true });
}
});
+1 -1
View File
@@ -81,7 +81,7 @@ async function startReader({ anilist, discovery = {}, mangaMeta = null, scraperU
const child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true, const child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true,
env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0', env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0',
SCRAPER_URL: scraperUrl, SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '', SCRAPER_URL: scraperUrl, SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '',
DISCOVERY_ANILIST_URL: anilist ? anilist.url : 'http://127.0.0.1:9/', DISCOVERY_HARVEST_AT: '' } }); DISCOVERY_ANILIST_URL: anilist ? anilist.url : 'http://127.0.0.1:9/', DISCOVERY_HARVEST_AT: '', GREATEST_COVER_WARM: '0' } });
child.stdout.on('data', d => output.push(String(d))); child.stdout.on('data', d => output.push(String(d)));
child.stderr.on('data', d => output.push(String(d))); child.stderr.on('data', d => output.push(String(d)));
const base = `http://127.0.0.1:${port}`; const base = `http://127.0.0.1:${port}`;
+33
View File
@@ -0,0 +1,33 @@
'use strict';
// The image copies top-level modules by name; a new module missing there crashes the reader at start.
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('fs'), path = require('path');
const root = path.resolve(__dirname, '..');
const allowedNames = () => new Set(fs.readFileSync(path.join(root, '.dockerignore'), 'utf8').split(/\r?\n/).filter(line => line.startsWith('!')).map(line => line.slice(1)));
test('every top-level module the reader requires is copied into the image', () => {
const dockerfile = fs.readFileSync(path.join(root, 'Dockerfile'), 'utf8');
const copied = new Set(dockerfile.split('\n').filter(line => line.startsWith('COPY ')).flatMap(line => line.split(/\s+/).slice(1, -1)));
const pending = ['server.js'], seen = new Set();
while (pending.length) {
const file = pending.pop();
if (seen.has(file)) continue;
seen.add(file);
const source = fs.readFileSync(path.join(root, file), 'utf8');
for (const [, dir] of source.matchAll(/require\('\.\/([\w.-]+)\/[^']+'\)/g)) {
assert.ok(copied.has(dir), `${dir}/ is not copied`);
assert.ok(allowedNames().has(dir + '/'), `${dir}/ is not allowed through .dockerignore`);
}
for (const [, name] of source.matchAll(/require\('\.\/([\w.-]+?)(?:\.js)?'\)/g)) {
// A folder (e.g. ./greatest) is copied as a whole; only single files need their own name in COPY.
if (fs.existsSync(path.join(root, name)) && fs.statSync(path.join(root, name)).isDirectory()) assert.ok(copied.has(name), `${name}/ is not copied`);
else pending.push(name + '.js');
}
}
// .dockerignore is an allowlist too: a file not let through never reaches COPY.
const allowed = allowedNames();
for (const file of seen) {
assert.ok(copied.has(file), `${file} is required but not copied by the Dockerfile`);
assert.ok(allowed.has(file), `${file} is required but not allowed through .dockerignore`);
}
});
+75
View File
@@ -0,0 +1,75 @@
'use strict';
// BL-0053: Greatest covers are served from the reader's own copy, with a redirect until one exists.
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net'), crypto = require('crypto');
const { spawn } = require('child_process');
const { loadCatalog } = require('../greatest');
const { GreatestCovers } = require('../greatest/covers');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const hashPw = pw => { const salt = crypto.randomBytes(16).toString('hex'); return salt + ':' + crypto.scryptSync(pw, salt, 32).toString('hex'); };
const sha = s => crypto.createHash('sha256').update(s).digest('hex');
const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
async function freePort() {
const listener = net.createServer();
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve));
const port = listener.address().port;
await new Promise(resolve => listener.close(resolve));
return port;
}
test('Greatest covers come from the local copy, else redirect to the catalog URL', async t => {
const entries = loadCatalog().entries.filter(entry => entry.cover);
const [cached, missing] = entries;
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-greatest-cover-'));
const library = path.join(root, 'library'), data = path.join(root, 'data');
fs.mkdirSync(library, { recursive: true });
fs.mkdirSync(path.join(data, 'greatest-covers'), { recursive: true });
const version = new GreatestCovers(path.join(data, 'greatest-covers')).version(cached.cover);
fs.writeFileSync(path.join(data, 'greatest-covers', `${version}.png`), PNG);
const token = crypto.randomBytes(32).toString('base64url');
fs.writeFileSync(path.join(data, 'db.json'), JSON.stringify({
users: { reader: { name: 'reader', hash: hashPw('reader-password'), admin: false, created: Date.now(), settings: {} } },
sessions: { [sha(token)]: { u: 'reader', t: Date.now() } } }));
const port = await freePort(), output = [];
const child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true,
env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0',
SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '',
GREATEST_COVER_WARM: '0' } });
child.stdout.on('data', d => output.push(String(d)));
child.stderr.on('data', d => output.push(String(d)));
t.after(async () => {
await new Promise(resolve => { if (child.exitCode !== null) return resolve(); child.once('exit', resolve); child.kill(); });
fs.rmSync(root, { recursive: true, force: true });
});
const base = `http://127.0.0.1:${port}`;
let ready = false;
for (let n = 0; n < 100 && !ready; n++) {
ready = await fetch(base + '/health').then(r => r.ok).catch(() => false);
if (!ready) await sleep(50);
}
assert.ok(ready, output.join(''));
const get = (url, cookie = `sid=${token}`) => fetch(base + '/' + url, { headers: cookie ? { cookie } : {}, redirect: 'manual' });
const listing = await (await get('api/greatest')).json();
const coverOf = id => listing.entries.find(entry => entry.id === id).cover;
assert.equal(coverOf(cached.id), `api/greatest-cover?id=${encodeURIComponent(cached.id)}&v=${version}`);
assert.match(coverOf(missing.id), /^api\/greatest-cover\?id=.+&v=[0-9a-f]{16}$/);
const hit = await get(coverOf(cached.id));
assert.equal(hit.status, 200);
assert.equal(hit.headers.get('content-type'), 'image/png');
assert.equal(hit.headers.get('cache-control'), 'private, max-age=31536000, immutable');
assert.deepEqual(Buffer.from(await hit.arrayBuffer()), PNG);
const miss = await get(coverOf(missing.id));
assert.equal(miss.status, 302);
assert.equal(miss.headers.get('location'), missing.cover);
assert.equal(miss.headers.get('cache-control'), 'no-store');
assert.equal((await get(coverOf(cached.id), null)).status, 401);
assert.equal((await get('api/greatest-cover?id=not-in-the-catalog')).status, 404);
assert.equal((await get('api/greatest-cover?id=../../db.json')).status, 404);
});
+84
View File
@@ -0,0 +1,84 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { GreatestCovers } = require('../greatest/covers');
const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
const image = (body = PNG, type = 'image/png') => new Response(body, { status: 200, headers: { 'content-type': type } });
function setup(t, responses, options = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-greatest-covers-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const calls = [];
const fetchImpl = async (url, init) => {
calls.push({ url, init });
const next = responses[url];
if (next instanceof Error) throw next;
return typeof next === 'function' ? next() : next || new Response('missing', { status: 404 });
};
const sleeps = [];
const covers = new GreatestCovers(dir, { fetchImpl, sleep: async ms => { sleeps.push(ms); }, ...options });
return { dir, calls, sleeps, covers };
}
test('warm downloads each cover once and serves it from disk afterwards', async t => {
const a = 'https://img.example/a.png', b = 'https://img.example/b.jpg';
const { dir, calls, sleeps, covers } = setup(t, { [a]: () => image(), [b]: () => image(PNG, 'image/jpeg') });
const result = await covers.warm([a, b, a]);
assert.deepEqual(result, { fetched: 2, skipped: 1, failed: 0, stopped: null });
assert.equal(calls.length, 2);
assert.deepEqual(sleeps, [1000], 'one pause between two downloads, none before the first');
assert.ok(covers.file(a).endsWith('.png') && fs.existsSync(covers.file(a)));
assert.ok(covers.file(b).endsWith('.jpg'));
assert.equal(covers.file('https://img.example/other.png'), null);
// A restarted reader finds the stored files and asks nobody.
const reopened = new GreatestCovers(dir, { fetchImpl: async () => { throw new Error('must not fetch'); } });
assert.deepEqual(await reopened.warm([a, b]), { fetched: 0, skipped: 2, failed: 0, stopped: null });
assert.equal(reopened.file(a), covers.file(a));
});
test('a changed cover URL is a new file', async t => {
const { covers } = setup(t, { 'https://img.example/v1.png': () => image(), 'https://img.example/v2.png': () => image() });
await covers.warm(['https://img.example/v1.png', 'https://img.example/v2.png']);
assert.notEqual(covers.file('https://img.example/v1.png'), covers.file('https://img.example/v2.png'));
assert.notEqual(covers.version('https://img.example/v1.png'), covers.version('https://img.example/v2.png'));
});
test('non-images, oversized files, plain http and errors are skipped', async t => {
const html = 'https://img.example/page', big = 'https://img.example/big.png', declaredBig = 'https://img.example/declared.png';
const broken = 'https://img.example/broken.png', gone = 'https://img.example/gone.png';
const { covers, calls } = setup(t, {
[html]: () => new Response('<html>', { status: 200, headers: { 'content-type': 'text/html' } }),
[big]: () => image(Buffer.alloc(64)),
[declaredBig]: () => new Response(PNG, { status: 200, headers: { 'content-type': 'image/png', 'content-length': '999999' } }),
[broken]: new TypeError('fetch failed'),
}, { maxBytes: 32 });
const result = await covers.warm([html, big, declaredBig, broken, gone, 'http://img.example/plain.png', '', null]);
assert.deepEqual(result, { fetched: 0, skipped: 0, failed: 5, stopped: null });
assert.equal(calls.length, 5, 'plain http and empty URLs are never requested');
for (const url of [html, big, declaredBig, broken, gone]) assert.equal(covers.file(url), null);
assert.deepEqual(fs.readdirSync(covers.dir).filter(name => !name.startsWith('.')), []);
});
test('429 and 403 stop the run at once', async t => {
for (const status of [429, 403]) {
const first = 'https://img.example/1.png', second = 'https://img.example/2.png';
const { covers, calls } = setup(t, { [first]: () => new Response('', { status }), [second]: () => image() });
const result = await covers.warm([first, second]);
assert.equal(result.stopped, `HTTP ${status}`);
assert.equal(calls.length, 1);
assert.equal(covers.file(second), null);
}
});
test('requests carry a timeout and a fixed user agent', async t => {
const a = 'https://img.example/a.png';
const { covers, calls } = setup(t, { [a]: () => image() });
await covers.warm([a]);
assert.ok(calls[0].init.signal instanceof AbortSignal);
assert.match(calls[0].init.headers['user-agent'], /^MangaCircus/);
});
+1 -1
View File
@@ -78,7 +78,7 @@ test('reader serves a saved index immediately after restart and verifies it in b
const output = []; const output = [];
child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true, child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true,
env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0', env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0',
SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '' } }); SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', GREATEST_COVER_WARM: '0' } });
child.stdout.on('data', data => output.push(String(data))); child.stdout.on('data', data => output.push(String(data)));
child.stderr.on('data', data => output.push(String(data))); child.stderr.on('data', data => output.push(String(data)));
const base = `http://127.0.0.1:${port}`; const base = `http://127.0.0.1:${port}`;
+150
View File
@@ -0,0 +1,150 @@
'use strict';
// BL-0055: Moments, a reader's saved pages or page regions with exactly one category.
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net');
const { spawn } = require('child_process');
const { Canvas } = require('../tools/png');
const moments = require('../moments');
const { trimPages, momentPageLabel } = require('../public/moments');
const { READER_KEYS, MOMENT_VIEWER_KEYS, keyAction } = require('../public/keys');
test('regions are clamped fractions; a whole-page rectangle means no region', () => {
assert.deepEqual(moments.cleanRegion({ x: 0.1, y: 0.2, w: 0.3, h: 0.4 }), { x: 0.1, y: 0.2, w: 0.3, h: 0.4 });
assert.deepEqual(moments.cleanRegion({ x: -1, y: 0.5, w: 3, h: 2 }), { x: 0, y: 0.5, w: 1, h: 0.5 });
assert.equal(moments.cleanRegion({ x: 0, y: 0, w: 1, h: 1 }), null);
assert.equal(moments.cleanRegion(null), null);
assert.throws(() => moments.cleanRegion({ x: 0.5, y: 0.5, w: 0.001, h: 0.5 }), /too small/);
assert.throws(() => moments.cleanRegion({ x: 'a', y: 0, w: 1, h: 1 }), /Invalid/);
});
test('exactly one known category is required; notes are trimmed and capped', () => {
for (const cat of ['twist', 'hot', 'awesome', 'cute', 'funny', 'feels']) assert.equal(moments.cleanCategory(cat), cat);
assert.throws(() => moments.cleanCategory(undefined), /Pick one category/);
assert.throws(() => moments.cleanCategory('meh'), /Pick one category/);
assert.equal(moments.cleanNote(' so good \n'), 'so good');
assert.equal(moments.cleanNote('x'.repeat(500)).length, moments.NOTE_MAX);
});
const near = (a, b) => Math.abs(a - b) < 1e-9;
test('side by side: a region inside one page of a spread becomes that page alone', () => {
const left = { i: 5, w: 700, h: 1000 }, right = { i: 4, w: 700, h: 1000 };
const one = trimPages([left, right], { x: 0.6, y: 0.1, w: 0.2, h: 0.3 });
assert.deepEqual(one.pages, [right]);
assert.ok(near(one.region.x, 0.2) && near(one.region.w, 0.4) && one.region.y === 0.1);
assert.equal(trimPages([left, right], { x: 0.3, y: 0.1, w: 0.4, h: 0.3 }).pages.length, 2);
});
test('stacked (Flow): a picture across the break keeps only the pages it touches', () => {
const pages = [0, 1, 2, 3].map(i => ({ i, w: 800, h: 400 })); // each page is half as tall as wide: 0.5 of the width
// The stack is 2 units tall; y 0.4–0.6 is the lower half of page 1 and the upper half of page 2.
const cut = trimPages(pages, { x: 0.1, y: 0.4, w: 0.8, h: 0.2 }, 'column');
assert.deepEqual(cut.pages.map(p => p.i), [1, 2]);
assert.ok(near(cut.region.y, 0.3) && near(cut.region.h, 0.4) && cut.region.x === 0.1);
assert.deepEqual(trimPages(pages, null, 'column').pages.length, 4);
assert.equal(momentPageLabel(cut.pages), 'p. 2–3');
});
test('every reader key is bound once and the M / I split holds', () => {
const keys = READER_KEYS.flatMap(entry => entry.keys);
assert.equal(new Set(keys).size, keys.length);
assert.equal(keyAction(READER_KEYS, 'm'), 'moment');
assert.equal(keyAction(READER_KEYS, 'i'), 'bars');
assert.equal(keyAction(READER_KEYS, 'Escape'), 'bars');
assert.equal(keyAction(READER_KEYS, '?'), 'help');
assert.equal(keyAction(MOMENT_VIEWER_KEYS, 'ArrowLeft'), 'step');
assert.ok(READER_KEYS.every(entry => entry.label && entry.text));
});
async function freePort() {
const listener = net.createServer();
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve));
const port = listener.address().port;
await new Promise(resolve => listener.close(resolve));
return port;
}
test('moments are private, validated, editable and survive page renumbering as "moved"', { timeout: 30000 }, async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-moments-'));
let child;
try {
const library = path.join(root, 'library'), data = path.join(root, 'data');
const chapterDir = path.join(library, 'Moon Garden', 'v01', 'c001 Roots');
fs.mkdirSync(chapterDir, { recursive: true }); fs.mkdirSync(data);
for (const n of [1, 2, 3]) fs.writeFileSync(path.join(chapterDir, `00${n}.png`), new Canvas(60, 90, [40 * n, 60, 120]).png());
const port = await freePort(), output = [];
child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true,
env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0',
SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '', GREATEST_COVER_WARM: '0' } });
child.stdout.on('data', d => output.push(String(d)));
child.stderr.on('data', d => output.push(String(d)));
const base = `http://127.0.0.1:${port}`;
let ready = false;
for (let n = 0; n < 100 && !ready; n++) {
ready = await fetch(base + '/health').then(r => r.ok).catch(() => false);
if (!ready) await new Promise(resolve => setTimeout(resolve, 50));
}
assert.ok(ready, output.join(''));
const post = (cookie, url, body) => fetch(base + url, { method: 'POST',
headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: JSON.stringify(body) });
const get = (cookie, url) => fetch(base + url, { headers: { cookie } }).then(r => r.json());
const session = r => r.headers.get('set-cookie').split(';')[0];
const alice = session(await post(null, '/api/signup', { name: 'alice', password: 'alice-password' }));
await post(alice, '/api/admin/user', { name: 'bob', password: 'bob-password' });
const bob = session(await post(null, '/api/login', { name: 'bob', password: 'bob-password' }));
await post(alice, '/api/admin/rescan', { full: true });
const manga = await get(alice, '/api/manga?m=' + encodeURIComponent('Moon Garden'));
const c = manga.chapters[0].id, v = manga.chapters[0].versions[0].id;
const base3 = { m: 'Moon Garden', c, v };
// A category is required: nothing is ever dumped into an uncategorised pile.
assert.equal((await post(alice, '/api/moments', { ...base3, pages: [1] })).status, 400);
assert.equal((await post(alice, '/api/moments', { ...base3, pages: [9], cat: 'hot' })).status, 400);
const created = await (await post(alice, '/api/moments', { ...base3, pages: [1], cat: 'awesome', region: { x: 0.1, y: 0.5, w: 0.5, h: 0.25 }, note: ' big fight ' })).json();
assert.equal(created.moment.cat, 'awesome');
assert.equal(created.moment.note, 'big fight');
assert.equal(created.moment.pages[0].i, 1);
assert.match(created.moment.pages[0].u, /^lib\/Moon%20Garden\/v01\/c001%20Roots\/002\.png$/);
const spread = (await (await post(alice, '/api/moments', { ...base3, pages: [2, 0], cat: 'twist' })).json()).moment;
assert.deepEqual(spread.pages.map(p => p.i), [2, 0]);
assert.equal(spread.layout, 'row');
// Flow: a picture stacked over three pages; more than four pages or an unknown layout is refused.
const stacked = (await (await post(alice, '/api/moments', { ...base3, pages: [0, 1, 2], layout: 'column', cat: 'awesome', region: { x: 0, y: 0.2, w: 1, h: 0.5 } })).json()).moment;
assert.equal(stacked.layout, 'column');
assert.equal((await post(alice, '/api/moments', { ...base3, pages: [0, 1, 2, 0], cat: 'hot' })).status, 400);
assert.equal((await post(alice, '/api/moments', { ...base3, pages: [0], layout: 'grid', cat: 'hot' })).status, 400);
assert.equal((await post(alice, '/api/moments/delete', { id: stacked.id })).status, 200);
// Story order: page 0/2 spread starts before page 1.
const listed = (await get(alice, '/api/moments?m=Moon%20Garden')).moments;
assert.deepEqual(listed.map(x => x.id), [spread.id, created.moment.id]);
const chapter = await get(alice, `/api/chapter?m=Moon%20Garden&c=${encodeURIComponent(c)}`);
assert.deepEqual(chapter.moments.map(x => x.pages).sort(), [[1], [2, 0]].sort());
// Private: bob sees nothing and cannot touch alice's moments.
assert.deepEqual((await get(bob, '/api/moments?m=Moon%20Garden')).moments, []);
assert.equal((await get(bob, `/api/chapter?m=Moon%20Garden&c=${encodeURIComponent(c)}`)).moments.length, 0);
assert.equal((await post(bob, '/api/moments/update', { id: created.moment.id, cat: 'hot' })).status, 404);
assert.equal((await post(bob, '/api/moments/delete', { id: created.moment.id })).status, 404);
const edited = (await (await post(alice, '/api/moments/update', { id: created.moment.id, cat: 'feels', region: null })).json()).moment;
assert.equal(edited.cat, 'feels'); assert.equal(edited.region, null);
assert.equal((await post(alice, '/api/moments/update', { id: created.moment.id, cat: '' })).status, 400);
// The file of page 2 goes away: the moment shows as moved instead of pointing at another picture.
fs.renameSync(path.join(chapterDir, '002.png'), path.join(chapterDir, '009.png'));
await post(alice, '/api/admin/rescan', { full: true });
const after = (await get(alice, '/api/moments?m=Moon%20Garden')).moments;
assert.equal(after.find(x => x.id === created.moment.id).missing, true);
assert.equal(after.find(x => x.id === spread.id).missing, false);
assert.equal((await post(alice, '/api/moments/delete', { id: spread.id })).status, 200);
assert.equal((await get(alice, '/api/moments?m=Moon%20Garden')).moments.length, 1);
} finally {
// The server writes db.json on SIGTERM; removing the folder before it exits races that write.
if (child && child.exitCode === null) await new Promise(resolve => { child.once('exit', resolve); child.kill(); });
fs.rmSync(root, { recursive: true, force: true });
}
});
+117
View File
@@ -0,0 +1,117 @@
'use strict';
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('fs'), os = require('os'), path = require('path'), http = require('http');
const { Tracer, requestTrace } = require('../perf/trace');
const { ReadAhead } = require('../perf/read-ahead');
const { analyse } = require('../tools/perf-report');
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'circus-perf-'));
const lines = dir => fs.readdirSync(dir).filter(n => n.endsWith('.jsonl'))
.flatMap(n => fs.readFileSync(path.join(dir, n), 'utf8').trim().split('\n')).filter(Boolean).map(JSON.parse);
test('tracer is off without the flag file and writes nothing', () => {
const dir = tmp(), tracer = new Tracer({ dir, env: {} });
tracer.check(); tracer.log({ k: 'x' }); tracer.flush();
assert.equal(tracer.on, false);
assert.deepEqual(fs.readdirSync(dir), []);
});
test('the flag file switches the trace on and off at runtime', () => {
const dir = tmp(), tracer = new Tracer({ dir, env: {} });
fs.writeFileSync(path.join(dir, 'trace.on'), '');
tracer.check(); tracer.log({ k: 'x', a: 1 });
fs.rmSync(path.join(dir, 'trace.on'));
tracer.check(); tracer.log({ k: 'x', a: 2 }); tracer.flush();
const events = lines(dir);
assert.deepEqual(events.map(e => e.k), ['trace', 'x', 'trace']);
assert.equal(events[1].a, 1);
assert.equal(events[2].on, false);
assert.ok(events.every(e => typeof e.t === 'number'));
});
test('a served file is traced with storage and network phases', async () => {
const dir = tmp(), tracer = new Tracer({ dir, env: { PERF_TRACE: '1' } });
tracer.check();
const file = path.join(dir, 'page.jpg');
fs.writeFileSync(file, Buffer.alloc(3 * 1024 * 1024, 7));
const server = http.createServer((req, res) => {
const trace = requestTrace(tracer, req, res);
fs.stat(file, (err, st) => {
trace.mark('stat');
res.writeHead(200, { 'Content-Length': st.size });
const stream = fs.createReadStream(file);
trace.stream(stream);
stream.pipe(res);
});
});
await new Promise(r => server.listen(0, r));
const { port } = server.address();
const r = await fetch(`http://127.0.0.1:${port}/lib/M/B/C/page.jpg`);
assert.equal((await r.arrayBuffer()).byteLength, 3 * 1024 * 1024);
await new Promise(r => setTimeout(r, 50));
server.close(); tracer.stopSampler(); tracer.flush();
const req = lines(dir).find(e => e.k === 'req');
assert.equal(req.status, 200);
assert.equal(req.bytes, 3 * 1024 * 1024);
assert.equal(req.path, '/lib/M/B/C/page.jpg');
for (const field of ['stat', 'open', 'first', 'readEnd', 'disk', 'netWait', 'ms']) assert.equal(typeof req[field], 'number', field);
assert.ok(req.ms >= req.readEnd && req.readEnd >= req.first && req.first >= req.open);
});
test('read-ahead reads files in order and a newer chapter replaces the queued one', async () => {
const dir = tmp(), files = [];
for (let i = 0; i < 6; i++) { const f = path.join(dir, `p${i}.jpg`); fs.writeFileSync(f, Buffer.alloc(1000 + i)); files.push(f); }
const batches = [];
const ahead = new ReadAhead({ concurrency: 1, onBatch: b => batches.push(b) });
const done = new Promise(resolve => { const check = () => (batches.some(b => b.tag === 'B' && !b.replaced) ? resolve() : setTimeout(check, 5)); check(); });
ahead.warm(files.slice(0, 4), 'A');
ahead.warm(files.slice(3), 'B');
await done;
assert.equal(batches[0].tag, 'A'); assert.equal(batches[0].replaced, true);
const b = batches.find(x => x.tag === 'B');
assert.equal(b.done, 3); assert.equal(b.bytes, 1003 + 1004 + 1005);
// Files read recently are skipped the next time.
batches.length = 0;
ahead.warm(files.slice(4), 'C');
assert.deepEqual(batches.map(x => [x.tag, x.files, x.skipped]), [['C', 0, 2]]);
});
test('read-ahead keeps one queue per reader and serves them in turn', async () => {
const dir = tmp(), mk = n => { const f = path.join(dir, n); fs.writeFileSync(f, n); return f; };
const order = [], batches = [];
const ahead = new ReadAhead({ concurrency: 1, onBatch: b => batches.push(b) });
const read = ahead.readFile.bind(ahead);
ahead.readFile = f => { order.push(path.basename(f)); return read(f); };
ahead.warm([mk('a1'), mk('a2')], 'A', 'ann');
ahead.warm([mk('b1'), mk('b2')], 'B', 'bob');
await new Promise(r => { const check = () => (batches.length === 2 ? r() : setTimeout(check, 5)); check(); });
assert.deepEqual(order, ['a1', 'b1', 'a2', 'b2']);
assert.ok(batches.every(b => !b.replaced && b.done === 2));
});
test('read-ahead runs the follow-up batch after the current one', async () => {
const dir = tmp(), a = path.join(dir, 'a.jpg'), b = path.join(dir, 'b.jpg');
fs.writeFileSync(a, 'a'); fs.writeFileSync(b, 'bb');
const tags = [];
const ahead = new ReadAhead({ onBatch: x => tags.push(x.tag) });
ahead.warm([a], 'now'); ahead.then([b], 'next');
await new Promise(r => { const check = () => (tags.length === 2 ? r() : setTimeout(check, 5)); check(); });
assert.deepEqual(tags, ['now', 'next']);
});
test('the report blames the storage for slow first reads', () => {
const t = Date.parse('2026-10-29T20:00:00Z');
const events = [
{ t, k: 'req', m: 'GET', path: '/lib/M/B/C/1.jpg', status: 200, bytes: 300000, stat: 2, open: 3, first: 1900, readEnd: 1950, disk: 1947, netWait: 0, ms: 1955, conn: 1 },
{ t: t + 1000, k: 'req', m: 'GET', path: '/lib/M/B/C/2.jpg', status: 200, bytes: 300000, stat: 1, open: 2, first: 5, readEnd: 20, disk: 18, netWait: 0, ms: 22, conn: 2 },
{ t: t + 2000, k: 'client', e: 'res', url: '/lib/M/B/C/1.jpg', total: 2100, ttfb: 30, download: 2000, queue: 1, connect: 0, dns: 0 },
{ t: t + 3000, k: 'client', e: 'view', wait: 2050, preloaded: false },
{ t: t + 4000, k: 'nas', firstMs: 2500, ms: 2600, bytes: 1 },
];
const o = analyse(events, { slow: 500 });
assert.equal(o.slowServer.count, 1);
assert.deepEqual(o.slowServer.by, { firstRead: 1 });
assert.deepEqual(o.slowBrowser.by, { server: 1 });
assert.equal(o.pageTurns.over1s, 1);
assert.match(o.verdict.join('\n'), /first read from storage/);
});
+106
View File
@@ -0,0 +1,106 @@
'use strict';
// Reader settings per login session: a session follows the latest set until it changes one, then keeps its own.
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net');
const { spawn } = require('child_process');
const readerSessions = require('../reader-sessions');
async function freePort() {
const listener = net.createServer();
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve));
const port = listener.address().port;
await new Promise(resolve => listener.close(resolve));
return port;
}
test('device labels name browser and system', () => {
assert.equal(readerSessions.deviceLabel('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1'), 'Safari on iPhone');
assert.equal(readerSessions.deviceLabel('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/130.0 Safari/537.36 Edg/130.0'), 'Edge on Windows');
assert.equal(readerSessions.deviceLabel(''), 'Unknown device');
});
test('sessions lock their reader settings on the first change, new ones copy the last active one', { timeout: 20000 }, async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-reader-sessions-'));
let child;
try {
const library = path.join(root, 'library'), data = path.join(root, 'data');
fs.mkdirSync(library); fs.mkdirSync(data);
const port = await freePort(), output = [];
child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true,
env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0',
SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '',
GREATEST_COVER_WARM: '0' } });
child.stdout.on('data', d => output.push(String(d)));
child.stderr.on('data', d => output.push(String(d)));
const base = `http://127.0.0.1:${port}`;
let ready = false;
for (let n = 0; n < 100 && !ready; n++) {
ready = await fetch(base + '/health').then(r => r.ok).catch(() => false);
if (!ready) await new Promise(resolve => setTimeout(resolve, 50));
}
assert.ok(ready, output.join(''));
const json = async r => { const body = await r.json(); assert.equal(r.status, 200, JSON.stringify(body)); return body; };
const post = (cookie, url, body) => fetch(base + url, { method: 'POST',
headers: { 'content-type': 'application/json', 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) Firefox/130.0', ...(cookie ? { cookie } : {}) },
body: JSON.stringify(body) }).then(async r => (url.endsWith('login') || url.endsWith('signup') ? r : json(r)));
const me = async cookie => (await json(await fetch(base + '/api/me', { headers: { cookie } }))).settings;
const overview = cookie => fetch(base + '/api/reader-sessions', { headers: { cookie } }).then(json);
const login = async () => (await post(null, '/api/login', { name: 'reader', password: 'reader-password' })).headers.get('set-cookie').split(';')[0];
const a = (await post(null, '/api/signup', { name: 'reader', password: 'reader-password' })).headers.get('set-cookie').split(';')[0];
// First session: defaults, following the latest set.
assert.equal((await me(a)).lastMode, 'page');
assert.equal((await overview(a)).sessions[0].locked, false);
// Session B exists before A changes anything: it follows A's change.
const b = await login();
await post(a, '/api/settings', { lastMode: 'flow', zoom: { flow: 1.5 } });
assert.equal((await me(b)).lastMode, 'flow');
assert.equal((await me(b)).zoom.flow, 1.5);
// B changes its own: B locks, A keeps its own set.
await post(b, '/api/settings', { pageTone: 'sepia' });
assert.equal((await me(b)).pageTone, 'sepia');
assert.equal((await me(b)).lastMode, 'flow');
assert.equal((await me(a)).pageTone, 'original');
// A non-reader setting is shared and does not lock anything.
const c = await login();
await post(c, '/api/settings', { continueRow: false });
assert.equal((await me(a)).continueRow, false);
assert.equal((await overview(c)).sessions.find(s => s.current).locked, false);
// C follows the last active session, which is A (it loaded /api/me last).
assert.equal((await me(c)).pageTone, 'original');
await me(b);
assert.equal((await me(c)).pageTone, 'sepia');
// Overview: three sessions, labels, intent.
const o = await overview(a);
assert.equal(o.enabled, true);
assert.equal(o.sessions.length, 3);
assert.equal(o.sessions[0].current, true);
assert.equal(o.sessions[0].device, 'Firefox on Linux');
assert.deepEqual(o.sessions.map(s => s.locked).sort(), [false, true, true]);
// "Follow latest again" drops A's lock; "Use for new sessions" makes a set the latest.
const idA = o.sessions[0].id, idB = o.sessions.find(s => !s.current && s.locked).id;
await post(a, '/api/reader-sessions', { id: idA, action: 'follow' });
assert.equal((await me(a)).pageTone, 'sepia');
assert.equal((await fetch(base + '/api/reader-sessions', { method: 'POST', headers: { 'content-type': 'application/json', cookie: a },
body: JSON.stringify({ id: 'nope', action: 'follow' }) })).status, 404);
assert.equal((await post(a, '/api/reader-sessions', { id: idB, action: 'latest' })).latestFrom, idB);
// Off: one shared set; a change applies everywhere and locks nothing. On again: kept locks return.
await post(b, '/api/settings', { readerPerSession: false });
await post(a, '/api/settings', { pageTone: 'dim' });
assert.equal((await me(b)).pageTone, 'dim');
assert.equal((await me(c)).pageTone, 'dim');
await post(a, '/api/settings', { readerPerSession: true });
assert.equal((await me(b)).pageTone, 'sepia');
assert.equal((await me(a)).pageTone, 'sepia');
} finally {
if (child && child.exitCode === null) { child.kill(); await new Promise(resolve => child.once('exit', resolve)); }
fs.rmSync(root, { recursive: true, force: true });
}
});
+99
View File
@@ -0,0 +1,99 @@
'use strict';
const test = require('node:test'), assert = require('node:assert/strict');
const act = require('../reading-activity');
const core = require('../public/activity-core');
const MIN = 60000, HOUR = 60 * MIN, DAY = 24 * HOUR;
const sum = a => Object.values(a.fine).concat(Object.values(a.hourly)).reduce((s, b) => s + b.a, 0);
test('active time ending at a moment is spread over the 6-minute buckets it covers', () => {
const a = act.emptyActivity(), end = 1000 * act.FINE_MS + 2 * MIN;
act.addActive(a, 'alpha', 5 * MIN, end);
assert.deepEqual(Object.keys(a.fine).map(Number), [999, 1000]);
assert.equal(a.fine[1000].a, 2 * MIN); assert.equal(a.fine[999].a, 3 * MIN);
act.addActive(a, 'beta', MIN, end);
assert.deepEqual(a.fine[1000].m, { alpha: 2 * MIN, beta: MIN });
});
test('old buckets fold into hours, very old ones are dropped, totals stay', () => {
const now = 2000 * DAY, a = act.emptyActivity();
act.addActive(a, 'alpha', 10 * MIN, now - 200 * DAY);
act.addActive(a, 'alpha', 10 * MIN, now - 2 * DAY);
act.addActive(a, 'alpha', 10 * MIN, now - 1200 * DAY);
act.fold(a, now);
assert.equal(Object.keys(a.hourly).length, 1);
assert.equal(sum(a), 20 * MIN);
});
test('backfill places remembered visits before their last save, once', () => {
const now = 100 * DAY, at = now - DAY;
const reading = { alpha: { c1: { recentSessions: [
{ id: 'v1', at, pass: { activeMs: 20 * MIN, startedAt: at - 30 * MIN } },
{ id: 'legacy', at, counted: true },
] } } };
const a = act.backfill(act.emptyActivity(), reading, now);
assert.equal(sum(a), 20 * MIN); assert.equal(a.backfilled, true);
act.backfill(a, reading, now);
assert.equal(sum(a), 20 * MIN);
});
test('forgetting a manga removes only its share', () => {
const a = act.emptyActivity();
act.addActive(a, 'alpha', MIN, 10 * act.FINE_MS);
act.addActive(a, 'beta', 2 * MIN, 10 * act.FINE_MS);
act.forgetManga(a, 'beta');
assert.equal(sum(a), MIN); assert.deepEqual(a.fine[9].m, { alpha: MIN });
});
test('series lists buckets in time order and chapter completions with labels', () => {
const a = act.emptyActivity();
act.addActive(a, 'alpha', MIN, 20 * act.FINE_MS);
a.hourly[1] = { a: MIN, m: { beta: MIN } };
const s = act.activitySeries(a, { alpha: { c1: { done: true, doneAt: 5 }, c2: { done: false } } },
{ titleOf: id => id.toUpperCase(), labelOf: (m, c) => 'Ch. ' + c });
assert.deepEqual(s.buckets.map(b => b[0]), [act.HOUR_MS, 19 * act.FINE_MS]);
assert.deepEqual(s.done, [[5, s.mangas.findIndex(m => m.id === 'alpha'), 'Ch. c1']]);
assert.equal(s.mangas.find(m => m.id === 'beta').title, 'BETA');
});
test('periods are local and Monday based; cells carry their natural segments', () => {
const tue = new Date(2026, 10, 3, 15, 30).getTime();
assert.equal(core.periodStart('week', tue), new Date(2026, 10, 2).getTime());
assert.equal(core.periodStart('month', tue), new Date(2026, 10, 1).getTime());
assert.equal(core.isoWeek(tue), 45);
assert.equal(core.segmentBounds('month', new Date(2026, 1, 1).getTime()).length, 29);
assert.equal(core.segmentBounds('week', new Date(2026, 10, 2).getTime()).length, 29);
assert.equal(core.segmentBounds('hour', new Date(2026, 10, 3, 15).getTime()).length, 11);
});
test('layouts: two years of months, six months of weeks without repeats, a month calendar, a day of hours', () => {
const t = new Date(2026, 10, 3, 15).getTime();
const months = core.activityLayout('month', t);
assert.deepEqual(months.rows.map(r => r.cells.length), [12, 12]);
const weeks = core.activityLayout('week', t).rows.flatMap(r => r.cells);
assert.equal(new Set(weeks.map(c => c.start)).size, weeks.length);
for (let i = 1; i < weeks.length; i++) assert.equal(weeks[i].start, core.periodShift('week', weeks[i - 1].start, 1));
const days = core.activityLayout('day', t);
assert.equal(days.rows.flatMap(r => r.cells).filter(c => !c.outside).length, 30);
assert.equal(core.activityLayout('hour', t).rows.flatMap(r => r.cells).length, 24);
});
test('sums split buckets by overlap and count completions in their segment', () => {
const day = new Date(2026, 10, 3).getTime();
const cells = core.activityLayout('hour', day).rows.flatMap(r => r.cells);
const nine = day + 9 * HOUR;
core.activitySums({ buckets: [[nine - 30 * MIN, HOUR, 60 * MIN, [[0, 60 * MIN]]]], done: [[nine + 7 * MIN, 0, 'Ch. 1']] }, cells);
assert.equal(Math.round(cells[8].totalTime), 30 * MIN); assert.equal(Math.round(cells[9].totalTime), 30 * MIN);
assert.deepEqual(cells[9].time.slice(0, 6).map(Math.round), [6 * MIN, 6 * MIN, 6 * MIN, 6 * MIN, 6 * MIN, 0]);
assert.equal(cells[9].chapters[1], 1); assert.equal(cells[9].done.length, 1);
});
test('drilling: month and week open days (week marked), day opens hours, hour is the end', () => {
const week = new Date(2026, 10, 30).getTime();
const next = core.activityDrill({ level: 'week', start: week });
assert.equal(next.view, 'day'); assert.equal(next.mark, week);
assert.equal(new Date(next.anchor).getMonth(), 11);
assert.equal(core.activityDrill({ level: 'day', start: week }).view, 'hour');
assert.equal(core.activityDrill({ level: 'hour', start: week }), null);
assert.equal(core.activityGlow(0, 10), 0); assert.equal(core.activityGlow(10, 10), 1);
});
+14 -1
View File
@@ -29,7 +29,7 @@ test('three services integrate collections, auth, custom covers and private heal
const token = 'integration-test-only'; const token = 'integration-test-only';
const env = { ...process.env, DATA: data, LIBRARY: library, SCRAPER_DATA: path.join(data, 'scraper'), const env = { ...process.env, DATA: data, LIBRARY: library, SCRAPER_DATA: path.join(data, 'scraper'),
PORT: String(rp), SCRAPER_PORT: String(sp), SCRAPER_HOST: '127.0.0.1', PORT: String(rp), SCRAPER_PORT: String(sp), SCRAPER_HOST: '127.0.0.1',
READER_URL: base, SCRAPER_URL: scraper, SCRAPER_TOKEN: token, SCRAPER_TOKEN_FILE: '', SCAN_INTERVAL: '0', COOKIE_SECURE: '0' }; READER_URL: base, SCRAPER_URL: scraper, SCRAPER_TOKEN: token, SCRAPER_TOKEN_FILE: '', SCAN_INTERVAL: '0', COOKIE_SECURE: '0', GREATEST_COVER_WARM: '0' };
for (const script of ['server.js', 'scraper/daemon.js']) { for (const script of ['server.js', 'scraper/daemon.js']) {
const child = spawn(process.execPath, [script], { cwd: root, env, windowsHide: true }); children.push(child); const child = spawn(process.execPath, [script], { cwd: root, env, windowsHide: true }); children.push(child);
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => output.push(chunk.toString())); for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => output.push(chunk.toString()));
@@ -67,6 +67,10 @@ test('three services integrate collections, auth, custom covers and private heal
assert.equal((await (await request('/api/settings', { pageTone: 'sepia' })).json()).settings.pageTone, 'sepia'); assert.equal((await (await request('/api/settings', { pageTone: 'sepia' })).json()).settings.pageTone, 'sepia');
assert.equal((await (await request('/api/settings', { pageTone: 'invalid' })).json()).settings.pageTone, 'sepia'); assert.equal((await (await request('/api/settings', { pageTone: 'invalid' })).json()).settings.pageTone, 'sepia');
assert.equal((await (await anotherRequest('/api/me')).json()).settings.pageTone, 'original'); assert.equal((await (await anotherRequest('/api/me')).json()).settings.pageTone, 'original');
assert.equal((await (await request('/api/me')).json()).settings.continueRow, true);
assert.equal((await (await request('/api/settings', { continueRow: false })).json()).settings.continueRow, false);
assert.equal((await (await request('/api/settings', { continueRow: 'no' })).json()).settings.continueRow, false);
assert.equal((await (await anotherRequest('/api/me')).json()).settings.continueRow, true);
assert.equal((await fetch(base + '/api/discovery')).status, 401); assert.equal((await fetch(base + '/api/discovery')).status, 401);
assert.equal((await anotherRequest('/api/admin/discovery/run', {})).status, 403); assert.equal((await anotherRequest('/api/admin/discovery/run', {})).status, 403);
assert.equal((await request('/api/settings', { discoveryEnabled: true })).status, 200); assert.equal((await request('/api/settings', { discoveryEnabled: true })).status, 200);
@@ -144,6 +148,15 @@ test('three services integrate collections, auth, custom covers and private heal
assert.equal((await send(visit(first, 'beacon-first', 2, { activeMs: 500, seen: [0], final: true, release: true }))).accepted, true); assert.equal((await send(visit(first, 'beacon-first', 2, { activeMs: 500, seen: [0], final: true, release: true }))).accepted, true);
assert.equal((await send(visit(second, 'third-visit', 1))).accepted, false); assert.equal((await send(visit(second, 'third-visit', 1))).accepted, false);
await send(visit(second, 'second-visit', 2, { release: true })); await send(visit(second, 'second-visit', 2, { release: true }));
// The activity grid gets every credited millisecond once, duplicates and stale pulses excluded.
assert.equal((await request('/api/progress', { m: first.manga.id, c: first.chapter.id, v: first.version, p: 0, n: 1, done: true })).status, 200);
const activity = await (await request('/api/reading/activity')).json();
assert.equal(activity.readingClock, true);
assert.equal(activity.buckets.reduce((sum, b) => sum + b[2], 0), 2000);
assert.deepEqual(activity.mangas.map(m => m.id), [first.manga.id]);
assert.equal(activity.done.length, 1); assert.equal(activity.done[0][2], first.chapter.label);
assert.equal((await request('/api/reading/delete', { m: first.manga.id })).status, 200);
assert.equal((await (await request('/api/reading/activity')).json()).buckets.length, 0);
assert.equal((await fetch(base + '/api/reading/visit', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(visit(first, 'unauth-visit', 1)) })).status, 401); assert.equal((await fetch(base + '/api/reading/visit', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(visit(first, 'unauth-visit', 1)) })).status, 401);
await request('/api/settings', { readingClock: false }); await request('/api/settings', { readingClock: false });
assert.equal((await request('/api/reading/visit', visit(first, 'disabled-visit', 1))).status, 403); assert.equal((await request('/api/reading/visit', visit(first, 'disabled-visit', 1))).status, 403);
+97
View File
@@ -0,0 +1,97 @@
'use strict';
// Stale reading sessions: a reader tab left open on another device must not move "Continue" back.
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net');
const { spawn } = require('child_process');
const { Canvas } = require('../tools/png');
async function freePort() {
const listener = net.createServer();
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve));
const port = listener.address().port;
await new Promise(resolve => listener.close(resolve));
return port;
}
test('a save from a tab that missed a newer position is refused until the reader stays', { timeout: 30000 }, async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-stale-'));
let child;
try {
const library = path.join(root, 'library'), data = path.join(root, 'data');
for (const ch of ['c001 One', 'c002 Two', 'c003 Three']) {
const dir = path.join(library, 'Moon Garden', 'v01', ch);
fs.mkdirSync(dir, { recursive: true });
for (const n of [1, 2, 3]) fs.writeFileSync(path.join(dir, `00${n}.png`), new Canvas(60, 90, [40 * n, 60, 120]).png());
}
fs.mkdirSync(data);
const port = await freePort(), output = [];
child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true,
env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0',
SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '', GREATEST_COVER_WARM: '0' } });
child.stdout.on('data', d => output.push(String(d)));
child.stderr.on('data', d => output.push(String(d)));
const base = `http://127.0.0.1:${port}`;
let ready = false;
for (let n = 0; n < 100 && !ready; n++) {
ready = await fetch(base + '/health').then(r => r.ok).catch(() => false);
if (!ready) await new Promise(resolve => setTimeout(resolve, 50));
}
assert.ok(ready, output.join(''));
const post = (cookie, url, body) => fetch(base + url, { method: 'POST',
headers: { 'content-type': 'application/json', cookie }, body: JSON.stringify(body) }).then(r => r.json());
const get = (cookie, url) => fetch(base + url, { headers: { cookie } }).then(r => r.json());
const signup = await fetch(base + '/api/signup', { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'alice', password: 'alice-password' }) });
const alice = signup.headers.get('set-cookie').split(';')[0];
await post(alice, '/api/admin/rescan', { full: true });
const manga = await get(alice, '/api/manga?m=Moon%20Garden');
const [one, two, three] = manga.chapters.map(c => ({ c: c.id, v: c.versions[0].id }));
const save = (ch, extra) => post(alice, '/api/progress', { m: 'Moon Garden', ...ch, p: 1, n: 3, ...extra });
const latest = () => get(alice, '/api/progress/latest?m=Moon%20Garden').then(r => r.latest);
// Phone reads chapter 1, the tablet then reads on in chapter 3.
const first = await save(one, { tab: 'phone-tab-1', seen: 0 });
assert.equal(first.ok, true);
const phoneSeen = first.t;
await new Promise(resolve => setTimeout(resolve, 5));
const tablet = await save(three, { tab: 'tablet-tab-1', seen: phoneSeen });
assert.equal(tablet.ok, true);
assert.equal((await latest()).c, three.c);
// The phone's old tab wakes up and saves chapter 1 again: refused, the head stays on chapter 3.
const refused = await save(one, { tab: 'phone-tab-1', seen: phoneSeen, p: 2 });
assert.equal(refused.ok, false);
assert.equal(refused.stale, true);
assert.equal(refused.latest.c, three.c);
assert.equal(refused.latest.w, 'tablet-tab-1');
assert.equal(refused.latest.continue.c, three.c);
assert.match(refused.latest.continue.text, /Continue/);
const after = await get(alice, '/api/manga?m=Moon%20Garden');
assert.equal(after.continue.c, three.c);
assert.equal(after.continue.at, tablet.t);
assert.equal(after.chapters[0].progress.p, 1, 'the stale save changed nothing');
// The chapter view tells a reader which head it is compared with.
const view = await get(alice, `/api/chapter?m=Moon%20Garden&c=${encodeURIComponent(one.c)}`);
assert.equal(view.latest.c, three.c);
assert.equal(view.latest.t, tablet.t);
// The tablet's own saves never conflict with themselves, even with an older `seen` still in flight.
assert.equal((await save(three, { tab: 'tablet-tab-1', seen: phoneSeen, p: 2 })).ok, true);
// "Keep reading here": the phone now knows the newest time and its save becomes the head.
const head = await latest();
const stayed = await save(one, { tab: 'phone-tab-1', seen: head.t, p: 2 });
assert.equal(stayed.ok, true);
assert.equal((await latest()).c, one.c);
assert.equal((await get(alice, '/api/manga?m=Moon%20Garden')).continue.c, one.c);
// Saves without `seen` (mark as read on the manga page, older clients) behave as before.
assert.equal((await save(two, { done: true })).ok, true);
assert.equal((await latest()).c, two.c);
} finally {
if (child && child.exitCode === null) { child.kill(); await new Promise(resolve => child.once('exit', resolve)); }
fs.rmSync(root, { recursive: true, force: true });
}
});
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env node
'use strict';
/*
* Load test for the reader: N simulated readers each open random chapters and fetch the pages one after
* another with a think time, the way a person paging through a chapter does, plus a small look-ahead.
* Measures time to first byte and total per page from this machine. Read-only apart from the login; it
* never writes progress. Run it with the server's trace on to see the server's share (docs/performance.md).
*
* node tools/perf-bench.js --url http://host:8080 --user bench --password-env BENCH_PASSWORD \
* [--readers 4] [--chapters 3] [--pages 40] [--think 400] [--ahead 2] [--manga Berserk] [--json]
* --cookie 'sid=…' instead of user/password. --chapter-only skips the page fetches (API timings only).
*/
function parseArgs(argv) {
const o = { url: 'http://localhost:8080', readers: 4, chapters: 3, pages: 40, think: 400, ahead: 2, manga: '', json: false };
for (let i = 0; i < argv.length; i++) {
const key = argv[i].replace(/^--/, ''), next = () => argv[++i];
if (['readers', 'chapters', 'pages', 'think', 'ahead'].includes(key)) o[key] = +next();
else if (key === 'password-env') o.password = process.env[next()] || '';
else if (['url', 'user', 'cookie', 'manga'].includes(key)) o[key] = next();
else if (key === 'json' || key === 'chapter-only') o[key.replace('-o', 'O')] = true;
else if (key === 'h' || key === 'help') o.help = true;
}
o.url = o.url.replace(/\/$/, '');
return o;
}
async function login(o) {
if (o.cookie) return o.cookie;
const r = await fetch(o.url + '/api/login', { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: o.user, password: o.password }) });
if (!r.ok) throw new Error(`login failed: HTTP ${r.status} ${await r.text()}`);
const cookie = (r.headers.get('set-cookie') || '').split(';')[0];
if (!cookie) throw new Error('login returned no session cookie');
return cookie;
}
async function timed(url, cookie) {
const t0 = performance.now();
const r = await fetch(url, { headers: { cookie } });
const ttfb = performance.now() - t0;
let bytes = 0;
for await (const chunk of r.body) bytes += chunk.length;
return { status: r.status, ttfb, ms: performance.now() - t0, bytes, server: r.headers.get('server-timing') || '' };
}
const json = async (o, cookie, path) => {
const t0 = performance.now(), r = await fetch(o.url + path, { headers: { cookie } });
if (!r.ok) throw new Error(`${path}: HTTP ${r.status}`);
return { data: await r.json(), ms: performance.now() - t0 };
};
const pick = list => list[Math.floor(Math.random() * list.length)];
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function reader(o, cookie, mangas, out, id) {
for (let c = 0; c < o.chapters; c++) {
const m = pick(mangas);
const manga = await json(o, cookie, '/api/manga?m=' + encodeURIComponent(m.id));
out.api.push({ path: '/api/manga', ms: manga.ms });
const ch = pick(manga.data.chapters);
const chapter = await json(o, cookie, `/api/chapter?m=${encodeURIComponent(m.id)}&c=${encodeURIComponent(ch.id)}`);
out.api.push({ path: '/api/chapter', ms: chapter.ms });
if (o.chapterOnly) continue;
const pages = chapter.data.pages.slice(0, o.pages);
// The browser keeps `ahead` pages in flight while the reader looks at the current one.
const inflight = new Map();
const start = k => { if (k < pages.length && !inflight.has(k)) inflight.set(k, timed(o.url + '/' + pages[k].u, cookie)); };
for (let k = 0; k < pages.length; k++) {
const waitFrom = performance.now();
start(k);
for (let a = 1; a <= o.ahead; a++) start(k + a);
const r = await inflight.get(k);
out.pages.push({ reader: id, manga: m.id, chapter: ch.id, page: k, wait: performance.now() - waitFrom, ...r });
await sleep(o.think * (0.5 + Math.random()));
}
await Promise.all(inflight.values());
}
}
const pct = (v, p) => { const s = v.slice().sort((a, b) => a - b); return s.length ? Math.round(s[Math.min(s.length - 1, Math.floor(p / 100 * s.length))]) : null; };
const sum = v => ({ n: v.length, p50: pct(v, 50), p90: pct(v, 90), p99: pct(v, 99), max: v.length ? Math.round(Math.max(...v)) : null });
async function main() {
const o = parseArgs(process.argv.slice(2));
if (o.help || (!o.cookie && !o.user)) {
console.log('usage: node tools/perf-bench.js --url URL (--user NAME --password-env VAR | --cookie sid=…) [--readers N] [--chapters N] [--pages N] [--think ms] [--ahead N] [--manga text] [--chapter-only] [--json]');
process.exit(o.help ? 0 : 2);
}
const cookie = await login(o);
let mangas = (await json(o, cookie, '/api/library')).data.mangas;
if (o.manga) mangas = mangas.filter(m => m.title.toLowerCase().includes(o.manga.toLowerCase()));
if (!mangas.length) throw new Error('no manga to read');
const out = { pages: [], api: [] }, t0 = Date.now();
await Promise.all(Array.from({ length: o.readers }, (_, i) => reader(o, cookie, mangas, out, i)));
const ok = out.pages.filter(p => p.status === 200), mb = ok.reduce((a, p) => a + p.bytes, 0) / 1048576;
const result = {
readers: o.readers, seconds: Math.round((Date.now() - t0) / 1000), pages: out.pages.length, errors: out.pages.length - ok.length,
MB: Math.round(mb), wait: sum(ok.map(p => p.wait)), ttfb: sum(ok.map(p => p.ttfb)), total: sum(ok.map(p => p.ms)),
waitedOver300: ok.filter(p => p.wait >= 300).length, waitedOver1s: ok.filter(p => p.wait >= 1000).length,
api: Object.fromEntries([...new Set(out.api.map(a => a.path))].map(p => [p, sum(out.api.filter(a => a.path === p).map(a => a.ms))])),
worst: ok.sort((a, b) => b.wait - a.wait).slice(0, 8).map(p => ({ wait: Math.round(p.wait), ttfb: Math.round(p.ttfb), ms: Math.round(p.ms), kb: Math.round(p.bytes / 1024), at: `${p.manga} ${p.chapter} p${p.page}` })),
};
if (o.json) return console.log(JSON.stringify(result, null, 2));
console.log(`${result.readers} readers, ${result.pages} pages (${result.MB} MB) in ${result.seconds} s, ${result.errors} errors`);
for (const k of ['wait', 'ttfb', 'total']) console.log(` ${k.padEnd(6)} ms ${JSON.stringify(result[k])}`);
console.log(` page waits >= 300 ms: ${result.waitedOver300}, >= 1 s: ${result.waitedOver1s} (wait = time the reader looked at an empty page)`);
for (const [p, s] of Object.entries(result.api)) console.log(` ${p} ${JSON.stringify(s)}`);
for (const w of result.worst) console.log(` wait ${w.wait} ms (ttfb ${w.ttfb}, total ${w.ms}, ${w.kb} KB) ${w.at}`);
}
main().catch(e => { console.error(e.message); process.exit(1); });
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env node
'use strict';
/*
* Summarise a performance trace (docs/performance.md) and say where slow page loads spend their time.
*
* node tools/perf-report.js DATA/perf all trace files in the folder
* node tools/perf-report.js trace-2026-10-29.jsonl --since 2026-10-29T18:00 --until … --slow 500 --json
*
* Plain text by default (readable by people and agents); --json prints the numbers only.
*/
const fs = require('fs'), path = require('path');
function parseArgs(argv) {
const opts = { files: [], since: null, until: null, slow: 500, json: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--since') opts.since = Date.parse(argv[++i]);
else if (a === '--until') opts.until = Date.parse(argv[++i]);
else if (a === '--slow') opts.slow = +argv[++i];
else if (a === '--json') opts.json = true;
else if (a === '-h' || a === '--help') opts.help = true;
else opts.files.push(a);
}
return opts;
}
function readEvents(inputs, { since, until }) {
const files = inputs.flatMap(p => fs.statSync(p).isDirectory()
? fs.readdirSync(p).filter(n => /^trace-.*\.jsonl$/.test(n)).sort().map(n => path.join(p, n)) : [p]);
const events = [];
for (const file of files) for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
if (!line) continue;
let e; try { e = JSON.parse(line); } catch { continue; }
if ((since && e.t < since) || (until && e.t > until)) continue;
events.push(e);
}
return events.sort((a, b) => a.t - b.t);
}
const pct = (values, p) => {
const v = values.filter(x => typeof x === 'number').sort((a, b) => a - b);
if (!v.length) return null;
return v[Math.min(v.length - 1, Math.floor((p / 100) * v.length))];
};
const stats = values => {
const v = values.filter(x => typeof x === 'number');
return v.length ? { n: v.length, p50: pct(v, 50), p90: pct(v, 90), p99: pct(v, 99), max: Math.max(...v) } : { n: 0 };
};
const isPageImage = e => e.k === 'req' && e.path.startsWith('/lib/') && e.path.split('/').length >= 6;
// Where one served image spent its time on the server.
function serverPhases(e) {
const stat = e.stat ?? 0, open = e.open != null ? e.open - stat : 0, firstRead = e.first != null && e.open != null ? e.first - e.open : 0;
const rest = Math.max(0, (e.disk ?? 0) - firstRead), net = e.netWait ?? 0;
const tail = e.readEnd != null ? Math.max(0, e.ms - e.readEnd) : 0;
const r = v => Math.round(v * 10) / 10;
return { stat: r(stat), open: r(open), firstRead: r(firstRead), readRest: r(rest), netWait: r(net), tail: r(tail) };
}
const dominant = phases => Object.entries(phases).sort((a, b) => b[1] - a[1])[0][0];
function analyse(events, { slow }) {
const out = { window: events.length ? { from: new Date(events[0].t).toISOString(), to: new Date(events.at(-1).t).toISOString() } : null };
const reqs = events.filter(e => e.k === 'req');
const imgs = reqs.filter(isPageImage), served = imgs.filter(e => e.status === 200 && !e.aborted);
out.requests = { total: reqs.length, pageImages: imgs.length, served: served.length,
notModified: imgs.filter(e => e.status === 304).length, aborted: imgs.filter(e => e.aborted).length,
errors: imgs.filter(e => e.status >= 400).length, keepAliveReuse: imgs.length ? round(imgs.filter(e => e.conn > 1).length / imgs.length) : null };
out.serverImage = { total: stats(served.map(e => e.ms)), stat: stats(served.map(e => e.stat)),
firstRead: stats(served.map(e => serverPhases(e).firstRead)), disk: stats(served.map(e => e.disk)),
netWait: stats(served.map(e => e.netWait)), kbytes: stats(served.map(e => e.bytes / 1024)) };
const slowServed = served.filter(e => e.ms >= slow);
out.slowServer = { count: slowServed.length, share: served.length ? round(slowServed.length / served.length) : null, by: {} };
for (const e of slowServed) { const d = dominant(serverPhases(e)); out.slowServer.by[d] = (out.slowServer.by[d] || 0) + 1; }
out.slowServer.worst = slowServed.sort((a, b) => b.ms - a.ms).slice(0, 10)
.map(e => ({ at: new Date(e.t).toISOString(), ms: e.ms, ...serverPhases(e), path: e.path }));
const api = {};
for (const e of reqs.filter(e => e.path.startsWith('/api/'))) (api[e.path] ||= []).push(e.ms);
out.api = Object.fromEntries(Object.entries(api).map(([p, v]) => [p, stats(v)]).sort((a, b) => (b[1].p99 || 0) - (a[1].p99 || 0)).slice(0, 12));
// Browser view, matched to the server's record of the same file where possible.
const res = events.filter(e => e.k === 'client' && e.e === 'res'), byPath = new Map();
const decoded = p => { try { return decodeURIComponent(p); } catch { return p; } };
for (const e of imgs) { const key = decoded(e.path); (byPath.get(key) || byPath.set(key, []).get(key)).push(e); }
const matched = res.map(r => {
const cands = (byPath.get(r.url) || []).filter(s => s.t <= r.t + 1000 && s.t >= r.t - 120000);
const s = cands.at(-1);
return { ...r, serverMs: s ? s.ms : null };
});
const loaded = matched.filter(r => !r.cached);
out.browserImage = { total: stats(loaded.map(r => r.total)), queue: stats(loaded.map(r => r.queue)),
ttfb: stats(loaded.map(r => r.ttfb)), download: stats(loaded.map(r => r.download)),
connect: stats(loaded.map(r => r.connect)), dns: stats(loaded.map(r => r.dns)), fromCache: matched.length - loaded.length };
const slowBrowser = loaded.filter(r => r.total >= slow);
out.slowBrowser = { count: slowBrowser.length, share: loaded.length ? round(slowBrowser.length / loaded.length) : null, by: {} };
for (const r of slowBrowser) {
const parts = { server: r.serverMs ?? 0, network: Math.max(0, (r.ttfb ?? 0) + (r.download ?? 0) - (r.serverMs ?? 0)),
queue: r.queue ?? 0, connect: (r.connect ?? 0) + (r.dns ?? 0) };
const d = r.serverMs == null ? 'unmatched' : dominant(parts);
out.slowBrowser.by[d] = (out.slowBrowser.by[d] || 0) + 1;
}
const views = events.filter(e => e.k === 'client' && e.e === 'view'), stalls = events.filter(e => e.k === 'client' && e.e === 'stall');
out.pageTurns = { count: views.length, preloaded: views.filter(v => v.preloaded).length, wait: stats(views.map(v => v.wait)),
over300ms: views.filter(v => v.wait >= 300).length, over1s: views.filter(v => v.wait >= 1000).length };
out.flowStalls = { count: stalls.length, wait: stats(stalls.map(s => s.wait)), over1s: stalls.filter(s => s.wait >= 1000).length };
out.clients = [...new Set(events.filter(e => e.k === 'client' && e.e === 'hello').map(e => `${e.user}: ${e.ua}`))].slice(0, 10);
// Storage probe: cold reads of random pages; a long read after quiet time points at disks waking up.
const nas = events.filter(e => e.k === 'nas' && !e.error);
// Library reads that finished before a probe started (a probe's own read does not count).
const ioEnds = events.filter(e => isPageImage(e) || e.k === 'nas' || e.k === 'ahead').map(e => e.t);
out.nas = { probes: nas.length, errors: events.filter(e => e.k === 'nas' && e.error).length, first: stats(nas.map(e => e.firstMs)), total: stats(nas.map(e => e.ms)) };
out.nas.slow = nas.filter(e => e.firstMs >= 1000).map(e => {
const start = e.t - e.ms, before = ioEnds.filter(t => t < start).at(-1);
return { at: new Date(e.t).toISOString(), firstMs: e.firstMs, quietMinBefore: before ? round((start - before) / 60000) : null };
}).slice(0, 30);
const loops = events.filter(e => e.k === 'loop');
out.eventLoop = { samples: loops.length, lagP99: stats(loops.map(e => e.lagP99)), lagMax: stats(loops.map(e => e.lagMax)), cpu: stats(loops.map(e => e.cpu)), rssMB: stats(loops.map(e => e.rss)) };
const scans = events.filter(e => e.k === 'scan');
out.scans = { count: scans.length, ms: stats(scans.map(e => e.ms)) };
const during = e => scans.some(s => e.t >= s.t - s.ms && e.t - e.ms <= s.t);
out.scans.slowImagesDuringScan = slowServed.filter(during).length;
const span = events.length ? events.at(-1).t - events[0].t : 0;
out.scans.timeShare = span ? round(scans.reduce((a, s) => a + s.ms, 0) / span) : null;
const ahead = events.filter(e => e.k === 'ahead');
out.readAhead = { batches: ahead.length, replaced: ahead.filter(e => e.replaced).length, files: ahead.reduce((a, e) => a + e.done, 0),
MBps: stats(ahead.filter(e => e.ms > 0 && e.bytes > 0).map(e => round(e.bytes / 1048.576 / e.ms))) };
const scraper = events.filter(e => e.k === 'scraper');
out.scraper = { calls: scraper.length, errors: scraper.filter(e => e.error).length, ms: stats(scraper.map(e => e.ms)) };
out.verdict = verdict(out, slow);
return out;
}
function verdict(o, slow) {
const lines = [];
const top = by => Object.entries(by).sort((a, b) => b[1] - a[1]);
if (o.slowServer.count) {
const [[phase, n]] = top(o.slowServer.by);
const name = { stat: 'the file lookup (stat) on the storage', open: 'opening the file', firstRead: 'the first read from storage',
readRest: 'reading the rest of the file from storage', netWait: 'the client receiving data (network)', tail: 'flushing to the client' }[phase];
lines.push(`${o.slowServer.count} of ${o.serverImage.total.n} served pages took >= ${slow} ms on the server; ${n} of them mostly in ${name}.`);
} else if (o.serverImage.total.n) lines.push(`No served page took >= ${slow} ms on the server (p99 ${o.serverImage.total.p99} ms).`);
if (o.slowBrowser.count) {
const [[part, n]] = top(o.slowBrowser.by);
lines.push(`${o.slowBrowser.count} of ${o.browserImage.total.n} page loads took >= ${slow} ms in the browser; mostly ${part} (${n}).`);
}
if (o.pageTurns.count) lines.push(`Page turns: ${o.pageTurns.preloaded}/${o.pageTurns.count} preloaded, ${o.pageTurns.over300ms} waited >= 300 ms, ${o.pageTurns.over1s} >= 1 s (p90 ${o.pageTurns.wait.p90 ?? 0} ms).`);
if (o.flowStalls.count) lines.push(`Flow mode: ${o.flowStalls.count} pages were on screen before their image (p90 ${o.flowStalls.wait.p90} ms, ${o.flowStalls.over1s} >= 1 s).`);
if (o.nas.probes) {
lines.push(`Storage probe (cold page): first byte p50 ${o.nas.first.p50} ms, p99 ${o.nas.first.p99} ms, max ${o.nas.first.max} ms; ${o.nas.slow.length} probes >= 1 s.`);
const sleepy = o.nas.slow.filter(s => s.quietMinBefore >= 5).length;
if (sleepy) lines.push(`${sleepy} slow probes followed >= 5 min without library reads: disks waking from standby is likely.`);
}
if (o.eventLoop.samples && (o.eventLoop.lagMax.max || 0) >= 200) lines.push(`Event loop blocked up to ${o.eventLoop.lagMax.max} ms (reader CPU work holds requests back).`);
if (o.scans.slowImagesDuringScan) lines.push(`${o.scans.slowImagesDuringScan} slow pages overlapped a library scan (scans ran ${Math.round((o.scans.timeShare || 0) * 100)}% of the time).`);
return lines;
}
function print(o) {
const s = x => (x && x.n ? `n=${x.n} p50=${x.p50} p90=${x.p90} p99=${x.p99} max=${x.max}` : 'n=0');
const out = [];
out.push(`Trace ${o.window ? `${o.window.from} .. ${o.window.to}` : '(empty)'}`, '', 'Verdict:', ...o.verdict.map(l => ' - ' + l), '');
out.push('Requests', ` ${JSON.stringify(o.requests)}`);
out.push('Server, page images (ms)');
for (const k of ['total', 'stat', 'firstRead', 'disk', 'netWait', 'kbytes']) out.push(` ${k.padEnd(10)} ${s(o.serverImage[k])}`);
out.push(` slow by phase ${JSON.stringify(o.slowServer.by)}`);
for (const w of o.slowServer.worst) out.push(` ${w.at} ${w.ms} ms stat=${w.stat} first=${w.firstRead} rest=${w.readRest} net=${w.netWait} ${w.path}`);
out.push('Browser, page images (ms)');
for (const k of ['total', 'queue', 'ttfb', 'download', 'connect', 'dns']) out.push(` ${k.padEnd(10)} ${s(o.browserImage[k])}`);
out.push(` from browser cache ${o.browserImage.fromCache}; slow by part ${JSON.stringify(o.slowBrowser.by)}`);
out.push(`Page turns ${o.pageTurns.count} (${o.pageTurns.preloaded} preloaded) wait ${s(o.pageTurns.wait)}`);
out.push(`Flow stalls ${o.flowStalls.count} wait ${s(o.flowStalls.wait)}`);
out.push(`Storage probe first byte ${s(o.nas.first)} total ${s(o.nas.total)} errors ${o.nas.errors}`);
for (const p of o.nas.slow) out.push(` ${p.at} first=${p.firstMs} ms after ${p.quietMinBefore ?? '?'} min quiet`);
out.push(`Event loop lagP99 ${s(o.eventLoop.lagP99)} lagMax ${s(o.eventLoop.lagMax)} cpu% ${s(o.eventLoop.cpu)} rssMB ${s(o.eventLoop.rssMB)}`);
out.push(`Scans ${o.scans.count} ${s(o.scans.ms)}`);
out.push(`Read-ahead ${JSON.stringify(o.readAhead)}`);
out.push(`Scraper calls ${o.scraper.calls} errors ${o.scraper.errors} ${s(o.scraper.ms)}`);
out.push('API (ms, slowest p99 first)');
for (const [p, v] of Object.entries(o.api)) out.push(` ${p.padEnd(28)} ${s(v)}`);
if (o.clients.length) out.push('Clients', ...o.clients.map(c => ' ' + c));
return out.join('\n');
}
const round = v => Math.round(v * 100) / 100;
if (require.main === module) {
const opts = parseArgs(process.argv.slice(2));
if (opts.help || !opts.files.length) { console.log('usage: node tools/perf-report.js <trace.jsonl|folder>... [--since ISO] [--until ISO] [--slow ms] [--json]'); process.exit(opts.help ? 0 : 2); }
const result = analyse(readEvents(opts.files, opts), opts);
console.log(opts.json ? JSON.stringify(result, null, 2) : print(result));
}
module.exports = { analyse, readEvents, print };
+1
View File
@@ -69,6 +69,7 @@ for (const [index, name] of ['Moon Garden', 'Before the Rain', 'Iron Harbor'].en
const env = { ...process.env, LIBRARY_PATH: library, DATA_PATH: data, SCRAPER_DATA_PATH: path.join(data, 'scraper'), const env = { ...process.env, LIBRARY_PATH: library, DATA_PATH: data, SCRAPER_DATA_PATH: path.join(data, 'scraper'),
SCRAPER_TOKEN_PATH: tokenFile, BACKUP_PATH: backupDir, READER_PORT: port, READER_BIND: '127.0.0.1', COOKIE_SECURE: '0', APP_IMAGE: process.env.APP_IMAGE || 'mangacircus:local', SCRAPER_TOKEN_PATH: tokenFile, BACKUP_PATH: backupDir, READER_PORT: port, READER_BIND: '127.0.0.1', COOKIE_SECURE: '0', APP_IMAGE: process.env.APP_IMAGE || 'mangacircus:local',
READER_IMAGE: process.env.APP_IMAGE || 'mangacircus:local', READER_IMAGE: process.env.APP_IMAGE || 'mangacircus:local',
GREATEST_COVER_WARM: '0', // QA stacks never download catalog covers
...(readingGateQa ? { READING_WINDOWS_JSON: '{"0":[],"1":[],"2":[],"3":[],"4":[],"5":[],"6":[]}', ...(readingGateQa ? { READING_WINDOWS_JSON: '{"0":[],"1":[],"2":[],"3":[],"4":[],"5":[],"6":[]}',
READING_TIMEZONE: 'Europe/Berlin', READING_QUIZ_UNLOCK_MINUTES: '60', READING_QUIZ_CATALOG_PATH: '/data/reading-quiz.json' } READING_TIMEZONE: 'Europe/Berlin', READING_QUIZ_UNLOCK_MINUTES: '60', READING_QUIZ_CATALOG_PATH: '/data/reading-quiz.json' }
: { READING_WINDOWS_JSON: '', READING_QUIZ_CATALOG_PATH: '' }), : { READING_WINDOWS_JSON: '', READING_QUIZ_CATALOG_PATH: '' }),
+1 -1
View File
@@ -40,7 +40,7 @@ async function freePort() {
const scraperPort = fakeScraper.address().port; const scraperPort = fakeScraper.address().port;
const child = spawn(process.execPath, ['server.js'], { cwd: root, windowsHide: true, const child = spawn(process.execPath, ['server.js'], { cwd: root, windowsHide: true,
env: { ...process.env, PORT: String(port), LIBRARY: library, DATA: data, SCAN_INTERVAL: '0', env: { ...process.env, PORT: String(port), LIBRARY: library, DATA: data, SCAN_INTERVAL: '0',
FULL_SCAN_INTERVAL: '0', SCRAPER_URL: `http://127.0.0.1:${scraperPort}`, COOKIE_SECURE: '0' }, stdio: 'ignore' }); FULL_SCAN_INTERVAL: '0', SCRAPER_URL: `http://127.0.0.1:${scraperPort}`, COOKIE_SECURE: '0', GREATEST_COVER_WARM: '0' }, stdio: 'ignore' });
let browser; let browser;
try { try {
for (let i = 0; i < 80; i++) { for (let i = 0; i < 80; i++) {