Skip to content

The Library stays fast with thousands of books - #83

Merged
StaticHumStudio merged 69 commits into
masterfrom
fix/big-library-load
Oct 4, 2026
Merged

StaticHumStudio merged 69 commits into
masterfrom
fix/big-library-load

Conversation

@StaticHumStudio

Copy link
Copy Markdown
Owner

Why

A listener with thousands of books on their own Audiobookshelf server waited about 4 minutes for the Library to show anything, and the same again every time they came back to the tab. This build fixes that and everything else a big-library user would hit.

What changes for a big library

  • The Library shows your saved books right away and refreshes behind them. Tabs keep their state, so coming back to the Library downloads nothing.
  • Sync downloads only what changed. One tiny check on app open and every 15 minutes while the app is open. New and removed books arrive incrementally. Book edits arrive with a daily full refresh on Wi-Fi or a pull to refresh. Nothing polls in the background, and big refreshes wait for Wi-Fi.
  • First sync is one pass, not two, and the shelf fills page by page while it runs.
  • Lighter everywhere: the shelf reads light rows and sorts once, big Series and Author views start collapsed, Home's recent list uses an index (Room 8 to 9), Dossier and Android Auto no longer load the whole library, playback saves its place every 10 seconds instead of twice a second, covers load as thumbnails on their own network lane.
  • Steadier connection: one slow ping no longer empties the shelf, and the app no longer comes back stuck offline after a network change in the background.

Also fixed

#51 cancelled downloads leave files behind. #53 cold start waits on a dead server. #57 leaving Settings kills a folder scan. #58 first-run tab taps pile up. #59 Home asks for a folder you already added. #68 Clear All hid downloaded books (Clear All is removed). #73 bookmarks downloaded all of /api/me. #74 covers broke after a LAN and VPN switch. #78 book detail showed no chapters. Downloads keep their track list and chapters, failed downloads stay visible with Retry, Settings keeps spaces in usernames.

Evidence

Mock server, 5,000 books, 2 s per page, headless emulator:

Before After
First Library visit after sign-in 215 s 4.3 s
Coming back from Home 111.5 s, 102 requests 4.2 s, 0 requests
Cold start 108.9 s 4.6 s, one small check
20 minutes in the background polling 0 requests

Real phone (SM-S948U) against a real server with 8,012 books: 15 MB per Library visit before, 0 KB on return after, no 5 minute re-download. Cancelling a 193 MB download clears its folder. Upgrade from the current database version is clean.

Evidence: /home/static/nine-lives-evidence/2026-10-03-big-library/ (PHONE-RESULTS.md, emulator-full/RESULTS.md, offline-fix/).

Review

Unit tests: 924 to 1,243, all passing. Independent review: Sol 6.1 high across every part over several rounds until clean, plus one ChatGPT Astra pass on the whole branch (one finding, fixed). Follow-ups filed as #61 to #82.

🤖 Generated with Claude Code

StaticHumStudio and others added 30 commits October 3, 2026 19:43
A server with thousands of books took minutes to show the Library, and
again on every return from another tab. The tabs now save and restore
their state, a return only reloads when the server, account, mode, or
library changed, the saved shelf shows before the network fetch, and the
merge, encode, decode, and sort work runs off the main thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 1: progress played in another tab and a cache cleared in
Settings now show on return through a Room re-read with no network, and
a shelf filtered to nothing no longer counts as empty for the retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The saved library's cached shelf now publishes before the /ping probe
and the library list fetch, so a gone server no longer holds first paint
behind serialized 5s probes. Closes #53.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 2: a failed library list left nothing selected and no
selected-library verdict, so a return skipped the reload and showed an
empty shelf until the next background sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add Folder and Rescan ran in the Settings screen's own scope, so leaving
Settings by Back or a tab switch cancelled the scan and nothing was
imported. The scan and import now run in an app-wide coordinator, and
Settings only shows its progress and result. A result that lands while
Settings is closed shows the next time Settings opens. The import and
reconcile rules are unchanged. Closes #57.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tabs popped to the graph's start destination, which is Welcome on
first run and is gone once onboarding finishes, so every tab tap pushed
a new screen. Onboarding now leaves Home under the screen it lands on,
and the tabs, mini player, and book detail pop to Home, which is on the
stack on every launch. Back goes Home and then out of the app, the same
as a normal launch. Closes #58.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tabs now keep Settings alive while another tab is open, and Settings
read the saved library selection only once when it opened. A library
picked on the Library tab meanwhile did not show on return, and the
local "All Books" and "All Deleted Books" cleanup actions still targeted
the old library. Settings now follows the saved selection for both the
server and local pickers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ims it

A cancelled download used to leave its partial audio on disk for good. Once
the engine is confirmed stopped (or the row was Paused or Failed), cancel now
removes the folder it was writing to, but only when that folder is a plain
direct child of the download root, holds nothing but plain files, and no
other book row or download points at it (#49). A user-chosen download
folder only loses .part files. Completed downloads and Queued rows keep
everything. Closes #51.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With books imported but none played, Home said to add a folder, which read
like the import failed. It now says the books are here and points at the
Library. Server mode had the same gap (the Archive stands empty, and the
Ritual and Unhinged lines said to add something), so a synced library with
nothing played gets the same treatment. Truly empty libraries keep the old
copy. Closes #59.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In a download folder the user picked, cancel cleanup used to delete every
.part file in the matching "Author - Title" folder. Other tools write .part
files too (a browser or a sync app), so one of theirs could go. Cancel now
deletes nothing from a user-picked folder and logs that it kept the files.
Only the app's own storage, which nothing else can write to, gets cleaned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cancel read the download rows and deleted files without waiting on resume.
A resume could read a Paused row, cancel could delete it and see an empty
queue, then resume wrote the row back and started the engine, and cleanup
deleted the files the resumed download was reusing. One lock now covers
cancel from its row read through the last deleted file, resume from its read
to the drain start, the write that makes a newly queued book pickable, and
every drain start. Right before deleting, cleanup also checks again that no
row for that book or folder exists and the engine is not on that book.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Home's "Open Library", "Enter The Archive" and "Open Settings" buttons
pushed a fresh copy of the tab instead of the one the user left, so its
scroll and loaded shelf were gone. Book detail's jumps to Settings and the
Player had the same gap. All of them now switch tabs the way the bottom bar
does, saving the tab being left and restoring the one being opened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hourly

The 5-minute timer re-downloaded every library's full book list on every
tick, which on a big library and a slow home server meant tens of megabytes
and minutes of server work around the clock. The tick now pulls listening
progress and retries the offline queue, and runs the full sync only when
the last one is an hour old or was for another server. App start, sign-in,
a server or mode switch, Home's reconnect, a server return after a failed
sync, Settings' Sync Now, and the Library's pull to refresh still fetch
right away. A progress-only tick writes no sync record, so the Library's
banners and shelf choices only move on real list syncs, and the Library
re-reads its saved shelf after each tick so pulled progress still shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tream

Audiobookshelf cannot list books edited since a time, but every 2.x server
sorts the library list by added date (sort=addedAt, with desc=1 for newest
first) and reports the library's total book count. This adds the calls a
change check needs: a one-book head request that returns the count and the
newest book, and an added-since fetch that pages newest first and stops at
the first book older than a cutoff. The added-since fetch checks the order
instead of trusting it, refuses books without an added date, gives up past
20 pages, and treats a count that moves mid-fetch as a reason to do a full
download instead.

The full download gets a streaming form that hands each page to the caller
and keeps only a count, so a big shelf no longer has to sit in memory until
the last page. It sorts oldest-added first, so a book added during a long
download lands at the end instead of shifting every later page, and drops
repeated rows by id. A failed page is retried twice (2 s, then 6 s) when the
failure is worth retrying, a 5xx, a timeout, or a rate limit, never a 401.
The old list-returning loop now runs on the streaming one, so both share one
set of stopping rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng every launch

A user with thousands of books on a slow home server waited minutes on every
launch, and sign-in plus opening the Library downloaded the whole library
twice back to back. Syncing now works like this.

The timer runs only while the app is in the foreground. Every foreground
entry checks at once unless a sync ran in the last 2 minutes, and the check
repeats every 15 minutes. Nothing polls in the background. Background
playback still pushes its own progress and the offline queue still flushes
on reconnect.

A check pulls progress, flushes the offline queue, and asks each book
library one tiny question: how many books, and which was added last. If that
matches the last good sync, nothing is fetched or written. If books were
added, only those are fetched, newest first, down to 5 minutes before the
stored newest book, and saved the way a full sync saves them. The server's
count must then equal the old count plus the new books, or something was
also removed and the full download runs, prunes and all. Any doubt, an
error, a first sync, or a cache missing rows falls back to the full
download too, and so does a full download more than a day old, because the
server cannot be asked for edited books.

Where each library stands (newest book, count, last full download) is kept
per server, account, and library in the settings document, written only
after its books are saved, so a cold start checks instead of downloading.

A full download a check finds necessary waits for an unmetered network when
a shelf is cached. Pull to refresh, Retry, Sync Now, sign-in, and a switch
back from Local mode always run in full, right away. Background full
downloads that fail back off from 15 minutes up to 4 hours, and a server
coming back after a failure now runs a check under the same debounce
instead of restarting the whole download on every flap.

One sync per library runs at a time and everyone who asks while it runs
shares it, including a check that turns into a full download. It runs
outside the caller, so leaving the Library no longer throws away a download.
Each library has its own lock, so a small library no longer waits behind a
big one, and an older response still can never land after a newer prune.

Full downloads save each page as it lands, so a first load fills the shelf
progressively, and the merge reads only the columns it keeps instead of
every cached row whole. Podcast libraries, which the app cannot play, are
skipped by background syncs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ne by one

Every pull read each book's whole row, took the queue lock, opened a
transaction, and rewrote the whole row (audio file and chapter lists
included) for every progress record the server returned, even when nothing
moved. On a fresh install each book with progress that the cache had not
seen yet also cost its own request, one after another, before the library
sync could start.

Local positions and shelf progress are now read in batches of 500 up front,
a record that already matches both is skipped, and an import updates only
the book's three progress columns. Unknown books are looked up one by one
only for the 12 most recently listened, enough for Home's recently played
row. The library sync brings the rest, and their progress rows still import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The overlap before the stored newest book was five minutes. A bulk import
adds hundreds of books a few seconds apart, and every book inside the
window is downloaded again on each check that finds something new, which on
a test shelf of books one second apart meant 13 pages for 3 new books. The
overlap only saves a full download in a rare commit-order race, and the
count check catches any book it misses, so one minute is plenty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A library sync stores books with no track list and no chapters, and a
finished download copied that empty row back with only the downloaded flag
and folder set. Offline playback then sorted the files by name, so
"Chapter 10.mp3" played before "Chapter 2.mp3", and the player had no
chapters. The details fetch before a download now keeps the server's
chapters as well as its tracks, and completion saves both into the book
row. The track list that was downloaded always wins because it names the
files on disk. An empty chapter list never wipes chapters the row had.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Downloads screen looked up each row's book one at a time, for both
lists, every time any download row changed. A running download writes its
progress about once a second, so 500 downloads meant 500 lookups per tick.
One joined query now brings every row with its book's cover, the lists are
split in Kotlin, and an unchanged result never reaches the screen. Rows
whose book left the database still drop out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A download that ran out of retries, a slow server timing out for example,
was in neither the active nor the completed list, so the book just vanished
and the Failed badge and Retry button never showed. Failed rows now sit in
the active list with their error and a Retry button. An old failure stays
hidden when the book got downloaded anyway or a later attempt for the same
book exists, so a stale red row never sits next to a finished book.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Before deleting a cancelled download's files, cleanup looks up the book
behind every download row in one query. Android 11's SQLite refuses more
than 999 values in one statement, so with 1,000 or more download rows the
lookup failed and cleanup quietly kept the partial files. The lookup now
goes 500 ids at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server URL and username fields trimmed on every keystroke, so a space
vanished the moment it was typed and a username like "john smith" could not
be entered. Both fields now keep what was typed and Connect trims the ends
before it validates and logs in. The trusted certificate host is read from
the trimmed URL, so spaces around it never hide a saved fingerprint. The
password is still sent exactly as typed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Audiobookshelf resizes covers to 400px wide when no width is given, and the
Library and Downloads rows draw them at about 64dp and 48dp, which is under
240px even on the densest screens. Those rows now ask for width=240, so a
long scroll through a big library pulls about a third of the pixels. Only
server item cover URLs change. Saved covers on the device, book detail, the
player and Home's tiles keep what they had. Covers cached at the old size
miss once while offline right after the update, since the address changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Coil, Retrofit and downloads shared one OkHttp client, which lets 5
requests per host wait on a server answer at once. A screen of covers the
server had not resized yet could fill all 5, and the play, session and
progress calls queued behind them on a slow server. The image loader now
builds on the same client, with the same auth, base URL and certificate
setup and the same connection pool, but with its own request dispatcher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… book current

The check counted a fetched book as new only when the cache lacked it. If
the server removed one book and added another, the first check saved the
added book, saw 100 + 1 against 100, and put the full download off until
Wi-Fi. The next check found the added book already cached, read 100 + 0 =
100, and moved the watermark forward with the removed book still on the
shelf. A progress pull or a full download that stopped short could save a
new book early the same way.

A book added after the last good sync's newest book now always counts as
new, whatever the cache holds. And when the counts disagree the watermark is
forgotten, so every later check goes to the full download (which is the only
thing that prunes) until one completes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…overed

A full download that stopped short left the library's watermark from the
last good sync in place. The next background check then saw the same newest
book and the same count, fetched nothing, and replaced the failed or partial
record with a success. Any edit on a server page the download never reached
stayed unapplied, and the full download was never tried again after its
backoff.

Only a complete full download now leaves a watermark. One that stops short
forgets it, so the next check goes back to the full download (waiting out
the failure backoff, or for Wi-Fi on a cell network) and a success is only
recorded once that download actually completes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e next

Library syncs were shared and serialized by library id alone, and they run
in the repository's own scope so they outlive whoever asked. Start a slow
full download as one account, sign in as another on the same server, and
refresh the same library: the new account joined the old download, and its
pages and prune landed in the new account's cache, including removing books
the new account can see.

Flights and per-library locks are now keyed by server, account, and sign-in
(a hash of the token, so two token sign-ins with no username still differ).
A sync also checks before every save and before its prune that the same
sign-in is still current. Once it is not, the sync stops, writes nothing
more, and fails with "the signed-in account changed during the sync",
leaving the cache to the new account's own sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A progress pull looks up only the 12 most recently listened books the cache
has never seen. For every other book with progress it saved the position
row but could not touch the book's shelf fields, and the library list the
sync downloads next carries no listening progress. So on a fresh install
most books someone had started or finished landed at zero and unfinished,
wrong in In Progress, Completed, and hide finished until a later pull.

When a sync saves a book that is new to the cache, it now reads the
position rows the pull already saved (one batched lookup, no extra
requests) and shelves the book with that position, its progress, and its
finished flag. The saved row wins only when it is ahead of what the server
list sent, the same rule local progress already follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oad waits for Wi-Fi

A check that put one library's full download off until Wi-Fi wrote no sync
record, even when the same check had just saved a changed library list (a
library removed on the server, say). The Library reloads its libraries only
when a record lands, and its progress listener only re-read the shelf, so
the selector kept showing the removed library. Later checks compare against
the list already saved and never report the change again.

When a check pulls progress, the Library now also compares the libraries it
shows with the saved list and reloads them if they differ, the same test a
new record already runs. This is the only change to LibraryViewModel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…read

Each period chip loaded and decoded every book in the active library on the
main thread, then used it only to look up the books the listening sessions
named. At 50k books that froze every chip tap for a second or more and held
about 100 MB. The Dossier now filters the sessions to the period first, asks
for just those books by id (500 at a time, still kept to the active library
and source), and builds the stats on a background thread. The numbers on
screen do not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
StaticHumStudio and others added 25 commits October 3, 2026 23:29
… own

Android Auto used to search with AudioBookRepository.search and browse with
the Library's shelf query. Auto now reads its own paged browse and search
queries, so search, the getAll it fell back to on a blank query, and the
DAO search query behind it had no callers left outside one instrumented
assertion. They are removed, along with that assertion, since Auto's own
instrumented test already checks that % and _ match literally. The shelf
query's comments no longer say Auto reads it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…up one

The library list never sends the structured series array. It sends one
string, "<name> #<sequence>" per series joined with ", ". The parser only
accepted a number at the very end, so "S1 #1, S2 #3" became a series named
"S1 #1, S2" at sequence 3, and a sequence like "1-3" stayed glued to the
name, giving every such book a series of its own in Series view.

The parser now takes the first "<name> #<sequence>" segment, ending at the
separator or the end, and accepts any sequence text. A comma or a "#"
inside a series name still stays in the name. It moved to its own file so
it can be tested directly.

Books already cached keep the old series name until the sync next rewrites
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A single /ping over 5 seconds called the server unreachable, and losing the
server switches the Library to Downloaded only. On a busy Pi or a slow VPN
that hid the whole shelf behind "Nothing downloaded" until the next ping a
minute later. And any answer counted as reachable, so a reverse proxy
returning 502 for a stopped Audiobookshelf showed Connected.

Now a failed probe on a server that was reachable, with the network still
up, waits 3 seconds and tries once more with a 10 second budget before
calling it unreachable. A server already unreachable is not retried, so
confirming a dead server still costs one short probe. A 5xx answer counts
as down. While the network is up and the server is not answering, the
periodic ping comes back after 15 seconds instead of 60.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app pinged /ping every minute for as long as the process lived, screen
off included, even though syncing now only happens in the foreground. The
periodic ping now follows the same process lifecycle flag as the sync
timer and stops the moment the app leaves the foreground.

Reachability is still checked when it matters. Every network change runs
a probe, as before, and coming back to the foreground runs one too. Three
things used to lean on the background ping and are covered another way:

- Background playback reports progress exactly as before. Its pushes never
  waited on the ping, and a push that lands now marks the server reachable
  again, which also flushes the offline progress queue.
- Android Auto probes the server before it refuses to stream a book on a
  status of offline or unreachable, the way restoring the last book does,
  so a car session never starts on a stale "unreachable".
- The offline queue still flushes on every return to connected, and on
  every foreground check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every 500 ms poll during playback saved the position row, added a row to
the offline progress queue, and rewrote the whole book row (description
and all) with a REPLACE. That is battery and database churn for every hour
of listening, screen off included, and each write woke every screen that
watches the books table.

The poll now saves locally every 10 seconds, and at once on a seek
(including the notification scrubber and Android Auto), a chapter change,
the first moment of a play, the book finishing, the app leaving the
foreground, and the service tearing down with a book still playing. Pause,
stop, and switching books already saved through their own paths and still
do. A seek while paused is saved now too, which it never was. A crash
loses at most about 10 seconds, which the resume rewind covers.

The book row gets a targeted update of its three progress columns instead
of a full rewrite. Server pushes keep their 30 second throttle and still
go out the moment they are due. After a failed push the retry waits for
the next 10 second save, so a server refusing connections no longer means
a save and a failed request every 500 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… one signs in

A library sync checked that its account was still signed in and then wrote
the cache as a separate step. A sync could pass the check, pause inside a
database read or delete, and resume after another account had signed in and
synced, then prune books only the new account can see. The page saves and
the "added since" save had the same gap.

The check and the write now run as one step under the auth token lock, the
lock every sign-in, sign-out, and token swap already takes. A sign-in waits
for a save or prune that already passed its check, and a write that comes
after a sign-in sees it and is refused. The prune reads the cached ids and
deletes inside that one step.

This adds one small method to SettingsManager that runs a block while
holding that lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…moval

The change check counts the books an "added since" fetch brings that the
last good sync did not count, and calls the shelf current when that plus
the old count matches the server. Inside the one minute overlap window it
used the cache to decide: a cached book counted as old. A delayed scan can
stamp a new book just before the stored newest one, and a progress pull can
cache it before the check runs. With one more new book and one removal the
counts still matched, so the removed book stayed on the shelf.

A watermark now records which books inside its overlap window its count
includes, and a book there is new exactly when it is not on that list,
whatever the cache holds. Full downloads and incremental syncs both write
the list. A watermark saved before the list existed cannot tell, so a
cached book in its window sends the library to a full download once.

The watermark model in AppSettings gains one optional field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… zero

A book finished on the server with no saved place lands on the shelf
finished, at position zero, with progress 1. The next pull to refresh lists
it again, and the library list carries no listening progress, so its copy
reads unfinished at zero. The merge kept local progress only when the local
position was ahead, and zero is not ahead of zero, so the book dropped out
of Completed and came back when hiding finished books.

The merge now also keeps local progress when the local row is finished (or
at progress 1) and the server copy is not. Marking a book unfinished again
stays with the progress pull, which reads the server's own record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The player loaded bookmarks by downloading all of /api/me, which carries
every progress row the account owns. At a big library that is a megabyte or
two per book opened. Any failure came back as an empty list, so a network
blip looked like every bookmark was gone.

Audiobookshelf 2.36.0 added a per-book bookmark route, and the player now
asks it first. An older server answers 404, and then the player reads
/api/me once and reuses its bookmarks for five minutes across books. The
cache belongs to one server URL and sign-in, and adding or removing a
bookmark drops it. A failed load now says so in the bookmark sheet with a
tap to try again, and keeps any list already on screen.

ApiService gains the new route and a RemoteResult return for bookmarks.

Closes #73

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The library list sync carries no chapters, and the detail screen only asked
the server when the book was missing from the cache. So the Chapters
section stayed hidden for nearly every server book until it was played or
downloaded.

Opening a server book with no chapters while the server is reachable now
fetches that one item and shows its chapters. They stay on the screen and
are never written back to the row, so shelf rows stay light. A failed fetch
tries again on the next resume or open, and a book the server says has no
chapters is asked once.

Closes #78

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clear All under Completed deleted the download rows and kept the files on
purpose, so every offline book dropped off the Downloads screen while it
still sat on the device. That is the one screen built to find and delete
them, so storage cleanup got harder the moment someone tidied the list.

Completed now lists downloaded books from the book rows as well as the
download rows. A book with no row left (cleared before this change, or not
written yet) shows after the rest, by title, without a size. Clear All is
removed, because clearing the list without deleting anything would hide
the books again. Each book's Delete still removes its files. Books hidden
by an earlier Clear All come back on their own.

Closes #68

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the free tier a failed download gives up the one offline slot, so
another book can download meanwhile. Retry on the failed book then put it
back in the queue without asking, and the queue only runs the book holding
the slot. The retried book sat in Queued forever with no word.

Resume and Retry now check the slot in the same step that requeues the row,
and take the slot when they win it. When another book holds it, the row
stays as it was and Downloads shows the same free tier notice the book
screen uses. A paused book the slot no longer favours gets the same answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Book rows keep an absolute cover URL with the host of whichever server
address synced them. After switching between a LAN and a VPN address,
every cover pointed at the old host. That host either does not answer on
the new network or gets no sign-in token, since the token only goes to the
current server, so the shelf turned to placeholders until a full resync.

Every item cover request now goes to the current server, rebuilt from its
address and the item id when the request is made. Existing rows work as
they are with no resync and no migration. The width that list rows and
Android Auto ask for is kept, and covers stay cached under the stored URL,
so switching back and forth does not download them all again. The rule
sits in the app's HTTP client, which the cover loader, Android Auto, and
the notification artwork all share.

Closes #74

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Switching periods cancels the old load, but cancelling does not stop the
aggregation it is already running on a background thread. That work used
to finish and publish its totals straight from there, so a slow 30 day
report could land on top of the All Time report while the All Time chip
stayed selected. A refund or downgrade that resets the period could leave
the same mismatch.

Each load now takes a generation number. The aggregation hands its result
back to the main thread, which drops it if the load was cancelled or a
newer load has started. The error message follows the same rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pausing queues a save of the paused position. A seek made while paused
(in the app, the notification, or Android Auto) saves too, and nothing
ordered the two. If the seek's save won the race, the late pause save
wrote the older position over it, and the next launch resumed from the
wrong place.

A paused seek now retires any pause save that has not run yet, using the
same snapshot generation that resuming and loading already use. A pause
save already writing still finishes first and the seek writes after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On an older server the app reads bookmarks out of /api/me and keeps that
list for five minutes. Adding or removing a bookmark clears the list, and a
fetch that started before the change is not kept. But the clear and the
"is this fetch still current" check were not ordered. A change could land
right after the check passed and right before the list was stored, so the
old list went back in and the refresh after the change showed it for five
minutes.

The clear, the check, and the store now share one lock, so a list fetched
before a change can never be stored after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pausing saves the position and the listening time since the last
heartbeat. Since the last fix, a seek made while paused dropped any pause
save that had not run yet so it could not put the old position back. But
that dropped the listening time with it. History came up short while
paused, and if the app was killed before playing again, that time was gone.

A paused seek now retires only the pause save's position. The pause save
still records its listening time, against the seek's position instead of
its own older one. Resuming or loading another book still retires the
whole save, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 3: a pause save overtaken by a paused seek saves the seek's
position, but it always wrote the book as unfinished, so a seek to the
last second while paused was undone. Every local position save now uses
the seek path's rule: within 1 second of the end counts as finished.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Astra review: a page size over 500 capped the limit but not the offset,
so page 1 started at 1000 and books 501 to 1000 were never listed. An
oversized page is now read as one capped list and its later pages are
empty. Books past the cap stay reachable through search (#71 tracks
letter folders).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A network change while the app sat in the background (Wi-Fi off and on,
or Wi-Fi marked metered) left it stuck on "Offline" when reopened, with a
streaming user's Library flipped to "Nothing Downloaded". Nothing healed
it until another network change or a manual reconnect tap. Android blocks
a backgrounded app's network, so the old Wi-Fi's onLost saw no default
network and marked the app offline, and coming back never looked again.

Every foreground entry now re-reads the network from the OS before the
sync's entry check runs, probes the server when it finds the network
again, and re-reads a few more times over 7 seconds if the OS still says
no network (Android lifts the block a moment after the app is visible).
The unblock callback re-reads too. An onLost no longer decides alone:
online is worked out from every network the callbacks still know about
plus the OS default. An entry check skipped because the app read offline
runs once the server shows as connected. Nothing pings in the background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The periodic ping picked its delay before sleeping. When the server went
unreachable (a 502 from a proxy in front of it) during a 60 second wait,
the first re-probe still waited out that wait, 56 seconds in the
evidence run, instead of the 15 second retry interval.

The wait now restarts whenever the status changes the interval, so the
first re-probe comes 15 seconds after the server is marked unreachable,
and a server that answers again goes back to every 60 seconds. Still
foreground only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cold start sent 3 to 4 /ping in about 1.5 seconds. Each caller (the
monitor's first check, the default network's replayed callback, the
sync's entry check, the Library, playback) cancelled the probe before it
or queued behind it and pinged again. A probe that is already running for
the same network now gets joined instead of cancelled, and an answer that
finished under 3 seconds ago is shared with anyone asking about the same
network and the same server. A network change, a new server, the reconnect
tap and coming back to the app still get a probe of their own.

Opening the Library also ran its own change check even when the app's
check had just run, a second HEAD 73 seconds after the cold-start one.
The Library now honors the same two minute debounce: inside it, opening
the tab sends nothing and shows the saved shelf. Pull to refresh still
goes to the server, and so does a library the app's check does not cover
(podcasts, or nothing cached yet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The network callbacks never see a VPN (Android leaves VPNs out of a
network request by default), but the OS default network was added to the
list of known networks anyway. With Tailscale as the default, that entry
stuck around after the VPN disconnected, because nothing could ever report
it lost. Then turning off Wi-Fi and cell left the app showing "Server
unreachable" instead of "Offline" until it was reopened.

The default network now joins that list only when the callbacks can also
report it lost, so a VPN never does. A connected VPN still counts as
online while it is the default, since the default is read fresh every
time, and the app goes offline once every real network is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…one by it

A failed /ping is shared for 3 seconds with anyone else asking. When a
playback progress push succeeded inside that window, the app marked the
server connected, and the reconnect listener's check then got the shared
failure back, recorded the sync as failed, and skipped it. The status
stayed connected, so no later ping made a new reconnect, and the shelf
could wait 15 minutes for the next timer.

Any request that reaches the server now stops a failed probe from being
shared, including one that lands while that probe is still running. The
next caller sends its own /ping. A successful probe is still shared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Coming back to the app, the entry check can read "Offline" and skip, and
it flags the skip so the server-return listener runs the check once the
status turns Connected. But the flag was set only after the skipped check
returned. If the network came back in that gap, the listener had already
handled the Connected change and found nothing to do (last sync clean,
flag not set yet), and the late flag had no change left to wake it. The
shelf then waited for the next 15 minute timer.

The listener now watches the flag as well as the status, so a flag set
while the server is already connected runs the check right away, under
the same foreground and two minute rules as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-04T06:34:31.401325Z 7c4ce91 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@StaticHumStudio
StaticHumStudio merged commit 99839f6 into master Oct 4, 2026
1 check passed
@StaticHumStudio
StaticHumStudio deleted the fix/big-library-load branch October 4, 2026 16:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant