Scan IO
- The walk decides whether a path is absorbed (page of a directory book,
stray image on a shelf) before stat'ing it, replacing the separate
recursive listing of every claimed directory.
- Ancestor checks are set lookups along the path instead of prefix scans
over every shelf/claimed directory (quadratic on large libraries).
- Directory handlers may provide fingerprint(); an unchanged comic folder
is settled from its listing instead of re-parsing and re-reading its
cover every scan.
Memory
- File handlers may provide parseFile(ctx, {size, contentHash}). The
scanner then hashes the file as a stream and settles identical bytes
under a touched mtime before parsing. PDF, CBZ and EPUB implement it;
EPUB metadata/cover/spine are read entry by entry through ZipArchive
(falling back to the buffered path for archives it cannot open).
parse(buf) is unchanged for acquisitions.
Synthetic library (1k shelves, 100 comics / 20k pages, one 300MB PDF):
unchanged rescan ~3.6s -> ~0.9s; first-scan peak RSS +352MB -> +78MB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- comic-directory: `!isBookCollection()` negated a Promise, so chapter
folders inside a volume (第01卷/第001话/*.jpg) were never collected.
Await it, count only real book files as a collection (ComicInfo.xml
and other sidecars no longer veto a chapter), and keep the root's
loose-page pass non-recursive so volume pages are not counted twice.
- scanner: remember the handler that claimed a directory instead of
re-resolving it with Array.find over an async predicate; drop the
unused directory signature; list the tree once per scan and memoise
"holds a book file" across discovery and the shelf pass.
- ai: one splitPathBatches for jobs and direct scans; folders larger
than batchSize are split instead of overflowing a prompt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SwitchRow styles were scoped to `.sheet`, so on 偏好设置 › 书架 the
显示作者/显示阅读进度 switches rendered as empty transparent buttons. Unscope
them, and restore the pill over the media page's generic button rule.
主题已保存 and theme save errors were inline <p> lines; they now go through
the global notice (ui.md §3.6), one notice updated in place per panel.
ui:review captures the shelf section and measures the switches and the
floating theme notice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The real cause of the CI failure: createMemoryRouter builds a fetch Request for
every navigation, and on Node 24 (CI) undici rejects jsdom's AbortSignal
("RequestInit: Expected signal to be an instance of AbortSignal"), so no click or
Back ever navigated. Locally Node 22 accepts it. The test now mounts a plain
MemoryRouter, like every other test, and reads location / navigation type /
navigate through a probe component.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI failed every test in media-list-return.test.tsx: each ran ~550-650ms, the whole
budget of settle()'s 50 x 10ms polls, because the first render is slower on the
runner than locally. settle() now polls for up to 4s (under vitest's 5s test
timeout); media-catalog-page.test.tsx had the same helper.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 影音浏览 preferences finally take effect: cover density (phone 3/2 columns),
default catalog sort, the continue card (channel and 首页) and the 来源库
selector, read live from the per-device preferences.
- A catalog or favorites page past the end clamps to the last page instead of
showing nothing / claiming there are no favorites.
- 演播者 shows the 有声书 category tabs (shared CatalogTabs).
- The channel ⋯ menu offers 媒体库管理 only to administrators.
- History and search restore their scroll on back (also back after forward);
a detail opened from search returns to the search; browser Back between two
searches shows the earlier search again.
- The player's queue re-renders after 上移 / 移除.
- Media writes invalidate the cached queries they change (editions, metadata,
hierarchy, libraries, recognition…); item details are always rechecked, so
刷新资源状态 sees a rescan.
- Aborts surface as ApiError('aborted') everywhere (including body reads), so a
shared request aborted by one caller is retried for a live one and never shows
"signal is aborted without reason".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
外观与主题, 书架, 影音浏览 and 播放 were four stacked sections on one long page.
They are now tabs (role=tablist, arrow keys, one tab stop): only the selected
panel renders, ?section= picks it (外观与主题 when absent) and switching replaces
the history entry, so back still leaves for 我的.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every script under web/tools and web/tools/ui-review had drifted from the app
(login lands on 首页, AppNav instead of the channel menu, 我的 pages instead of
sheets, query-string paging and folders, the redesigned plugin, source and
library screens). Each is updated to the current flows; checks for features that
no longer exist are removed (the v2 settings home and media palettes, the
channel menu, the 刷新 button, debounced library search, the sheet-based 添加书源
and 书源登录, the per-script track filters, …).
media-smoke and media-review-smoke run to the end but still fail on genuine app
regressions they record (listed in their output) rather than hiding them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 偏好设置 › 打开播放器 always opened the video player; it now opens the playing
channel's player (playerPath, shared with the mini player's link).
- The player's panel and back navigation dropped item/part from the address, so a
refresh could not restore playback; the address now follows the playing part, and
a restoring link shows 正在恢复播放… instead of flashing 当前没有播放内容.
- A link that cannot be restored no longer leaves another video expanded over its
explanation; the other playback stays in the mini player.
- The first category tab of 音乐 and 有声书 was never marked current (the bare
channel path redirects to it).
- A navigation query shared by two callers handed the first caller's abort to the
second (a detail opened right after login showed "request aborted").
- The metadata and match pages' fixed header covered the sidebar at >=701px.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A book downloaded for offline reading did not open offline: nothing ever writes
books into the offline mirror, so the fallback found nothing. The reader page now
falls back to the book record the download stored in its manifest.
- 书源登录 reopened on the cached credential status after saving; saving now
refetches that source's status.
- A newly installed/upgraded/enabled/removed plugin's source types did not appear
in 添加书源 for up to five minutes; plugin changes now refetch the type list.
- The extension page did not scroll its selected tab into view on narrow screens.
- The shelf settings sheet's sticky headings stopped 14px below the header again
(the padding rule was lost when .panel-body became .sheet-body).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
我的 mixed sheets (书架显示, 系统设置, OPDS nested in the shelf sheet) with pages
(外观与主题, 影音浏览, 播放), and the personal preferences were four separate rows.
- 偏好设置 (#/me/settings) holds 外观与主题, 书架, 影音浏览 and 播放 as sections of
one page, with a jump bar and ?section=; every control applies at once (影音浏览
loses its save button and unsaved-changes prompt). The shelf's gear still opens
the same fields (ShelfSettingsFields) as a sheet shortcut.
- 系统设置 becomes three admin pages of 我的 › 管理: 用户管理, 注册与邀请, 服务配置
(#/me/admin/*, lazily loaded).
- 连接外部阅读器 (OPDS) is a page (#/me/opds) under 账号, also reached from the
书架 section and the shelf sheet; back returns to whoever opened it.
- Old /me/theme|browse|playback and /media/:channel/settings[/:panel] redirect to
the matching section; /me/account keeps its page.
- Drops MediaSettingsPage and the CSS only it used; docs follow the new paths.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Books, video and audiobooks in 「继续」 are the same card (cover, work title,
episode/chapter, progress), most recent first, up to two books. An episode
resolves to its series for title and poster instead of reading "第 1 集".
- New rows 看点什么 / 听本书 / 听张专辑, each switching between 最近添加 and
随便看看 (with 换一批); hidden when the channel is empty or unavailable.
- Server: browse accepts sort=random and sort=added (newest available file
mtime across the work and its episodes/tracks; no schema change).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page borrowed media-utility-workspace, whose layout expects the settings
screens' own .media-settings-heading, so the frame's header was left unstyled:
on desktop it sat ~100px down inside the content column instead of pinned at
the top like 影音浏览 and 播放历史. It is a content page, so it now wears
media-secondary-page as 播放历史 does.
The React UI review also stubs /api/v1/ai/capabilities, which 扫描与刮削 asks
since the AI-switch change and the media fixture does not serve.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main.ts registers the routes with a placeholder context and attaches the
services afterwards, so the stats route captured ctx.shelf while it was still
undefined and every summary failed with "Cannot read properties of undefined
(reading 'getMany')". Writes never touched the shelf and were unaffected. The
service is now built on first request; a new test builds the app the way
main.ts does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/api/v1/ai/capabilities now also answers `scan`: 启用 AI and 启用 AI 扫描 on and
Base URL, key and model filled in. 扫描与刮削 shows the AI 扫描 tab only then; while
the answer is unknown there is no tab, a ?tab=ai link falls back to 媒体库扫描,
and a server without the endpoint keeps the tab as before. The answer is
re-asked each time the page opens, so a switch changed in 系统设置 takes effect
on the next visit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reading time is counted by the reader: only while the page is visible and the
reader has turned, scrolled or tapped within three minutes, or while 朗读 plays.
It is kept per book per 15-minute UTC slot and per device install, saved
locally until sent, and stored as MAX(stored, reported) in the new
reading_time table, so a replayed report never counts twice.
Media time is derived on the server from the progress saves the web player and
Android Media3 already send: the smaller of the wall-clock gap and the position
advance between two saves of one session, gaps over 30s ignored. It is stored
in media.db (media_watch_time), so neither client changes.
我的 → 时长统计 shows a stacked bar per day (one month), month (one year) or
year, with a legend that filters by kind, a table view and the works ranked for
the range or the bar picked. Administrators also get every account's totals.
Chart colours are new mode-level tokens (--color-chart-1..4), validated for
colour-vision separation on both surfaces. The React UI review now covers the
page and names the overflowing elements when a screen overflows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The two-way 左退右进 / 左进右退 switch becomes nine cells, each 上一页, 下一页,
菜单, 上一章, 下一章 or 无操作, edited as a small page in 阅读设置 → 翻页 with
左右互换 and 恢复默认. The default grid is the old standard layout and a stored
`tapZone: 'reversed'` is folded into its grid. At least one 菜单 cell is kept,
right-to-left books mirror the grid, and the gesture layer now reports the row
as well as the column, from layout geometry like before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tapping the title opens 书籍信息, which now ends with 阅读记录: the places this
account has been in the book on any device, newest first, each with its
chapter, percentage, device and time. The position on screen is pushed
before the list is read, so its entry is marked 当前; pressing another entry
goes back there and saves it as the position. Offline the section says the
history cannot be read; a failed read offers 重试.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
reading_progress keeps one row per book and last-writer-wins, which answers
"where am I" and loses "where was I": a stale device, a mistapped 目录 entry
or a clock running ahead overwrote the place for good (P0: 进度历史).
Every valid progress write now also lands in reading_progress_history,
including a write that lost the merge. A device reading on through one
chapter updates its latest entry (same chapter, within 5% and 30 minutes)
instead of adding rows; another chapter, a jump, another device or an
out-of-order write is a new entry. The newest 50 per book are kept.
GET /api/v1/sync/progress/:bookId/history lists them, newest first, for the
signed-in account only.
The table is new (CREATE TABLE IF NOT EXISTS), so the schema change is
additive and older builds sharing the database ignore it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Taps and keys already crossed chapters, but a reader scrolling by hand reached
the last line and had to find 下一章 (#702, #698). A touch pull of more than
72px that starts at the end of a chapter, or wheel scrolling past it, now
opens the next chapter; the same at the top goes back. Only a gesture that
begins at the edge counts, so arriving at the end does not turn by itself,
and a cooldown keeps trackpad momentum from turning a second chapter. Touch
events are used because the browser cancels pointers once it scrolls.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Leaving the reader left the new position to the sync debounce, and 首页's
继续阅读 and the shelf's 最近阅读 order were served from a query cache that stays
fresh for a minute, so coming back from a book showed the old chapter and
percentage (#663). Closing the reader now saves the position, pushes the
outbox, and then re-reads the home and shelf queries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
a894bdd deleted .action-menu-item/-label/-icon/-check/-description and the
.sheet-list-icon/-check/-description rules as unused, but ActionMenu builds
those names as `${base}-item` (base 'action-menu' or 'sheet-list'), which the
dead-class scan did not recognise. Every ⋯ dropdown and its bottom-sheet form
lost their row layout, hover, danger colour and check mark. Both files are
restored to their state before that commit.
The class-usage test now covers every stylesheet (not only media) and
understands built names: `${expr}-suffix` with a literal prefix, and
`prefix-${expr}`. Library markup (Notyf, DPlayer) is exempt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
32 class names in reader.css, action-menu.css and sheet.css had no reference
in the source (the old quick-settings row, manager screen and selection bar,
catalog source heading, source capabilities and editor, action-menu and
sheet-list item parts, …); their rules, or their members of selector lists,
are removed. Classes built at runtime (indicator-*, reading-indicator-*) and
Notyf's own are kept.
The reader review no longer checks for a .library-preview-hint the browsing
page stopped drawing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LoadingPage and isApiError; the media search's back/forward restore
(restoreSearchHistory, its history tagging and saved-entry map,
clearSearchReturn, restoreSearchReturn), which nothing ever called — the
in-memory return to search still works; SeriesPlayback and its styles;
useMediaRequest, useMediaDetail, useMediaPlayback and searchQuery; and the
short route aliases at the end of MediaItemManagementPages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An import graph from main.tsx shows a previous reader still in the tree:
render/{reader,paged,pdf,text,container,theme}.ts with lib/{events,locator}.ts,
ui/settings.ts and bridge.ts — superseded by ReaderScreen/ReaderView and
never imported. Also unreachable: render/paginator.ts, render/window.ts and
ui/shelf-order.ts (only their own tests used them; the shelf sorts on the
server), formats/remote-zip.ts (re-exported, never called; chapters are
fetched per section), ui/source-capabilities.tsx and the sources pages barrel.
Inside live modules: PrefetchCache (only shouldPrefetch is used),
textToParagraphs / paragraphElement / endsAtParagraphBreak (the streaming
TXT reader's carry), txt.ts's textToHtml alias, and ChunkCursor. Their tests
go with them; the node:test list, the Vitest excludes and the docs that
named these files are updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The UI draws Lucide SVGs through ui/icon.tsx and has not loaded the icon
font for a while; the font, its code-point table and the generator only fed
their own tests. Removed: tools/icons (generator, glyph paths, stroker),
src/styles/reader-icons.ttf, src/ui/icon-names.ts, the `icons` npm script and
the font/metrics tests. icons.test.ts keeps the contracts that still apply:
no borrowed names (now checked against ICONS), only icon.tsx imports
lucide-react, the .icon rule and icon buttons. The .icon rule drops its
font-era declarations.
ICONS loses the names nothing draws (the Font Awesome spellings kept as
aliases, add-circle, film, line-height, indent, sign-out); the reader's 朗读
button is `read-aloud` rather than `volume`. Docs and CLAUDE.md describe
adding an icon in icon.tsx.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>