# Roadmap & Future Features
Forward-looking ideas for the player. Grounded in what is already built (see
`src/`) and in what a real ~6,300-book library on this machine actually contains,
so the suggestions are concrete rather than aspirational.
Each item is tagged with rough effort — **S** (hours), **M** (a day or two),
**L** (several days) — and a note when it also resolves a known limitation from
the README.
Book counts quoted below vary (~6,300 in older entries, 5,825 today) because
the library itself changed — duplicate detection alone found 468 removable
copies. Each figure is what was actually measured at the time, so they are
left as written rather than retro-fitted to today's number.
---
## Where the app stands today
Already shipped, so it is not repeated in the lists below:
- Library scan with book grouping (single `.m4b`, `.mp3` folders, disc merge,
numbered-part merge by duration), size+mtime cache.
- Custom MP4 chapter parser for QuickTime text chapter tracks (`mp4-chapters.js`).
- Multi-track unified timeline, chapter navigation, 30s skip, 0.75–3× speed,
volume, per-book resume, keyboard shortcuts.
- `ab-media://` protocol with byte-range seeking; cover extraction with folder
fallback; paged grid rendering.
- **Sleep timer** — fixed duration / end-of-chapter / end-of-book, with a 20s
volume fade, 30s rewind on resume, and a "+5 min" extend (Tier 1 #1, shipped).
- **Bookmarks** — named marks with notes, jump-to, list view in the book detail,
and a rolling "last stop" auto-bookmark on manual pause (Tier 1 #2, shipped).
- **Skip silence** — a Web Audio analyser detects sustained quiet gaps and
briefly boosts playbackRate through them (Tier 1 #3, shipped).
- **Volume normalization** — measures each book's loudness on first play and
applies a gain toward a common target via a Web Audio graph, on by default
(Tier 1 #4, shipped).
- **Continue-listening shelf + filters + sort** — in-progress books surfaced at
the top of the library, most-recent first; All / In progress / Finished / Not
started tabs; sort by author/title/added/played/duration (Tier 1 #5–#8,
shipped).
- **Series grouping** — a toggle collapses a series' volumes into one tile with a
drill-in series view; series name + index parsed from the title, author-guarded
against franchise over-grouping; renderer-only, no re-scan (Tier 1 #7, shipped).
- **Light theme** — follows the OS by default; a top-bar toggle overrides it,
applied before paint so there's no flash on launch (Tier 1 #9, shipped).
- **Library folder management, safety & polish (Tier 1 #10–17, all shipped)** —
a Folders panel to view/remove library folders (plus drag-and-drop to add
one); an Undo toast on Reset Progress and bookmark delete instead of a
blocking confirm, since both are reversible; backup/restore of
progress+bookmarks+normalization to a sibling folder so deleting the data
folder can't take backups down with it; a customizable skip amount for the
↺/↻ buttons; a manual "mark as finished/not finished" override; a chapter
list search box (validated against the real 212-chapter *Wind and Truth*);
and a "NEW" badge on books/series added since you last opened the app.
- **Online metadata lookup** — an opt-in, Open Library–backed "Look up online"
button in the book view corrects title/author/description and fetches a
high-res cover, cached locally so the app stays offline-first after applying
(Tier 2 #5, shipped). Series-splitting from the original ask was descoped —
see the item for why.
- **Windows installer + in-app updates** — an NSIS installer/uninstaller
(`electron-builder`, standard per-user default location) built and
published to GitHub Releases by CI on every version tag. **Help → Check for
Updates…** checks Releases manually (never automatic), auto-downloads a
newer version in the background, and shows its change notes pulled from
`CHANGELOG.md` (Tier 1 #18, shipped). Shipped 2026-07-22.
- **Windows SMTC + taskbar integration** — the media flyout, lock screen, and
hardware/keyboard media keys via `navigator.mediaSession`; taskbar
thumbnail-toolbar buttons (prev/play-pause/next chapter); a jump list of
recently-played books, backed by a proper single-instance lock (Tier 2 #2,
shipped). Shipped 2026-07-22.
- **Discord Rich Presence** — an opt-in topbar toggle shows "Listening to
*\
* — Ch. N" on Discord, updating on chapter changes and
play/pause; off by default since it reports externally. Wraps
`@xhayper/discord-rpc` defensively — a stuck/failed connection (Discord not
running is the common case) is bounded to a few seconds with a retry
cooldown, since the library doesn't reliably fail fast on its own (Tier 2
#7, shipped). Needs a Discord Application ID (`DISCORD_CLIENT_ID`) to
actually activate; inert without one. Shipped 2026-07-22.
- **Local Whisper transcription + search** — opt-in, per book: "Transcribe
this book" runs `ffmpeg-static` + `@kutalia/whisper-node-addon` fully
offline (GPU via Vulkan auto-detected, CPU/BLAS fallback), then "Search
transcript" jumps straight to any spoken line, and a captions toggle shows
the current line live (Tier 2 #1, shipped). Native-binary packaging through
asar was the real risk here, not transcription itself — verified against
an actual packaged build before shipping. Per-chapter summaries descoped
(needs its own model/approach). Shipped 2026-07-22.
- **Duplicate book detection** — **File → Find duplicate books…** groups the
already-scanned library by title+author, splitting into distinct
recordings by matching duration/track count so genuinely different
narrations are never confused with real duplicates (Tier 2 #9, shipped).
Removal goes to the Recycle Bin, one book's own files only. Validated
against this library: 376 titles, 468 removable copies found. Shipped
2026-07-22.
- **Reorganize library by author** — **File → Reorganize library by
author…** previews a move of every book into `//
/` before touching disk, moves only on explicit confirm, and
journals every individual move so **File → Undo last reorganization…** can
reverse the whole run. A shared folder (unrelated books filed side by
side) only has its own book's files moved, never the whole folder. Since
book ids are derived from file path, execute/undo both carry progress,
bookmarks, normalization, metadata overrides, and transcripts to a moved
book's new id rather than orphaning them (Tier 2 #10, shipped). Verified
against synthetic fixtures before ever touching real files, then
hands-on confirmed against the real library, including undo. Shipped
2026-07-23.
- **Voice Boost EQ** — the 🎚 button (`V` toggles) runs a ~100Hz highpass plus
a ~2.8kHz presence-peak `BiquadFilterNode` pair, spliced into the same Web
Audio graph as skip-silence/normalization, to keep dialogue intelligible
at 2.5–3× where deep-voiced narration turns muddy (Tier 2 #8, shipped).
Off by default; ramps smoothly rather than snapping. Shipped 2026-07-23.
- **Two-phase library scanning** — a scan shows the grid much sooner by
deferring cover art and (for single-file books) chapter extraction to a
low-priority background pass, with an on-demand fast-track for whatever
book you open first (Performance & architecture #5, shipped). Confirmed
faster hands-on against the real ~6,300-book library. Shipped 2026-07-23.
- **Read along (EPUB)** — pairs an EPUB with its audiobook and shows the
matching chapter's text in a book-view side panel, opened via a per-book
**Read along** button (auto-pairs by folder proximity, or pick manually);
a background pass checks the whole library over time, powering a **Has
ebook** filter and card badge. Auto-advances chapters when confident and
estimates paragraph position from time elapsed — real word-level sync
needs the Whisper transcript aligned to text and is future work (Tier 2
#6, shipped, EPUB only). Confirmed working hands-on, including the
paragraph-position estimate. Shipped 2026-07-23.
- **SQLite library store** — `library.json` became `library.db`
(`src/main/db.js`), migrated automatically on first launch, old file kept
as `.bak`. Per-row writes replace whole-file rewrites (Performance #1,
backend swap shipped). Shipped 2026-07-24.
- **Virtualized grid + cover thumbnails** — the grid renders only what's near
view, and loads ~200px cached thumbnails instead of full-size covers.
Shipped together deliberately: virtualizing alone made scrolling back into
a visited section *slower* (Performance #2/#3, shipped). Shipped 2026-07-24.
- **Listening statistics & streaks** — a Stats view with total time listened,
books finished, current day streak, top authors/narrators, and a 12-week
pace chart, backed by a new day-keyed `activity.json` (Tier 2 #3, shipped).
Streak/pace accumulate from install forward; there was no history to
backfill. Shipped 2026-07-24.
- **Scan reliability & speed on large libraries** — a scan could spike CPU and
hang until Windows killed the app. Fixed by O(1) per-book database updates
(was O(total books)), coalescing progress IPC (5,825 renderer wakeups down
to 93), a directory-mtime fast path that skips per-file checks for unchanged
books (~80,000 file checks down to ~5,800), yielding to the event loop so a
fast scan can't starve the UI, and guarding background failures so they
can't terminate the app. Also added `diagnostic.log` for crash/hang
forensics. Shipped 2026-07-25.
- **Delete / remove library entries** — two per-book actions, available both
in the book detail view and via right-click on a library card: **Remove
from library** (drops the book's data only, files untouched) and **Delete
book (files too)** (also trashes its files). Both fully purge every
per-book record tied to that id — progress, bookmarks, normalization,
online-metadata override, ebook pairing, and transcript — so nothing
orphaned lingers behind, unlike the existing duplicate-removal path (which
deliberately leaves those alone, since a sibling copy under the same id
namespace might still need them).
File deletion always goes through the Recycle Bin, never a permanent
delete. What gets trashed depends on whether this book exclusively owns
its containing folder — checked against the whole library (`dirCounts`,
the same check `reorganize.js` already uses), not assumed from the book
alone: if so, the whole folder goes in one shot, sweeping up any leftover
cover art or notes that a file-by-file approach would otherwise leave
behind and treat as "something else is still here"; if the folder is
shared with sibling books (confirmed real in this library: a "Radio and
Podcast Production" folder holds four separate single-file books side by
side), only this book's own track files are touched, and the folder is
removed afterward only if it ends up completely empty. A paired
read-along ebook living inside the book's own folder is trashed
alongside it either way; one picked from elsewhere on disk (the ebook
picker can point anywhere) is never trashed — only its pairing record is
dropped.
*Tested:* real, disk-touching verification rather than mocks throughout —
a real audio fixture actually moved to the Recycle Bin and confirmed gone
from its original path; all five per-book stores purged and reread from
disk (not just memory) to confirm the target book's entries are gone
while an unrelated book's survive untouched; the exclusive-vs-shared
folder split confirmed against real directories (solo book → folder
removed; shared folder → sibling's file and the folder itself survive;
leftover unrelated file → folder survives); and the co-located-vs-external
ebook split confirmed the same way, including that a book exclusively
owning its folder still leaves an external ebook alone even though the
whole audio folder gets trashed. The right-click context menu itself was
verified end-to-end against a real rendered grid: opens positioned at the
cursor, dismisses on outside-click/Escape, and sends the correct book id
and files-or-not flag through the same deletion path. Shipped 2026-07-29.
Known gaps carried forward as motivation: series volumes can share a display
title, box sets stay whole, and merged `.m4b` parts collapse to one chapter each.
---
## Tier 1 — Parity features users expect (and we lack)
These are table stakes across Smart AudioBook Player, BookPlayer, Listen, and
Prologue. Their absence is the most likely reason someone would keep another app
open alongside this one.
### 1. Sleep timer — **shipped** ✅
End-of-chapter, fixed duration, or "end of book"; a 20s volume fade rather than a
hard stop; 30s rewind on resume; "+5 min" extend. Renderer-only (`app.js` timer
driving `el.audio.volume`, control in the player bar, `T` to open the menu).
*Still possible:* a true system-wide hotkey and tray "+5 min" (needs main-process
`globalShortcut` / `Tray`), deferred to keep this renderer-only.
### 2. Bookmarks with notes — **shipped** ✅
Named bookmarks with an optional note, a list view in the book detail (rename,
note, jump-to, delete), and a single rolling "last stop" auto-bookmark dropped on
manual pause (editing it makes it permanent). Persisted in `bookmarks.json` via
the same `JsonStore`. Still the foundation for clip export (Tier 2).
*Possible next:* a bookmark count/indicator on library cards, and global
cross-book bookmark search once the data layer moves to SQLite.
### 3. Skip silence / "smart speed" — **shipped** ✅
Real-time: the `` is routed through a Web Audio `AnalyserNode`, and a
sustained quiet gap (RMS below ~0.01 for ≥0.7s) briefly boosts playbackRate
(base × 3, capped at 4) so the gap passes fast, snapping back on speech. Toggle
with the ⏩ button or `S`; stacks on per-book speed.
*Two things that made it work:* the `ab-media://` responses needed
`Access-Control-Allow-Origin` + `crossOrigin='anonymous'` or the analyser reads a
tainted all-zero stream; and the detection loop must be a `setInterval`, not
`requestAnimationFrame` — rAF is paused when the window is hidden, but a
backgrounded audiobook still needs to skip silence (timers aren't throttled while
audio plays).
*Tuned after real-world use:* the original 0.2s entry threshold was catching a
narrator's mid-sentence breathing pause (still talking, just inhaling) as dead
air, boosting speed for a moment in the middle of a sentence — audible as
choppiness. Raised to 0.7s, comfortably past a normal breath/phrase pause
while still catching genuine gaps (a beat between chapters, a rough edit).
*Still possible:* an offline silence-span pass for exact glitch-free jumps, and a
sensitivity/adaptive-threshold control; both pair with auto-chapter generation
(Tier 3 #3).
### 4. Volume normalization / loudness leveling — **shipped** ✅
Each book's gated RMS loudness is measured over ~30s on first play (the real
library spans ~7 dB), a gain toward a common target (−19 dBFS) is computed —
clamped ±12 dB and peak-limited so a boost can't clip — stored in
`normalization.json`, and applied instantly thereafter via a Web Audio gain node.
On by default; ⚖ / `N` toggles.
*Refactor it drove:* volume moved off `el.audio.volume` into a graph gain node
(`source → analyser → normGain → volumeGain → out`) so the analyser always sees
full-scale audio — which also made skip-silence volume-independent.
*Still possible:* true EBU R128 (K-weighting/gating) instead of gated RMS, and an
offline scan at import so the very first play is normalized too.
### 5. Per-book & persisted playback speed — **shipped** ✅
Each book stores its last speed in `progress.json` and restores it on open
(`defaultPlaybackRate` is set so it survives multi-track boundaries).
*Possible next:* a per-narrator default once narrator metadata is reliable.
### 6. Auto-rewind after pause — **shipped** ✅
On resume, rewinds a few seconds scaled to how long you were paused (0 under 30s,
3s, 10s, up to 20s after an hour+). Kept separate from the sleep timer's fixed
30s resume-rewind.
### 7. Library organization: sort, filter, series & collections — **shipped** ✅
Filter tabs, **sort** (author / title / recently added / recently played /
longest / shortest), and **series grouping** (collapse a series' volumes into one
tile, with a drill-in view) all ship. **Still open:** better series coverage —
title-parsing groups ~30% of books (~360 series); the misses are un-numbered
series (Dune's prequels, standalone novellas) and folder-numbered books whose
title omits the series — those want the sidecar/online metadata below.
### 8. "Continue listening" shelf + finished state — **shipped** ✅
In-progress books surface in a row at the top of the library, most-recently-played
first, hidden while searching or filtering. Finished state drives the filter tabs.
### 9. Light theme + theme toggle — **shipped** ✅
A light palette under `:root[data-theme="light"]`, mirrored in a
`prefers-color-scheme: light` media query for the system-default case. The eight
places that had hardcoded colors (button hovers, scrollbar, bookmark-delete,
etc.) were pulled into variables too, so the whole UI actually re-themes, not
just the parts that already used variables. The ☀/☾ button in the top bar
overrides the OS choice and persists it; a tiny CSP-safe `theme-init.js` applies
a saved override before the body paints, so there's no flash of the wrong theme.
### 10. Library folder management (view + remove) — **shipped** ✅
A **Folders** popover in the top bar (replacing the old standalone "Add folder"
button) lists every library folder with its live book count and a ✕ to remove
it — finally calling the `library:removeFolder` IPC that had been fully wired
end-to-end but dead (no UI caller) since the very first commit. Removing a
folder asks for confirmation via a native dialog first, since it can silently
drop thousands of books in one click — more destructive than a typical action,
so it didn't wait for the general confirm-before-destroy treatment (item 11).
*Bug fixed along the way:* the folder→book match on both the removal path and
the new book-count computation used a naive `startsWith`, which would wrongly
match a folder like `E:\Books\Fan` against books actually under
`E:\Books\Fantasy`. Replaced with a path-boundary-safe check
(`isUnderFolder` in `main.js`), unit-tested directly.
### 11. Confirm before destructive actions — **shipped** ✅
Went with the **Undo toast** option rather than a blocking `confirm()` dialog —
both actions are frequent and fully reversible (unlike removing a folder, which
can't be undone without a 40-minute rescan and got a native confirm instead), so
a dialog on every click would have been needless friction. "Reset progress" and
a bookmark's 🗑 now act immediately and show a 6s "Undo" toast; clicking it
restores the **exact** prior state — same position, speed, and (for bookmarks)
the same id/label/note/createdAt, via a new `bookmarks:restore` IPC rather than
re-adding as a fresh bookmark. A second toast silently replaces a pending one
(matching Gmail-style undo conventions); `Escape` dismisses it early.
### 12. Backup / export of app data — **shipped** ✅
**File → Backup data…** / **Restore from backup…**, next to the existing "Open
data folder". Bundles the three stores into one timestamped JSON envelope rather
than a real zip — no archive library needed, and it stays human-inspectable —
defaulting to `%APPDATA%\Midnight Athenaeum Backups`, a **sibling** of the data folder so
deleting/corrupting the live folder can't take the backup with it. Restore reads
and validates the file, shows what it contains (counts, backup date) in a native
confirm dialog, and only applies on confirmation — this replaces current data and
can't be undone, so it got the same treatment as folder removal rather than the
Undo-toast treatment (items 10/11), which only fits reversible actions.
*Tested:* the bundle/validate/restore logic directly against throwaway temp
files (16 checks — round-trip fidelity, rejection of a wrong app name, a missing
section, and an unrelated JSON file) rather than the real data folder, since the
native save/open dialogs this feature triggers can't be driven through the
CDP-based testing used elsewhere in this project.
### 13. Customizable skip amounts — **shipped** ✅
A **Skip** dropdown next to Speed (10/15/30/45/60s, 30 default) drives the ↺/↻
buttons and the plain arrow-key shortcuts; the buttons' own labels update to
match so they never show a stale amount. Persisted, with a safe fallback to 30
if the stored value is ever invalid. `Shift`+arrow's 5-minute jump is a
deliberately separate, fixed "big skip" — not part of this setting.
*Tested:* 18 checks including that a reload picks the persisted value back up
immediately (no stale button label before the first click) and that a corrupted
localStorage value falls back safely rather than breaking the buttons.
### 14. Manual "mark as finished / not finished" — **shipped** ✅
A toggle button in the book view, next to "Reset progress", covers both cases:
a book finished elsewhere (marks finished with zero listening position recorded
— the card still shows a full progress bar rather than a confusing empty one),
and a DNF'd book stuck near the end that you want out of "In progress" (marks it
explicitly not-finished, which beats the auto-computed value).
Stored as `finishedOverride: true | false | null` alongside the existing
`finished` (auto-computed) field — `null` means "let position/duration decide,"
which is what every book starts at. The tricky part was making sure the override
actually survives: the periodic auto-save that runs during normal playback
rewrites the whole progress record every few seconds, so it had to be explicitly
carried forward there or a manual mark would silently vanish mid-listen. The
"Reset progress" Undo (item 11) needed the same care — restoring only
position/duration/speed and not the override would have "un-undone" a finished
mark that was in effect right before the reset.
*Tested:* 27 checks — including the override surviving a routine position
auto-save, and Undo-after-Reset restoring the override together with position,
verified round-tripped through the backend rather than just in-memory state.
### 15. Chapter list search — **shipped** ✅
A search box above `#chapterList`, filtering by title text or by chapter number
(so typing `150` finds chapter 150 directly). Rows keep their original array
index (`dataset.index`) even while filtered, so the existing active-chapter
highlight and click-to-seek logic needed no changes — filtering only ever hides
rows, never renumbers them. `Esc` clears the query first, then un-focuses on a
second press, without leaving the book view.
*Tested against the real 212-chapter Wind and Truth*: number search, title-text
search (verified zero false positives — every rendered row actually contains the
query), the no-match state, clicking a filtered row still seeks to the right
spot, the active-chapter highlight surviving a re-filter, and the search
resetting when a different book is opened.
### 16. Drag-and-drop to add a folder — **shipped** ✅
Drop a folder on the window; a dashed-border overlay shows while dragging, and
it's added the same way the Folders panel's own "Add folder" would be. Rejects
non-folder drops (e.g. an individual file) with an info toast instead of
silently doing nothing.
Needed more than a bare `dragover`/`drop` handler: `File.path` was removed from
the renderer in recent Electron for security, so the dropped item's real
filesystem path has to come from `webUtils.getPathForFile()` — callable from
preload (even under `sandbox: true`, where it's explicitly still exposed) and
bridged to the renderer. `library:addFolder`'s "merge into the folder list and
rescan" logic was factored into a shared `addFoldersToLibrary()` so a new
`library:addFolderPaths` IPC could reuse it without the file-picker dialog,
validating server-side that each dropped path is actually a directory rather
than trusting the renderer.
*What I could verify vs. couldn't:* the overlay's show/hide (including that
nested dragenter/dragleave pairs from crossing child elements don't flicker it),
and the full `addFolderPaths` → validate → add → rescan-once-if-new path against
real directories on disk, including that re-dropping an already-added folder
adds nothing and doesn't trigger a redundant rescan. What's *not* verified by
an automated test: `webUtils.getPathForFile()` resolving a genuine OS drag's
path correctly, since that requires real native drag data no CDP-based
automation can produce — confirmed instead by checking the known Electron
regression here (electron/electron#44600) is macOS-specific and closed; this
app targets Windows.
### 17. "Recently added" indicator on cards — **shipped** ✅
A small accent "NEW" pill on a book's cover for anything added since the last
time you opened Midnight Athenaeum — top-right on a plain card, top-left on a series
tile (opposite corners from the existing volume-# and count badges, so nothing
collides). A series tile shows NEW if *any* volume inside it is new; without
that, a newly added volume of a series you already own would be invisible
behind the tile whenever Group Series is on — which would have undercut the
whole point for exactly the constant-growth libraries this was aimed at.
The threshold is "the last time the app was opened," read once at load and
immediately overwritten with the current moment for next time — so badges from
this session stay put for the whole session (they don't vanish the instant you
glance at a card) and clear on the *next* launch, not this one. A first-ever
install has no stored threshold and defaults to "now," so a fresh library
doesn't badge all 6,000+ books as new.
*Tested:* 13 checks — the first-launch default, a real book flipping from NEW
to not-NEW as the threshold crosses its actual file mtime (and back), the
series-tile aggregation, and confirmed live on screen (not just via computed
style, which returns empty strings for a detached element) that the volume-#
badge and the NEW badge render on opposite corners of the same card without
overlapping.
### 18. Installer + in-app updates — **shipped** ✅
An NSIS installer/uninstaller (`electron-builder`, standard per-user default
location, desktop + Start Menu shortcuts) built and published to GitHub
Releases by CI on every version tag. **Help → Check for Updates…** checks
Releases manually — never automatic, never on launch — auto-downloads a
newer version in the background, and shows its change notes pulled straight
from `CHANGELOG.md` (`scripts/extract-changelog.cjs`). Restarting to install
runs `quitAndInstall()` silently (no interactive wizard) and relaunches the
app automatically once done.
*Still possible:* an optional "check on launch" setting for users who'd
rather not remember to check manually, and a macOS `dmg` build once that
port exists (`electron-builder`'s config already separates `win`/`mac`
targets, so this is additive, not a rewrite).
---
## Tier 2 — Differentiators (rare or absent in Windows players)
Where this app can be better than what exists, not just equal to it.
### 1. Local full-text search inside audiobooks (Whisper) — **shipped** ✅
Per-book, opt-in "Transcribe this book" (book view) runs entirely offline:
`ffmpeg-static` converts each track to 16kHz mono PCM, `@kutalia/whisper-node-addon`
(prebuilt whisper.cpp bindings, GPU via Vulkan auto-detected with a CPU/BLAS
fallback) transcribes it, and multi-track books get per-track timestamp
offsets merged into one flat transcript. "Search transcript" finds matching
lines and jumps straight to that moment; a captions toggle shows the current
line live while that book plays. One book transcribes at a time; the ~148MB
English model downloads once, on first use, into the data folder.
Packaging native binaries through Electron's asar turned out to be the real
risk, not the transcription itself — `require()`-loading a native `.node`
addon from inside `app.asar` works transparently, but `child_process.spawn()`
does **not**: it needs a real path and fails silently (`ENOENT`) on the
virtual asar one. Fixed by rewriting the ffmpeg path to `app.asar.unpacked`
before spawning it, verified against an actual packaged build (not assumed)
before shipping. GPU acceleration, multi-track offset math, and the full
pipeline were also verified end-to-end against real generated speech audio
before any UI was built on top of them.
*Descoped from this pass:* per-chapter summaries (Whisper transcribes, it
doesn't summarize — a real feature, needs its own model/approach decision).
*Foundation for:* semantic bookmarks, "quote this passage", accessibility.
### 2. Windows System Media Transport Controls (SMTC) — **shipped** ✅
The Windows media flyout, lock screen, and hardware/keyboard media keys
(play/pause, next/prev chapter) now work via the standard `navigator.mediaSession`
web API — Chromium wires this to SMTC on its own, no native module needed.
Title/author/cover show as the metadata; the subtitle updates live to the
current chapter as playback moves through the book. `setPositionState` keeps
the flyout's own seek bar in sync.
Also added while in this layer, both native Windows Shell features with no
mediaSession involvement of their own: **taskbar thumbnail-toolbar buttons**
(prev/play-pause/next chapter, shown on the taskbar button's hover preview —
flat glyph icons generated procedurally in `scripts/make-media-icons.cjs`,
same draw-big-downsize-for-anti-aliasing trick as the app icon), and a
**jump list** of recently-played books (right-click / Start tile), which
required adding a proper single-instance lock so clicking a jump-list item
focuses the running window instead of opening a second one.
*Tested:* the mediaSession wiring end-to-end via CDP (metadata incl. real
cover artwork decoding, `playbackState` sync, live chapter-subtitle updates,
the actual code paths every action handler calls), confirmed identical
behavior in a real installed build, and confirmed the thumbbar registers
successfully with the OS (`setThumbarButtons` accepted, no rejection). The
jump list's `setJumpList` call was confirmed to *fail* with exactly the
expected `customCategoryAccessDeniedError` on this dev machine (Windows'
"show recently opened items" privacy setting is off here) and then, with that
setting temporarily flipped on and immediately reverted back, confirmed to
*succeed* — proving the code path itself is correct independent of the local
privacy setting. Also verified end-to-end: launching a second process with
`--open-book=` (what a jump-list click does) is blocked by the new
single-instance lock and correctly focuses the running window on that exact
book instead of opening a duplicate one.
### 3. Listening statistics & streaks — **shipped** ✅
New Stats view (topbar bar-chart icon): total time listened, books finished,
current streak, top-5 authors/narrators, and a 12-week pace chart. The
premise that `progress.json`'s timestamps "already capture most of the raw
signal" didn't hold up on inspection — it's a per-book snapshot overwritten
on every save, no history — so this shipped with a new minimal `activity.json`
store (`{ [date]: secondsListened }`, nothing richer) and real wall-clock
listening-time tracking piggybacked onto the existing 5-second progress-save
cadence rather than a new timer. "Books finished" and "top authors/narrators"
needed no new tracking — both computable from data already sent to the
renderer. Streak/pace can only accumulate from this version forward; there's
no way to backfill history that was never recorded.
Two real bugs caught before shipping: an early design would have counted
phantom listening time if a pause/seek/speed-change fired while already
paused (fixed with a play-state gate checked before any state mutation), and
a genuinely pre-existing bug found along the way — `pairingStore` (ebook
read-along pairings) was never actually loaded from disk at startup, so
every launch silently re-scanned the whole library and could overwrite
manually-set pairings.
### 4. Bookmark clips: export & share cards — **M**
Turn a bookmark span into a short audio clip (via ffmpeg) or a shareable image
card with cover + quote + timestamp. Differentiating and delightful; depends on
bookmarks (Tier 1) and optionally transcripts (item 1) for auto-captioned quotes.
### 5. Auto-fix metadata from online sources — **shipped** ✅
A **Look up online** button in the book view searches Open Library by
free-text query (pre-filled with the scanned title/author, editable), shows a
picker with thumbnail/author/year for each match, and previews the full
description before you apply it. Applying downloads the large cover, caches it
locally, and stores a per-book override (title/author/description/cover) that
wins over the scanned tags everywhere the book is displayed — chapters,
duration, and tracks always stay from the real file, since an online source
has no idea how *this* rip is chaptered. **Revert to file tags** removes the
override and the cached cover in one click.
Opt-in, gated behind an explanation shown the first time you click the button
(what it sends, that it's manual-only, that results are cached locally); off
by default; nothing is looked up automatically at any other time. Overrides
persist in `metadata-overrides.json` / `covers-online\` and round-trip through
the existing backup/restore feature (cached cover *images* aren't included in
backups — they're re-fetchable — but the override records are).
Source is Open Library only, decided empirically rather than assumed: Google
Books' keyless API returned a 429 quota error on the very first test call,
before a single real query shipped, which would force every user to configure
their own API key just for an opt-in convenience — rejected. Audible has no
public API and scraping it is a ToS risk out of scope for this app. Open
Library's general free-text search (not the strict `title=`/`author=` field
search) was needed too — the strict form returned zero results against this
library's real, sometimes-garbage scanned author tags on books that a
free-text search found correctly, including surfacing the *actually correct*
author name ("T. L. Mancour") where the scanned tag was wrong ("Terry
Mancour"). Open Library's `description` field is also inconsistent — some
records hold physical-copy metadata ("746 pages ; 23 cm") instead of a real
blurb — filtered out so it's never shown as if it were a real description.
**Series-splitting is descoped.** The original ask included using this lookup
to split series/box-set titles apart. Open Library's series data proved too
sparse and inconsistent across this library's real books to build a reliable
splitting feature on top of — this stays a metadata-correction tool, not a
re-grouping one. The series-title display collision this was meant to help
with is still primarily addressed by the existing renderer-side
[series grouping](../README.md#series-grouping) from Tier 1, and remains a
documented [known limitation](../README.md#known-limitations) for books that
aren't (or can't be) corrected here.
*Tested:* the Open Library client (`metadata-lookup.js`) against real network
data — 14 checks covering empty-query rejection, a real search finding the
correct book and correcting its author, description-field junk-filtering vs. a
genuine 1,364-char description passing through, cover download (real JPEG,
not Open Library's tiny placeholder), and graceful handling of a
zero-result query and a missing work key. The full UI flow — opt-in gate,
search, pick, preview (including the description arriving after a stale pick
is correctly ignored), apply, revert — was driven end-to-end via CDP against
the real running app and its real 6,324-book library: applying "Warmage:
Spellmonger, Book 2" → "Warmage" by "T. L. Mancour" correctly updated the
book's title/author/description/cover everywhere (header, badge, library
grid) and downloaded a real cover to `covers-online\`; reverting correctly
restored the scanned tags and deleted the cached override and cover file, with
`metadata-overrides.json` confirmed empty on disk afterward. Also confirmed
live: the opt-in flag persists across the modal reopening, an empty query is
rejected without a network call, a nonsense query returns "No matches found"
rather than an error, and a real Open Library cover thumbnail loads in the
results list under the app's CSP (`img-src` allows `covers.openlibrary.org`
specifically, nothing broader).
### 6. Read-along / immersion reading (audio + ebook) — **shipped** ✅ (EPUB only)
Pairs an EPUB with its audiobook and shows the matching chapter's text in a
book-view side panel, opened via a per-book **Read along** button
(auto-pairs by folder proximity, falls back to a manual file picker), plus
a **Has ebook** library filter and card badge from a gentle background pass
that checks the whole library over time. Scoped to EPUB only — MOBI (139
files) is a proprietary format Amazon itself deprecated in 2021, and PDF
(116 files) has no reliable chapter structure; both would need much more
work for much less reliability. The panel auto-advances chapters when
confident (exact chapter-count match, or proportional position as a weaker
signal) and estimates paragraph position within a chapter from time elapsed
— real word-level sync is still the "ambitious version," needing the
Whisper transcript aligned to text, not attempted here.
`src/main/epub.js` hand-rolls a ZIP + narrow XML reader (no new dependency,
same spirit as `mp4-chapters.js`) — verified against a 32-check synthetic
harness (real generated EPUB2/EPUB3 fixtures covering nested/fragment-split
TOCs, percent-encoded paths, malformed markup, and a simulated DRM failure)
before ever touching real files. The pairing search radius was tuned twice
against a real read-only dry run of this ~6,300-book library: a first pass
assumed `E-Books/` subfolders (from the earlier sidecar-metadata
investigation) and only covered 29% of the library's real epub files; the
actual dominant convention turned out to be a bare `epub` subfolder, fixed
after inspecting the real folder structure directly rather than guessing
further. Remaining unmatched epubs were hand-checked and are consistently
epub-only acquisitions with no audiobook counterpart, not radius misses.
### 7. Discord Rich Presence — **shipped** ✅
A topbar toggle (off by default) shows "Listening to *\* — Ch. N" on
Discord, pushed on chapter changes and play/pause (`pushDiscordActivity()` in
`app.js`, hooked into the same points that already update the OS media-session
metadata). `src/main/discord-presence.js` wraps `@xhayper/discord-rpc` on the
main-process side, entirely best-effort: no client ID configured, or Discord
not installed/running, and every call just no-ops. The library itself doesn't
reliably fail fast when Discord isn't running — observed hanging indefinitely
in testing rather than rejecting — so a 4s timeout plus a 30s retry cooldown
are enforced independently, rather than trusted to the library.
*Still needed:* a real Discord Application ID (`DISCORD_CLIENT_ID`) baked into
the shipped build; the feature is fully wired but inert until one is set.
### 8. Voice-clarity EQ / voice boost — **shipped** ✅
The 🎚 button (off by default; `V` toggles) runs two `BiquadFilterNode`s
spliced into the existing skip-silence/normalization Web Audio graph
(`source → analyser → normalize gain → voice boost EQ → volume gain →
output`): a ~100Hz highpass clears low rumble that eats headroom without
carrying intelligibility, and a peaking filter lifts the ~2.8kHz
consonant/sibilance range that separates words at speed. Both filters ramp
in/out over 0.3s rather than snapping (same `linearRampToValueAtTime`
approach as normalization's gain), and stay in the graph always-connected —
off just ramps the highpass down to 20Hz and the peak gain to 0dB, matching
how normGain/volumeGain are already handled elsewhere in the chain.
### 9. Library organization: duplicate detection — **shipped** ✅
**File → Find duplicate books…** groups the already-scanned library by
title+author, then splits into distinct *recordings* by matching duration
and track count — only a recording with 2+ copies is a real duplicate and
offered for removal (to the Recycle Bin, never permanent, and never the
containing folder, which can hold unrelated sibling books). Researched
first: no audiobook player on the market combines playback with duplicate
detection — even Audiobookshelf has "merge duplicates" as an open,
unshipped feature request. Validated against this library's real ~6,300
books: 376 titles with at least one real duplicate, 468 removable copies,
found alongside titles with 2-3 *genuinely different* narrators (never
flagged) — confirming the recording-matching split is doing real work, not
just title-matching.
### 10. Library organization: reorganize by author — **shipped** ✅
**File → Reorganize library by author…** computes (but does not perform) a
move of every book into `///`, shows the full
plan in a preview modal, and only touches disk after an explicit confirm.
`src/main/reorganize.js` separates `computePlan()` (pure, read-only) from
`executePlan()` (moves one book at a time, journaling every individual move
as it happens) and `undoLastReorganization()` (replays the journal
backwards) — verified against synthetic fixtures covering an
exclusively-owned folder rename, a folder shared by unrelated books (moves
only that book's own files, confirmed real in this library: four different
Alien audio dramas side by side in one folder), an already-correctly-placed
book, author/title collisions, illegal-Windows-character sanitization, and a
nested-subfolder multi-track book, plus full undo. Since a book's id is
derived from its file path, a move mints it a new one; execute and undo both
carry progress, bookmarks, normalization gain, metadata overrides, and
transcripts over to the right id (`newBookId()`/`remapIdKeyedStores()` in
`main.js`) rather than silently orphaning them on the next scan — the id
formula was independently checked against the real `scanLibrary()`, not just
assumed to match. Confirmed working hands-on against the real library,
including undo.
*Still possible:* genre-from-folder reorganization, on the same
preview/journal/undo machinery.
---
## Tier 3 — Format & content depth (leveraging the real library)
Concrete because the numbers come from the actual `E:\Books` scan.
### 1. `.cue` sheet chapter markers — **shipped** ✅
Parses a sibling `.cue` (`src/main/cue.js`) as a **fallback** when a single-file
book has no embedded chapters, recovering titles + offsets (INDEX `MM:SS:FF`).
Real embedded chapters are never overridden. Turned out narrower than hoped —
most of the 656 `.cue` files pair with `.m4b` that already carry chapters — so it
helped ~47 books, but those were genuinely unnavigable single `.mp3`s (some
80–180 chapters). Applied to the existing library via a one-off backfill; future
scans do it inline.
### 2. Sidecar metadata: `.nfo`, `.opf`, `metadata.json` — **M** (investigated)
Coverage on the real library is thinner than hoped and does **not** meaningfully
help series detection, which is why series grouping (Tier 1 #7) shipped from title
parsing instead. What's actually there: **504 `.nfo`** — freeform text with Title
/ Author / "Read By" (narrator) but *no* series field; **51 `.opf`** — Calibre
metadata (has `calibre:series`) but sitting in `E-Books/` subfolders beside the
ebook, not the audio; and rare **`metadata.abs`** (Audiobookshelf) which *does*
carry a clean `series=`. The real win here is **narrator + description** from
`.nfo`, and picking up `series` from a co-located `.abs`/`.opf` to fill gaps the
title parser misses (Dune, folder-numbered books). Needs a re-scan to apply.
### 3. Auto-generate chapters for chapterless books — **M/L**
Many mp3-folder books have no real chapters. Detect long silences to synthesize
chapter breaks, or (better) reuse the Whisper transcript to place semantically
sensible marks. Shares the silence-detection engine with skip-silence.
### 4. Gapless multi-track playback — **S/M**
At file boundaries in a multi-track book there can be a tiny stall while the next
`ab-media://` source loads. Preload/prime the next track's element (double-buffer
two `` nodes and cross-hand at the boundary) for seamless rollover. Purely
a `seekTo`/`ended` refinement in `app.js`.
### 5. Per-chapter embedded artwork — **S**
Some `.m4b` chapters carry their own images (`IChapter.image`). Surface them in
the chapter list / now-playing view for books that have them.
### 6. Full-cast / graphic audio productions — **M**
GraphicAudio and similar "movie in your mind" productions are structurally
different from a narrated audiobook, and the library already has them:
`E:\Books\GraphicAudio` holds 28 titles, 16 of them split across parts named
`(1 of 2)` / `(2 of 2)`, and 4 tagged `[Dramatized Adaptation]`. Across the
whole library, 53 folders use an `x of y` part convention.
What breaks today:
- **Parts scan as separate books.** `(1 of 2)` and `(2 of 2)` are distinct
folders with distinct tags, so a single production shows up as two
unrelated entries with duplicated titles. `consolidateSelfContainedParts`
merges numbered parts *within one folder*; this is the across-folders case,
and the existing series parser reads `x of y` as a series index rather than
as parts of one work.
- **"Narrator" is wrong for a full cast.** These have a cast, not a narrator,
and the `composer` tag they land in is usually a studio credit. Showing it
as "Narrated by" is misleading.
- **They are not duplicates.** Duplicate detection groups by title+author and
splits by duration/track count — a dramatized adaptation and the straight
narration of the same book are legitimately different recordings, and both
are worth keeping. Worth an explicit check that this holds, since the
titles often match exactly.
Scope: detect the `x of y` convention and merge parts into one book with a
continuous timeline (the multi-track machinery already does this — it just
needs to span folders); recognise `[Dramatized Adaptation]` / `GraphicAudio`
and label the production type on the card and in the book view; prefer "Cast"
over "Narrator" for those. A **Full cast** filter would be a natural follow-on
once the type is known, and pairs with the existing Has-ebook filter.
Worth doing before sidecar metadata (#2): both touch how a book's identity is
derived, and getting parts merged first means less to redo.
### 7. Audible `.aax` support — **shipped** ✅ (AAX only, not AAXC)
**File → Decrypt Audible file (.aax)…** picks one `.aax` via a native file
dialog and decrypts it into a `.m4b` alongside it, via ffmpeg's own
long-public `-activation_bytes` option — stream-copied (`-c copy`), so this
runs at disk speed, not encode speed. The source `.aax` is never touched,
moved, or deleted; the result gets added to the library the normal way
(**Folders → Add folder**, or picked up by the next scan if it already sits
under one) — no changes needed anywhere in the scanner, database, or
player. Activation bytes are entered once via **File → Set Audible
activation bytes…**, stored locally, and used only as that one ffmpeg
argument — this app never talks to Audible's servers or handles Audible
account credentials itself; the user supplies bytes obtained through their
own means. A book decrypted this way gets a small "AUDIBLE" card badge
(bottom-left corner, the one badge position left free), driven by a
tiny `audible-sources.json` store written at the moment decryption
finishes.
Deliberately scoped to **AAX only**, not AAXC (the current Audible app's
format) — the roadmap's own "activation bytes" framing is specifically the
AAX mechanism; AAXC uses a per-book key+IV pair from a companion voucher
file instead, a different enough mechanism to be its own future item rather
than silently folded into this one.
**Also offered automatically when a folder is newly added** (Folders → Add
folder, or drag-and-drop) — a real user report ("my test book doesn't show
up even in drag and drop") surfaced that dropping a folder containing only
`.aax` files did nothing, with zero explanation, since `.aax` is
deliberately not a recognized audio extension. Rather than either fully
automatic decryption during every scan (rejected: wrong activation bytes
fail *silently* — ffmpeg exits 0 but writes a corrupt file — so unattended
decryption of a whole library in one pass is exactly the wrong place to
remove the one confirmation step that catches that mistake early; it would
also turn scanning from a fast, mostly-read-only operation into one that
writes new files as a side effect) or leaving it fully manual, a folder that
is genuinely new to the library is checked for `.aax` files lacking an
already-decrypted `.m4b` sibling, and — only if any are found — a single
native confirm offers to decrypt all of them right then.
**First version of this had a real gap, caught by hands-on testing against a
real file**: it only ever checked *newly-added* folders, so a `.aax` dropped
into a folder that had been part of the library for a while (the actual
situation in the bug report, confirmed with a direct `library.db` query —
both `E:\Books` and `E:\Books\xtestx` already tracked) was never checked at
all. Fixed by also running the check on an explicit **File → Rescan
library** — deliberately *not* on routine/automatic scans (app launch,
background rescans), which stay exactly as fast as before — covering the
"dropped a new file into an existing folder" case a new-folder-only check
structurally can't. A small `audible-offered.json` store (`{ [aaxPath]:
true }`) records every file either check has ever asked about, regardless
of the answer, so a file is never offered twice no matter which of the two
entry points found it, and declining still doesn't turn into a nag on a
later rescan.
Explicit account login (the fuller "Libation approach" the original roadmap
wording gestured at) was considered and deliberately **not** built: it would
mean this app itself speaking Audible's private, undocumented
device-registration API and storing the user's actual Audible account
password rather than a small revocable derived value — a materially
different risk category from applying activation bytes obtained through the
user's own separate means, and out of scope here.
*Tested, with one real limitation stated plainly:* every other feature this
session was verified against a real fixture; this one couldn't be, fully —
there's no legitimately obtainable `.aax` file to test against, and AAX
encryption isn't something that can be synthesized the way this project's
`.m4b`/`.mp3` test fixtures have been all along. What *was* verified
directly: the ffmpeg spawn itself (binary resolution, argument
construction, exit-code handling, the concurrent-job guard) against a real
audio file stream-copied through the exact same invocation a real decrypt
would use; activation-bytes format validation (8 hex chars, case-insensitive,
rejects short/non-hex/empty); that ffmpeg does not echo the activation
bytes value back in its own stderr output (checked directly, not assumed,
before deciding no redaction was needed there); the newly-added-folder
`.aax` walker (finds an undecrypted file, skips one with an existing `.m4b`
sibling, recurses into subfolders, never descends into a skip-listed
directory like `$RECYCLE.BIN`, checks multiple directories correctly); and
— the one genuinely tricky correctness risk in this feature — that the book
id predicted from the output path at decrypt-time (so the badge can appear
correctly the very first time the file is scanned, without waiting on a
second scan) exactly matches the id `scanLibrary()` itself computes for
that same file, confirmed by actually running a real scan and comparing.
The activation-bytes modal (open, prefill masked by default with a
Show/Hide toggle that always re-masks on reopen, validation error, save,
Escape/backdrop dismiss) and the card
badge's rendering were both confirmed against a real running instance too.
The one thing that can only be proven by an actual user running it against
a real file with their real activation bytes is the decryption itself.
**A real gap in that testing, caught by the user, not found first:** every
check above ran against the dev Electron binary, where `app.asar` doesn't
exist and the `app.asar` → `app.asar.unpacked` ffmpeg-path rewrite this
feature depends on (same pattern the whisper/transcribe feature already
needed) is a silent no-op — so none of it had actually exercised that
rewrite at all. Once flagged, verified directly against the real packaged
build: required `audible.js` from inside the real `app.asar` and confirmed
it still resolves and successfully spawns the real unpacked `ffmpeg.exe`
from there. The user's own manual single-file decrypt against a real
Audible file, run after that, is what actually confirmed decryption itself
works end to end.
---
## Performance & architecture optimizations
The current design loads the **whole library fully into memory and ships it
over IPC** at startup (now backed by `library.db` on disk, not a monolithic
JSON file — see #1 below). The grid is now virtualized and covers are now
thumbnailed (see #2/#3) — the remaining ceiling is the full-library
IPC/in-memory load itself, not rendering or cover decode.
**Scanning is I/O-bound, not CPU-bound.** Measured on the real library: a
rescan parses *nothing* (every book is an unchanged cache hit), and its cost
was filesystem metadata checks — which is why #6 below failed and why the
directory-mtime fast path is what actually made rescans cheap. Treat any
future "make scanning faster" idea as an I/O problem until measurement says
otherwise.
### 1. Move the library to SQLite — **backend swap shipped** ✅, **query-per-view still open**
`library.json` is now `library.db` (`src/main/db.js`), migrated automatically
on first launch of this version (old file kept as `library.json.bak`, never
deleted). Real per-row writes replace whole-file rewrites — on the real
5,823-book/46.5 MB library, a no-op rescan went from a full JSON re-serialize
to ~2 ms, and a single detail-fill write from a full rewrite to ~9 ms; cold
load is ~225 ms. `better-sqlite3` segfaulted on this machine's Electron 34
build (native-module incompatibility, never resolved); shipped with
`@vscode/sqlite3` instead — same schema, but its callback-only API means
`db.js` is async under the hood, fronted by an in-memory cache so every
existing call site keeps its old synchronous `get()`/`set()` shape and the
renderer sees zero change.
Deliberately **not done**: the grid/search/sort/filter still load the full
book array over IPC at startup and filter in memory client-side — instant
server-side search and query-per-view (a page, a search, one book) are a
separable, larger change to the app's interaction model and remain a future
pass.
### 2. Cover thumbnails — **shipped** ✅
Small (~200 px) JPEG thumbnails (Electron's `nativeImage` — originally
`jimp`, replaced; see Security) generated at detail-fill time and cached in
`COVER_CACHE/{id}-thumb.jpg`; the grid, series-grouped view, and duplicates
finder all load these instead of the full-size cover. A backfill pass
(`runThumbnailFill`, chained after the ebook-pairing fill) catches
books that were already fully detailed before this shipped. Book detail
view still shows the full-size cover — thumbnails are grid-only.
### 3. True virtualized grid — **shipped** ✅
`#grid` now renders only the visible range ± a buffer, framed by two spacer
elements that reserve scroll space for the rest — DOM node count stays
bounded regardless of scroll depth or library size, instead of growing
monotonically as pages were appended. Verified via a headless-Chromium
harness (no real windowed Electron launch works in this dev sandbox) against
a synthetic 6,000-book library.
Shipping these two together mattered: virtualizing the grid alone made
scrolling-back-into-a-visited-section noticeably *slower* than before (cards
leaving the DOM meant their already-decoded full-size cover images had to
redecode from scratch on return) — cover thumbnails are what actually fixed
that regression, not just a nice-to-have on their own.
### 4. Incremental scan via file watcher — **M**
A *cold, first-ever* scan of `E:\Books` (parsing every book) takes ~40
minutes. A routine rescan is now ~3s, since unchanged books are detected from
their folder's mtime and never re-parsed. So the remaining gap is not speed
but *latency*: new books only appear when a scan is triggered. Watch library
folders (`chokidar`) and update only what changed, for near-real-time pickup.
Would also remove the main reason to run a manual rescan at all.
### 5. Two-phase / lazy scanning — **shipped** ✅
Phase 1 (`scanLibrary`) reads tags + duration only — no cover art, and for
single-file `.m4b`/`.m4a` books, no chapters either (multi-track mp3-folder
books get chapters for free from the same per-track tag reads duration
already needs, so only their cover is deferred). `readMp4Duration()` reads
just the `mvhd` atom instead of the full chapter-track walk that costs one
extra disk read per chapter. Phase 2 (`fillBookDetails`/`ensureDetail` in
`library.js`) fills in cover + chapters afterward: a low-priority background
pass that resumes automatically across restarts if interrupted, and a
same-book on-demand path that jumps the queue when you open a book before
the background pass reaches it (de-duplicated against each other via a
shared in-flight map, so neither redoes the other's work). Playback was
already never gated on chapters/cover, only tracks/duration, so a book is
fully playable the instant phase 1 finds it. Verified against a 37-check
synthetic harness (real `ffmpeg`-generated `.m4b`/`.mp3` fixtures with
actual chapter atoms and embedded art) before ever touching the real
library, then confirmed faster hands-on.
### 6. Worker-thread parsing — **attempted, reverted ❌**
Moving tag/chapter parsing and cover-thumbnail generation into
`worker_threads` was built, then removed. Recorded here because the premise
was wrong in a way worth remembering.
**The premise did not hold.** This item assumed scanning is CPU-bound. It
isn't. Instrumenting a real ~5,800-book library showed a rescan dispatches
**zero** tasks to the pool — every book is an unchanged cache hit, so nothing
is ever parsed. Scanning is I/O-bound: its cost was `fs.stat` on every file
of every book (~80,000 calls, concentrated in books split into 200-500
tracks). The pool did nothing on a normal launch except exist.
**It also broke the app.** Every build with the pool enabled spiked CPU and
hung part-way through a scan until Windows killed the app (logged as
Application Hang, event 1002). The same build with dispatch disabled ran
clean, as did v0.13.0 before the pool existed — bisected by building and
running both. Merely spawning the worker inside Electron's main process was
enough; a plausible but untested explanation is the cost of instantiating
`jimp`'s large dependency tree in a second V8 isolate inside that process.
It never reproduced headlessly, only in the full app. (`jimp` has since been
removed from the runtime entirely, so a future attempt would not carry that
particular weight — which makes the explanation cheaper to test, not proven.)
**What was kept.** `src/main/parse-core.js` survives — the parse functions
are cleaner extracted, and now run in-process. The real wins came from bugs
the investigation exposed, none of which needed worker threads: O(1) database
updates per book (was O(total books), 6.3s of CPU per scan on this library),
coalesced progress IPC (5,825 renderer wakeups down to 93), a directory-mtime
fast path that skips per-file checks for unchanged books, an event-loop yield
so a fast scan can't starve the UI, and guards so a background failure can no
longer terminate the app.
**If revisited**, the case would have to come from genuinely CPU-bound work
(loudness analysis, waveform precompute) rather than scanning, and would want
process-level isolation (`utilityProcess`) rather than a thread sharing the
main process.
### 7. Waveform / seek preview — **M**
Precompute a coarse waveform per book for a richer seek bar and instant scrub
previews. Cache next to the cover. Nice-to-have that also visualizes chapter
boundaries.
---
## Reliability & data safety
Found while reviewing this roadmap against the code (2026-07-25). None of
these are hypothetical — each is a path that exists in `main.js`/`library.js`
today.
### 1. A scan can wipe the library if the drive is unavailable — **fixed** ✅
`runScan()` guards only the "no folders configured" case. If folders *are*
configured but the scan finds nothing — the library drive offline, unplugged,
still spinning up, a drive letter that moved — `walk()` catches the read
error, warns to the console, and returns nothing. `scanLibrary` then returns
`[]`, and `runScan` unconditionally persists it:
`libraryStore.set({ ...libraryStore.get(), books })`. That deletes every book
row in `library.db`.
This library lives on `E:`, a separate SATA drive, and scans run
automatically at launch — so "drive not ready yet when the app starts" is a
realistic Tuesday, not a contrived edge case. Progress/bookmarks survive (they
are keyed by book id in separate stores) but would be orphaned, and recovery
means a ~40-minute cold rescan.
**Fixed** (unreleased): each configured folder is checked for readability
before the result is trusted. Unreadable folders are dropped from the scan
and the books already known under them are carried through untouched, so a
multi-folder library only rescans what it can actually see. If *no* folder is
readable the scan aborts and says so, leaving the library alone. A final
guard refuses to persist an empty result when the library previously had
books, covering read failures the per-folder check can't see (permissions, a
mount that answers with an empty listing). Verified against the real
`scanLibrary`, including that an *empty but readable* folder is correctly
distinguished from an unreadable one — the former is a genuine "you deleted
everything", the latter never is.
### 2. Books that fail to parse are invisible — **S**
`library.js` already records `tagsFailed` and `detailFailed` per book, and
logs a count to the console (`N book(s) had tag-parse failures`). Nothing
surfaces either flag: `toClientBook` doesn't send them and the renderer
never references them (verified — zero occurrences in `app.js`). So a book
that scanned with unreadable tags shows up as "Unknown author" with no
indication *why*, and a failed detail-fill is silently permanent until the
book's signature changes. Surface it: a filter, a card badge, or a line in
the Folders panel — plus a "retry failed books" action, since the current
best-effort design deliberately never retries on its own.
### 3. No way to cancel a running scan — **S**
There is cancellation *machinery* (`isCancelled` tokens for the detail,
pairing and thumbnail fills) but nothing for phase 1, and no UI for any of
it. A scan started by accident on a large library holds the Rescan button
disabled until it finishes. Wire a cancel affordance to the existing token
pattern.
### 4. The fast path can miss in-place edits — **S**
The directory-mtime fast path skips per-file checks for unchanged folders,
which is what made rescans cheap — but a file rewritten in place under the
same name (a re-tag) leaves the folder mtime untouched and goes unnoticed.
File > Rescan library forces the full per-file check, so the escape hatch
exists; it just requires knowing to use it. Consider an occasional automatic
deep scan (first launch of the week, say) so a library edited outside the app
converges without the user having to know the distinction.
### 5. Nothing surfaces an unexpected exit — **S**
`diagnostic.log` now records process-gone reasons, uncaught exceptions and
scan milestones, which is how the v0.14.0 scan hang was diagnosed. But it is
only useful to someone who knows to look for the file. On launch, notice that
the previous session ended without a clean shutdown and offer the log — the
difference between a bug report saying "it closed itself" and one with
evidence attached.
---
## Security & dependency maintenance
Findings from a security pass (2026-07-24): repo-level hardening (GitHub
secret scanning + push protection, a light-touch ruleset blocking
force-push/deletion on `main`) and the Electron sandbox/CSP/preload
configuration were already in good shape — `contextIsolation`/`sandbox`/
`nodeIntegration: false` on both windows, a strict CSP (`script-src 'self'`,
no `unsafe-inline`/`eval`) on every page, and `preload.js` exposing a
closed, explicitly-enumerated API rather than a generic IPC passthrough.
Three cheap renderer-hardening fixes and two deliberately-deferred dependency
upgrades came out of that pass:
### Resolved: Electron 34 → 43 ✅
Done. Electron 34.5.8 → **43.2.0**, clearing all **18** CVEs `npm audit`
listed against the 34.x line (ASAR integrity bypass, several use-after-frees,
IPC response spoofing, HTTP response-header injection in custom protocol
handlers, among others). Two of those were directly reachable here: the app
registers a custom protocol (`ab-media://`) and ships as an asar.
`npm audit --omit=dev` now reports **0 vulnerabilities**.
**The ABI fear that justified deferring this was wrong.** Both native modules
(`@vscode/sqlite3` and `@kutalia/whisper-node-addon`) build against
**Node-API**, not NAN — `node-addon-api`, with `NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS`
in sqlite3's `binding.gyp` and no `nan` in the tree. Node-API is ABI-stable
by design, so both loaded unchanged under Electron 43's ABI 148 (up from
133), verified by requiring each directly. No rebuild, no native-module
gauntlet. The "would need its full verification gate re-run" framing was
inherited from the `better-sqlite3` segfault, which was a different problem.
**What actually needed fixing** was mundane and would have broken the release
build silently:
- Electron 42 removed the `postinstall` download — the npm package no longer
fetches its binary. Added `"postinstall": "install-electron"`, without
which `npm install` yields a package with no runnable Electron.
- Electron 43 requires **Node ≥ 22.12.0**; CI was pinned to Node 20. Bumped
the workflow to 22 and declared `engines.node` so a too-old Node fails at
install rather than confusingly at build time.
**Behaviour changes worth knowing** (from the 35→43 breaking-change notes,
checked against the APIs this app actually calls): Electron 43 makes file
dialogs default to the Downloads folder. Three of this app's five dialogs set
`defaultPath` explicitly and are unaffected; the library-folder picker and
the ebook picker don't, so they now open at Downloads. Cosmetic, but both
would be better with a sensible default — worth a follow-up. Nothing else in
35→43 touches the APIs in use (`protocol.handle`, `nativeImage`,
`setJumpList`, `setWindowOpenHandler`, `contextBridge`, `webUtils`,
`ipcMain.handle`, sandbox/contextIsolation settings all unchanged).
### Resolved: `jimp` removed from the shipped app ✅
`npm audit` flags a moderate DoS vulnerability (infinite loop on malformed
input) in `file-type`, a transitive dependency via `jimp` (used for cover
thumbnails — `generateCoverThumb` in `src/main/library.js` — and the
build-time icon scripts). **Corrected from an earlier pass**, which wrongly
assumed this doesn't apply since "this app only feeds it JPG/PNG": checked
`@jimp/core`'s actual source
(`node_modules/@jimp/core/dist/utils/image-bitmap.js`) — `parseBitmap` calls
`fileType.fromBuffer(buffer)`, which sniffs the **real byte content**,
completely independent of file extension or the tag-declared picture
format. A crafted audio file with malicious "cover art" bytes (ASF magic
bytes, regardless of what extension/format the surrounding tag claims) would
reach the vulnerable parser exactly the way the advisory describes — and
at the time this was written, that decode ran synchronously on the main
process's single thread — a genuine infinite loop there couldn't be escaped
with a timeout (JS timers can't preempt a blocked event loop on the same
thread), so it would have hung the entire app, not just failed one book's
thumbnail, until force-killed. Given this app already accepts arbitrary
user-supplied audio files as its core input (and users audiobook-shopping
outside official stores is a realistic path for a maliciously-crafted file
to arrive), this was real exposure, not a theoretical one.
**Resolved** by removing the dependency from the runtime rather than
upgrading it. `generateCoverThumb` now uses Electron's `nativeImage`, and
`jimp` is a devDependency used only by the build-time icon scripts (see
"Replace `jimp` outright" above). The vulnerable `file-type@16.5.4` is no
longer packaged — confirmed against a real build — and `npm audit --omit=dev`
reports 0 vulnerabilities. This closes the exposure at its source: the app no
longer runs *any* JS image decoder over user-supplied cover art.
Two earlier attempts at this are worth remembering, because neither worked:
first a claim that the app "only feeds it JPG/PNG" (false — `file-type`
sniffs bytes, not extensions), then containing the hang in a worker thread
(reverted; it broke the app). The fix that held was deleting the dependency
from the path where hostile data reaches it.
### Replace `jimp` outright — **shipped** ✅
Done, using Electron's own `nativeImage` rather than upgrading to `jimp` 1.x
or adding another image dependency.
`generateCoverThumb` (`src/main/parse-core.js`) now decodes with
`nativeImage.createFromPath`, resizes by width (aspect ratio is preserved
when one dimension is given) and encodes with `toJPEG`. Measured against 50
real covers from this library:
| | jimp | nativeImage |
|---|---|---|
| per cover | 1,629 ms | **61 ms** (26x faster) |
| average output | 20.5 KB | 15.1 KB |
| covers decoded | 50/50 | 50/50 |
The security result is the point, though: `jimp` moved to
`devDependencies` (the icon scripts still need it — see below), so the
vulnerable `file-type@16.5.4` it pulls in is **no longer shipped**. Verified
on a real packaged build: zero `jimp` entries in `app.asar`, the only
`file-type` present is `21.3.4` via `music-metadata` (a different, unaffected
line), and `npm audit --omit=dev` reports **0 vulnerabilities**. Hostile
input is handled structurally rather than by hoping: `nativeImage` returns an
*empty image* for anything it can't decode — garbage, truncated files,
directories, missing paths — so there is no exception to catch and nothing to
spin on.
**Not converted:** `scripts/make-icons.cjs` and `scripts/make-media-icons.cjs`
still use `jimp`, because they do per-pixel drawing (`img.scan`, direct
bitmap writes) that `nativeImage` cannot do — it resizes and re-encodes, it
does not draw. That is fine: both are build-time scripts, run by hand,
operating on a checked-in logo, and their outputs (`build/icon.ico`,
`build/media-icons/*`) are committed. They are not an attack surface and they
are not in the shipped app.
---
## Suggested sequencing
Everything in the original sequencing plan has shipped — all of Tier 1, and
Tier 2 apart from bookmark clips. What follows is what is actually left,
ordered by value against effort:
1. **Reliability #2–#5** (surface parse failures, cancel a scan, occasional
deep scan, flag unexpected exits) — all small, all address things that are
currently silent. Cheap trust wins.
2. **Incremental scan via file watcher** (Performance #4) — with rescans now
~3s, the remaining annoyance is having to trigger one at all.
3. **Sidecar metadata** (Tier 3 #2) — narrator + description from `.nfo`, and
`series` from co-located `.abs`/`.opf`, fills the gaps title parsing
can't reach. Best remaining metadata win for this library specifically.
4. **Full-cast / graphic audio productions** (Tier 3 #6) — 28 titles in this
library scan as split, mislabelled entries today. Touches how a book's
identity is derived, so worth doing before sidecar metadata rather than
after.
5. **Gapless playback + per-chapter artwork** (Tier 3 #4–#5) — small, purely
playback polish, no architectural risk.
6. **Query-per-view** (Performance #1, the open half) — the real ceiling for
very large libraries, and a prerequisite for instant server-side search.
Bigger: it changes the app's interaction model, so it wants its own pass.
7. **Waveform / seek preview, auto-generated chapters, bookmark clips** —
genuine features rather than fixes; pick by appetite.
Deliberately **not** sequenced: the Electron major bump and the `jimp`
question (see Security). Both are real, both need their own verification
pass, and neither is a good candidate for bundling into feature work.
---
## Sources / prior art
Feature landscape informed by current audiobook players and reviews:
- [Voice Audiobook Player (open source)](https://f-droid.org/en/packages/de.ph1b.audiobook/)
- [Smart AudioBook Player](https://play.google.com/store/apps/details?id=ak.alizandro.smartaudiobookplayer)
- [Listen Audiobook Player](https://play.google.com/store/apps/details?id=com.acmeandroid.listen)
- [BookPlayer](https://apps.apple.com/us/app/bookplayer/id1138219998)
- [Chapters (transcription + bookmark search)](https://chapters.mobileappster.co.uk/)
- [Abookio (stats & streaks)](https://apps.apple.com/us/app/abookio-audiobook-player/id6754542041)
- [Best audiobook players for Windows](https://windowsreport.com/audiobook-players/)
- [For the Joy of Books — best audiobook apps 2026](https://forthejoyofbooks.com/best-audiobook-app/) (customizable skip amounts)
- [Goodreads / Spotify community threads on marking audiobooks finished](https://www.goodreads.com/topic/show/17957277-marking-books-as-read) (manual finished-state gap)