Repository navigation
Conversation
List and shelf writes stored podcasts with INSERT OR IGNORE, so a row never changed after its first write: - Latest Episodes saves a placeholder libraryItem (empty path, the episode's size) and a podcastMedia row with no episode count or size, because its response carries neither. Later list loads kept them. - Podcast.numEpisodes wasn't decoded, so every podcast from a list or shelf stored 0 episodes (Android Auto's track count). - Changed covers kept their old updatedAt-keyed URL until the podcast was opened. Add grouped UPDATE + INSERT OR IGNORE upserts for libraryItem and podcastMedia (INSERT OR REPLACE would cascade-delete the episodes) and use them in the podcast branches of the library, paging and home writers. Latest Episodes keeps INSERT OR IGNORE so it never overwrites real data. Decode numEpisodes, and bind the expanded episode `size` as a Long so it no longer silently drops.
This was referenced Oct 9, 2026
Generated by 🚫 Danger Kotlin against fb4269a |
Collaborator
r0adkll
added a commit
that referenced
this pull request
Oct 10, 2026
…1225) <!-- pr-stack:start --> ## Stack - [This PR] #1225 - #1226 - #1227 <!-- pr-stack:end --> Podcast libraries could fail to load in the Home, Shows and Latest tabs. Audiobookshelf stores a podcast's release date as free text, and Campfire decoded it as an ISO-8601 `Instant`. The web UI saves `""` for podcasts added from an RSS URL, OPML imports save `""`, and admins can type anything ("2019"). One such podcast throws `kotlin.time.InstantFormatException`, failing the whole personalized-shelves, library-items or recent-episodes response. Fetch errors with nothing cached are swallowed, so Home keeps the previous library's shelves or spins, and Shows spins with a "--" count. Closes #1224 ## What changed - `PodcastMetadata.releaseDate` (`data/network/api`) is now `String?`, matching the server's `STRING` column. - `NetworkPodcastMetadata.asDomainModel()` (`PodcastMapping.kt`) maps a blank release date to null. The domain model and DB already held it as a `String?`. - `PodcastDraft.asCreateMetadata()` (`PodcastSearchMapping.kt`) still sends only an ISO-8601 release date when creating a podcast, now as `Instant.toString()`. That is the same wire format the `Instant` serializer produced. - New tests: - `PodcastMetadataTest` decodes a podcast item with a blank or non-ISO release date, standalone and inside a personalized podcast shelf. - `PodcastMappingTest` and `PodcastSearchMappingTest` cover both mapping changes. This adds `commonTest` source sets to `:data:db:mapping` and `:features:podcasts:impl`. ## Verification - **Root cause:** on unmodified `main`, a test decoding a minified podcast item with `"releaseDate": ""` failed with `kotlin.time.InstantFormatException`. - **Writers:** confirmed in ABS v2.37.1 that `NewModal.vue` (web RSS add) and `PodcastManager.js` (OPML import) write `''`. - **Before/after on the testbed emulator** (local ABS, Podcasts library). One podcast was set to `releaseDate: ""` and another to `"2019"`. - `main`: Home kept showing the audiobook shelves, and Shows and Latest stayed empty. Logcat showed decode failures on `/personalized`, `/items` and `/recent-episodes` (`An empty string is not a valid Instant`, `The input string is too short ... "2019"`). - This branch: Home shows Newest Episodes and Recently Added, including both affected podcasts. Shows lists all 12 podcasts. The `""` podcast's detail loads with its episodes. Latest loads, and switching back to Audiobooks works. No decode errors in logcat. - **Real data:** podcast responses from a real 2.37.1 server decode cleanly with these models: library items, personalized shelves, recent episodes, and 8 expanded item details. - **Checks:** `./scripts/ktlint --check`, `./gradlew --continue jvmTest test`, and compiles for `:app:desktop:compileKotlin`, `:app:android:compileAlphaDebugKotlin` and `:app:ios:compileKotlinIosSimulatorArm64`. All pass. - Excluded: `:infra:audioplayer:engine-tests:test`. Its FFmpeg engine tests can't load the native library on this machine (`UnsatisfiedLinkError`), and this PR doesn't touch audio code. ## Follow-ups An audit of the other podcast models against ABS v2.37.1 found more fields of the same kind: - non-null `AudioFile` probe fields that ffprobe can leave null; - `PodcastEpisode.Enclosure.length` typed `Long?`, though the server sends the feed's raw text; - `maxEpisodesToKeep` typed `Int?`, though the server accepts any number; - `numEpisodes`, which is never decoded, so every podcast from a list or shelf is stored with 0 episodes. These come next in their own PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
r0adkll
marked this pull request as draft
October 13, 2026 14:51
This branch has not been deployed
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.
Stack
List and shelf writes stored podcasts with
INSERT OR IGNORE, so a podcast's row never changed after its first write. Three kinds of stale data came from this. The podcast-model audit for #1224 turned it up. Part of #1224.libraryItem(empty path/ino/folder, one episode's size) and apodcastMediarow with no episode count or size, because the recent-episodes response carries neither. Later Home and Shows loads kept these.Podcast.numEpisodeswasn't decoded, so every podcast from a list or shelf stored 0 episodes. Android Auto shows this count (MediaTree.setTotalTrackCount).updatedAt, so a changed cover stayed stale until the podcast was opened.What changed
libraryItems.sqandpodcastMedia.sqgain groupedUPDATE+INSERT OR IGNOREstatements (upsert), andUpserts.ktadds row-basedupsert(row)helpers that await them.INSERT OR REPLACEisn't an option, because it deletes the row first, and that cascades to the podcast's episodes through their foreign keys.LibraryItemRemoteMediator,StoreLibraryRepositoryandHomeSourceOfTruthFactorynow upsert. Book writes are unchanged.RecentEpisodesRemoteMediatorkeepsINSERT OR IGNORE, so its placeholders never overwrite real rows.Podcast.numEpisodesis decoded and preferred inPodcastMapping. The expanded episode'ssizeis bound asPodcastEpisode.sizeBytes: Long?. It used to be an unboundpropertySize: Int?, which was always null and would have overflowed on episodes over 2 GiB.Verification
Tests
UpsertsTest(:data:db:mapping, foreign keys on as on Android):INSERT OR REPLACEfails the episodes test, so the test catches the cascade.PodcastMappingTestcoversnumEpisodes.PodcastEpisodeTestdecodes a 3 GB episodesize.Testbed emulator, upgrade path. I installed the parent (#1226's tip), loaded Latest, Home and Shows, and opened one podcast. Then I installed this branch over it with data kept and loaded only Home and Shows (no detail pages). The device's
campfire.dbwas queried with sqlite3 at each stage, and its values were compared withGET /api/libraries/<id>/items?minified=1.numEpisodespath = '', one episode's size, andpodcastMedia.sizeInBytes = 0path,sizeandupdatedAt, equal to the server'spodcastEpisoderowsAfter the upgrade:
dumpsys media_sessionPLAYING, position advancing).Checks
./scripts/ktlint --check,:data:db:core:verifyCommonMainCampfireDatabaseMigration,./gradlew --continue jvmTest test,:app:desktop:compileKotlin,:app:android:compileAlphaDebugKotlinand:app:ios:compileKotlinIosSimulatorArm64all pass.:infra:audioplayer:engine-tests:test, whose FFmpeg tests can't load the native library on this machine.Follow-ups
Book rows from lists have the same
INSERT OR IGNOREstaleness, including covers keyed onupdatedAt. Book media rows can't take the same upsert as-is: minified and expanded book media differ in shape, so a list write would overwrite detail-only values. That needs its own change.🤖 Generated with Claude Code