Skip to content

Decode audio files and podcasts with incomplete server details - #1226

Closed
r0adkll wants to merge 3 commits into
mainfrom
fix/podcast-models-a-tolerant-decoding
Closed

r0adkll wants to merge 3 commits into
mainfrom
fix/podcast-models-a-tolerant-decoding

Conversation

@r0adkll

@r0adkll r0adkll commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Stack

An audit of Campfire's podcast models against Audiobookshelf v2.37.1 found more values the server can send that the models reject. Like the release date in the PR below, one such value fails a whole shelf, list or item-page response. This PR makes those fields tolerant. Unknown durations then reach playback as 0, so it also guards every place that treated 0 as a real length. Part of #1224.

What changed

Network models (data/network/api)

  • AudioFile: duration, bitRate, codec, timeBase, channels and channelLayout are nullable. The server stores null when ffprobe doesn't report them, and the download path keeps the file anyway (prober.js, PodcastManager.probeAudioFile). Books share this model.
  • AudioTrack: duration and codec are nullable, since an episode's track is a copy of its audio file.
  • PodcastEpisode.Enclosure.length is a String?. The server passes the feed's raw length attribute through as text, and Campfire never reads it.
  • Podcast.maxEpisodesToKeep / maxNewEpisodesToDownload are Double?. Podcast.updateFromRequest saves any number, fractions included.

Mapping (data/db/mapping)

  • AudioFileMapping, AudioTrackMapping and PodcastMapping fill the server's own defaults into the non-null domain models and DB columns: duration 0 (as ABS's audioFile?.duration || 0), 0 and "". No migration is needed.
  • PodcastMapping truncates the episode limits to Int.

Unknown (zero) duration

  • Session.isFinished requires a known duration. Before this, playing such an episode marked it finished at once: the progress sync sent isFinished: true to the server, and RemoteSessionsUpdater closed the server session on its first sync. timeRemaining no longer goes negative.
  • MediaProgress.actualProgress falls back to the reported progress instead of dividing by zero, which drew infinite bars and a "2147483647%" label.
  • iOS: IosAudioPlayer ignores lock-screen scrubbing when the duration is 0, instead of seeking to an infinite position.
  • Desktop: DesktopAudioPlayer.seekAbsolute no longer clamps every seek to 0.
  • UI: EpisodeListItem, PodcastEpisodeBottomSheet, EpisodeDescriptionBottomSheet, QueueItem and LibraryItemListItem hide a zero duration instead of showing "0s" or "0 ns". AudioTrackSlot uses progressOver.

Verification

Tests

  • PodcastEpisodeTest: 5 decode cases (an unprobed audio file and track, a non-numeric enclosure length, fractional episode limits, and a recent-episodes page containing such an episode). They fail with JsonDecodingException against the parent's models and pass here.
  • AudioFileMappingTest and PodcastMappingTest cover the defaults and the truncation.
  • UnknownDurationTest covers isFinished, timeRemaining and actualProgress.

Testbed emulator, before vs after

  • Seeding: one episode got null probe fields and enclosureSize = 'unknown', its podcast got maxEpisodesToKeep = 2.5, and a book's first audio file got null probe fields. The API returned those exact values.
  • Parent: Home, Shows, Latest and the podcast's page failed, and the book page's fetch failed. Logcat showed Failed to parse literal '2.5' as an int value, '"unknown"' as a long value and 'null' as a float value.
  • This branch: everything loads, and the episode and the book both play (dumpsys media_session PLAYING, position advancing).

Testbed emulator, unknown-duration guards

  • Labels: the unprobed episode shows no "0s" or "0 ns".
  • Not finished: after about 77 s of playback it isn't marked finished, and the server agrees ("duration": 0, "currentTime": 77.554, "isFinished": false).
  • Seeking: skip-forward moves +30 s.
  • Control: a normal episode still finishes at its end ("isFinished": true).

Checks

  • ./scripts/ktlint --check, ./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 (UnsatisfiedLinkError).

Follow-ups

  • iOS seeking: seeking within an episode of unknown duration still lands at 0. IosPlayer multiplies the target by the item's metadata duration, so fixing it means using the player's own duration.
  • Out of scope (shared with books; the server code can produce these, but they didn't show up in real data):
    • /api/me fails on an old progress row with no libraryItemId.
    • Listening stats fail on a session with deviceInfo: {}.
    • One undecodable item still fails a whole list. Decoding item by item, and showing an error when nothing is cached, would cover fields not yet found.

🤖 Generated with Claude Code

Audiobookshelf stores a podcast's releaseDate as free text: the web UI
saves "" for podcasts added from an RSS URL, OPML imports save "", and
admins can type anything. Decoding it as an Instant failed the whole
personalized-shelves, library-items and recent-episodes responses, so
Home, Shows and Latest never loaded for those libraries.

Decode releaseDate as a String, map a blank value to null, and keep
sending only ISO-8601 dates when creating a podcast.

Closes #1224
One undecodable field in one item fails a whole shelf, list or item
response. ABS v2.37.1 can send several values Campfire's models reject:

- AudioFile duration, bitRate, codec, timeBase, channels and
  channelLayout are null when ffprobe doesn't report them, and the
  episode's AudioTrack copies duration and codec. Books share these.
- PodcastEpisode.Enclosure.length is the feed's raw length attribute,
  which isn't always a number. Campfire never reads it.
- Podcast maxEpisodesToKeep and maxNewEpisodesToDownload accept any
  number, fractions included.

Make those fields nullable (or String/Double) on the network models and
fill the server's own defaults (duration || 0, 0 and "") when mapping
into the non-null domain models and DB columns, so no migration is
needed.
Now that audio files without a probed duration decode, their episodes
and tracks carry a duration of 0, and several places treated that as a
real length. Playing such an episode marked it finished: Session
isFinished (currentTime >= duration) was true at once, so progress sync
sent isFinished to the server and the server session closed on its
first sync.

- Session.isFinished needs a known duration; timeRemaining never goes
  negative.
- MediaProgress.actualProgress falls back to the reported progress
  instead of dividing by zero (infinite bars, a 2147483647% label).
- iOS lock-screen scrubbing ignores a zero duration instead of seeking
  to an infinite position, and desktop seeks no longer clamp to 0.
- Episode cards, the episode and description sheets, the queue and
  list items hide a zero duration instead of showing "0s" or "0 ns";
  the audio track row uses progressOver.
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Warnings
⚠️ Big PR, try to keep changes smaller if you can
⚠️ This PR is original Xbox Huge! Consider breaking into smaller PRs
Messages
📖 This PR has been checked by Danger

Generated by 🚫 Danger Kotlin against 34fc9ee

@Rotom-Bot

Copy link
Copy Markdown
Collaborator

Code Coverage

File Coverage [29.85%] ❌
common/compose/src/commonMain/kotlin/app/campfire/common/compose/widgets/EpisodeListItem.kt 0.00% ❌
common/compose/src/commonMain/kotlin/app/campfire/common/compose/widgets/LibraryItemListItem.kt 0.00% ❌
core/src/commonMain/kotlin/app/campfire/core/model/MediaProgress.kt 92.31% ✅
core/src/commonMain/kotlin/app/campfire/core/model/Session.kt 70.00% ❌
data/db/mapping/src/commonMain/kotlin/app/campfire/data/mapping/AudioFileMapping.kt 66.20% ❌
data/db/mapping/src/commonMain/kotlin/app/campfire/data/mapping/AudioTrackMapping.kt 41.46% ❌
data/db/mapping/src/commonMain/kotlin/app/campfire/data/mapping/LibraryItemMapping.kt 0.00% ❌
data/db/mapping/src/commonMain/kotlin/app/campfire/data/mapping/PodcastMapping.kt 42.65% ❌
data/network/api/src/commonMain/kotlin/app/campfire/network/models/AudioFile.kt 100.00% ✅
data/network/api/src/commonMain/kotlin/app/campfire/network/models/AudioTrack.kt 100.00% ✅
data/network/api/src/commonMain/kotlin/app/campfire/network/models/Podcast.kt 100.00% ✅
data/network/api/src/commonMain/kotlin/app/campfire/network/models/PodcastEpisode.kt 77.14% ❌
features/libraries/ui/src/commonMain/kotlin/app/campfire/libraries/ui/detail/composables/slots/AudioTrackSlot.kt 0.00% ❌
features/libraries/ui/src/commonMain/kotlin/app/campfire/libraries/ui/detail/podcast/episode/PodcastEpisodeBottomSheet.kt 0.00% ❌
features/sessions/ui/src/commonMain/kotlin/app/campfire/sessions/ui/playback/expanded/composables/QueueItem.kt 0.00% ❌
features/sessions/ui/src/commonMain/kotlin/app/campfire/sessions/ui/sheets/description/EpisodeDescriptionBottomSheet.kt 0.00% ❌
infra/audioplayer/impl/src/jvmMain/kotlin/app/campfire/audioplayer/impl/DesktopAudioPlayer.kt 91.74% ✅
Total Project Coverage 24.96% ❌

@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

Android APK @ 5b2449d

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)
Base automatically changed from fix/podcast-release-date to main October 10, 2026 03:57
@r0adkll
r0adkll marked this pull request as draft October 13, 2026 14:51
@r0adkll

r0adkll commented Oct 16, 2026

Copy link
Copy Markdown
Owner Author

Abandoning for now; the #1224 fix landed in #1225.

@r0adkll r0adkll closed this Oct 16, 2026
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.

2 participants