Repository navigation
The Library stays fast with thousands of books - #83
Merged
Merged
Conversation
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>
… 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>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
This was referenced Oct 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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:
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