Skip to content

Refresh stored podcast rows from list and shelf data - #1227

Draft
r0adkll wants to merge 1 commit into
fix/podcast-models-a-tolerant-decodingfrom
fix/podcast-models-b-stored-data
Draft

r0adkll wants to merge 1 commit into
fix/podcast-models-a-tolerant-decodingfrom
fix/podcast-models-b-stored-data

Conversation

@r0adkll

@r0adkll r0adkll commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

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.

  • Latest Episodes: it stores a placeholder libraryItem (empty path/ino/folder, one episode's size) and a podcastMedia row with no episode count or size, because the recent-episodes response carries neither. Later Home and Shows loads kept these.
  • Episode count: Podcast.numEpisodes wasn't decoded, so every podcast from a list or shelf stored 0 episodes. Android Auto shows this count (MediaTree.setTotalTrackCount).
  • Covers: cover URLs are keyed on the stored updatedAt, so a changed cover stayed stale until the podcast was opened.

What changed

  • Upserts: libraryItems.sq and podcastMedia.sq gain grouped UPDATE + INSERT OR IGNORE statements (upsert), and Upserts.kt adds row-based upsert(row) helpers that await them.
    • These are query-only changes; the schema is untouched.
    • INSERT OR REPLACE isn't an option, because it deletes the row first, and that cascades to the podcast's episodes through their foreign keys.
  • Writers: the podcast branches of LibraryItemRemoteMediator, StoreLibraryRepository and HomeSourceOfTruthFactory now upsert. Book writes are unchanged.
  • Latest Episodes: RecentEpisodesRemoteMediator keeps INSERT OR IGNORE, so its placeholders never overwrite real rows.
  • Models: Podcast.numEpisodes is decoded and preferred in PodcastMapping. The expanded episode's size is bound as PodcastEpisode.sizeBytes: Long?. It used to be an unbound propertySize: 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):
    • an upsert refreshes a placeholder row;
    • it keeps the podcast's episodes;
    • it inserts a missing row.
    • Swapping the upsert for INSERT OR REPLACE fails the episodes test, so the test catches the cascade.
  • PodcastMappingTest covers numEpisodes. PodcastEpisodeTest decodes a 3 GB episode size.

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.db was queried with sqlite3 at each stage, and its values were compared with GET /api/libraries/<id>/items?minified=1.

Parent After upgrade
numEpisodes 0 for 11 of 12 podcasts (10 only for the one opened in detail) 10 for all 12, matching the server
Placeholders from Latest 2 rows with path = '', one episode's size, and podcastMedia.sizeInBytes = 0 real path, size and updatedAt, equal to the server's
podcastEpisode rows 10 per podcast 10 per podcast; none deleted, including the opened one's

After the upgrade:

  • Latest loads.
  • The opened podcast's page lists its episodes.
  • An episode plays (dumpsys media_session PLAYING, position advancing).

Checks

  • ./scripts/ktlint --check, :data:db:core:verifyCommonMainCampfireDatabaseMigration, ./gradlew --continue jvmTest test, :app:desktop:compileKotlin, :app:android:compileAlphaDebugKotlin and :app:ios:compileKotlinIosSimulatorArm64 all pass.
  • Excluded: :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 IGNORE staleness, including covers keyed on updatedAt. 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

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.
@r0adkll r0adkll added the ignore-db-change Used to tell Danger to ignore .sq changes as they only pertain to queries and not schema changes. label Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Warnings
⚠️ Big PR, try to keep changes smaller if you can
⚠️

Ignoring DB schema changes due to ignore-db-change label

Messages
📖 This PR has been checked by Danger

Generated by 🚫 Danger Kotlin against fb4269a

@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

Android APK @ f1e2ef2

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
r0adkll marked this pull request as draft October 13, 2026 14:51

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ignore-db-change Used to tell Danger to ignore .sq changes as they only pertain to queries and not schema changes.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants