Skip to content

feat: add on-demand transcript generation - #5762

Draft
stefanosala wants to merge 2 commits into
mainfrom
stefanosala/on-demand-transcripts-android
Draft

feat: add on-demand transcript generation#5762
stefanosala wants to merge 2 commits into
mainfrom
stefanosala/on-demand-transcripts-android

Conversation

@stefanosala

@stefanosala stefanosala commented Aug 20, 2026

Copy link
Copy Markdown

Linear: PCDROID-729

Description

Adds on-demand transcript generation for Plus and Patron listeners when an episode has no existing transcript. The client checks for creator-provided content first, makes one authenticated request, and refreshes only while visible every 15 seconds for at most 20 attempts/five foreground minutes. It then stops in a check-later state. The feature remains production-off.

Fixes: PCDROID-729

Testing Instructions

  1. Enable the on-demand transcripts feature and sign in with Plus or Patron.
  2. Open a missing transcript and confirm one request is made.
  3. Background and foreground the app; confirm polling pauses and resumes without another request.
  4. Confirm creator-provided transcripts load without triggering generation.
  5. Confirm polling stops after success, navigation, or 20 visible attempts and shows “Check back later” without retry.
  6. Verify free, signed-out, rejected, offline, and server-error behavior.
  7. Verify TalkBack, large font/display sizes, landscape, RTL, and both themes.

Screenshots or Screencast

Required before moving out of draft because this changes listener-facing states. Current copy is prototype copy pending Design review.

Verification

  • Focused transcript ViewModel and repository tests
  • Existing TranscriptManager wait-path coverage
  • ./gradlew spotlessCheck
  • transcript/repository lint and IDE diagnostics

Checklist

  • If this is a user-facing change, I have added an entry in CHANGELOG.md
  • Ensure the linter passes (./gradlew spotlessApply to automatically apply formatting/linting)
  • I have considered whether it makes sense to add tests for my changes
  • All strings that need to be localized are in modules/services/localization/src/main/res/values/strings.xml
  • Any jetpack compose components I added or changed are covered by compose previews
  • I have updated (or requested that someone edit) the Event Horizon schema to reflect any new or changed analytics.

I have tested any UI changes...

  • with different themes
  • with a landscape orientation
  • with the device set to have a large display and font size
  • for accessibility with TalkBack

Made with Cursor

Co-authored-by: Cursor <cursoragent@cursor.com>
@stefanosala stefanosala self-assigned this Aug 20, 2026
@stefanosala stefanosala added the [Type] Feature Adding a new feature. label Aug 20, 2026
@stefanosala stefanosala added this to the 8.20 milestone Aug 20, 2026
@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown

Claude finished @stefanosala's task in 6m 28s —— View job


Code review: on-demand transcript generation

  • Gather context (read diff + key files)
  • Review repository / networking layer
  • Review TranscriptViewModel polling logic
  • Review UI + strings + feature flag
  • Post review (10 inline comments)

Nice shape overall — the feature is properly flag-gated, FeatureFlag.isEnabled correctly doesn't check tier so the explicit isSignedInAsPlusOrPatron guards are load-bearing, creator transcripts are checked before generation is requested, the Available-outcome / CancellationException handling is deliberate, and the ViewModel test suite is genuinely good on the polling lifecycle. My concerns are concentrated in two places: one reachable stuck-state bug, and the cost of the polling loop.

Blocking

1. The transcript page gets stuck on an indefinite spinnerTranscriptViewModel.kt:208-214

Once generation ends in GenerationDelayed / GenerationUnavailable / GenerationFailed, a second loadTranscript(sameUuid) sets state to Loading, then loadExistingTranscript(showMissingFailure = false) returns without touching state, then requestOnDemandTranscript early-returns on requestedEpisodeUuid == episodeUuid because state is Loading rather than Generating. No copy, no retry, no polling.

Reachable in both hosts as written: EpisodeFragment.kt:936 uses LaunchedEffect(Unit) inside the selectedTab == TRANSCRIPT branch (re-fires on every tab switch back), and PlayerHeaderFragment.kt:1251 guards on playerEpisodeUuid != transcriptEpisodeUuid, which is always true while no transcript is loaded (re-fires on close/reopen). Not covered by tests — the existing re-entry test only exercises the Generating case.

2. Polling costs ~60 network requests per episode viewOnDemandTranscriptRepository.kt:83-96

Each of the 20 attempts does getPodcastAndEpisode + getShowNotesLocation + a full getShowNotes (the whole podcast's show-notes JSON), and re-runs ShowNotesProcessor.process, which also rewrites chapters as a side effect. The show-notes round trip only exists to re-trigger updateTranscriptstranscriptDao.replaceAll; the generated transcript URL is synthesised locally from hasGeneratedTranscript, which step 1 already persisted. Suggest running steps 2–3 only on the poll where the flag flips, and/or backing off the interval instead of flat 15 s × 20.

Worth fixing before merge

3. Error mapping surfaces the wrong copyOnDemandTranscriptRepository.kt:98 and TranscriptViewModel.kt:243

401 → NotEligible tells a listener with a stale token that the episode isn't eligible, terminally and with no retry button. Same class of problem for Throttled and UnknownGenerationUnavailable: rate-limiting is a "try later" condition, and Unknown will catch every future server-side outcome that old clients decode as UNRECOGNIZED. Both would degrade better as retryable states.

4. Proto enum values pollute the package namespacesync_api.proto:167

Proto3 scopes top-level enum values to the package, so QUEUED, AVAILABLE, ENABLED, INTERNAL_ERROR, THROTTLED are now taken for all of com.pocketcasts.service.api. Prefix them or nest the enums inside the message. Also worth confirming in review whether this schema was mirrored from the backend's canonical definition or hand-written.

5. Repository has no behavioural testsOnDemandTranscriptRepositoryTest.kt

The test file covers only the IntOutcome helper. Untested and regression-prone: proto→domain outcome mapping, the CancellationException rethrow, and the ordering in refreshMetadata (the local hasGeneratedTranscript write must precede the show-notes process, since ShowNotesProcessor.updateTranscripts reads that flag back from the DB).

Smaller items

  • First poll is delayed a full 15 s — including when the server answered Available, and on every resume-from-background.
  • ShelfViewModel.kt:85-109 — the availability expression is duplicated verbatim in two places over plain mutable fields; also a product question, since this shows the transcript action on every podcast episode for Plus listeners, including ones the backend will reject.
  • episodeManager.update(localEpisode) writes the full row from a stale snapshot every 15 s while playback may be writing position to the same row.
  • EpisodeFragment.kt:1203.orEmpty() for episodeUuid vs INVALID_OR_NULL_VALUE for podcastUuid in the same event.
  • TranscriptPage.kt:386 — fully-qualified androidx.compose.ui.graphics.Color, and no previews for the four new states (checklist item, and these are the states hardest to reach by hand).
  • Fully-qualified au.com.shiftyjelly.pocketcasts.models.type.SignInState inline in ShelfSharedViewModel.kt:126 — should be an import.
  • Prototype strings are translatable and GlotPress pulls automatically, so translators will work on copy that's known to be changing.
  • loadTranscript reads userManager.getSignInState().asFlow().first() even though init already collects the same state into uiState.isPlusUser — minor duplication.
  • elapsed_seconds on the ready event measures wall-clock from the request, so it includes time the app spent backgrounded. Probably intended, but it doesn't mean the same thing as the "five foreground minutes" budget.

Not verified

I reviewed statically only — I didn't build the modules or run the test suites, so I can't confirm spotlessCheck/lint status or that the added tests pass. The security surface looked clean: the request is authenticated via the existing getCacheTokenOrLogin path, and nothing sensitive is logged (the analytics payloads carry only UUIDs, consistent with the existing transcript events).
• branch stefanosala/on-demand-transcripts-android

@dangermattic

dangermattic commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator
1 Warning
⚠️ This PR is larger than 500 lines of changes. Please consider splitting it into smaller PRs for easier and faster reviews.
1 Message
📖 This PR is still a Draft: some checks will be skipped.

Generated by 🚫 Danger

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Comment thread modules/services/protobuf/src/main/proto/sync_api.proto
Comment thread modules/services/localization/src/main/res/values/strings.xml

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a production-off, feature-flagged “on-demand transcript generation” path for eligible (Plus/Patron) listeners when no transcript exists, including a new sync API endpoint, repository orchestration, UI states/copy, and polling/refresh logic while the transcript screen is visible.

Changes:

  • Introduces a new ON_DEMAND_TRANSCRIPTS feature flag and protobuf-backed sync endpoint (/user/transcript/on_demand) for requesting generation.
  • Adds OnDemandTranscriptRepository + ViewModel logic to request once, then poll refresh (15s / max 20 attempts) and surface new UI states (Generating / Delayed / Failed / Unavailable).
  • Updates episode/player surfaces to keep Transcript entry points available for eligible users even when a transcript is initially missing, plus adds/updates tests.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/featureflag/Feature.kt Adds ON_DEMAND_TRANSCRIPTS feature flag definition.
modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncServiceManager.kt Adds manager wrapper to call on-demand transcript sync endpoint.
modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncService.kt Adds Retrofit API method for /user/transcript/on_demand.
modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/ShowNotesServiceManager.kt Exposes show-notes download helper used by transcript metadata refresh.
modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/transcript/OnDemandTranscriptRepository.kt New repository to request generation and refresh local metadata.
modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/transcript/OnDemandTranscriptRepositoryTest.kt Unit tests for HTTP-status → domain outcome mapping.
modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/shownotes/ShowNotesManager.kt Adds refreshTranscriptMetadata used during polling refresh.
modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/di/RepositoryModule.kt Wires OnDemandTranscriptRepository into DI graph.
modules/services/protobuf/src/main/proto/sync_api.proto Adds protobuf request/response + enums for on-demand transcript generation.
modules/services/localization/src/main/res/values/strings.xml Adds new listener-facing strings for generation states (prototype copy).
modules/features/transcripts/src/test/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/TranscriptViewModelTest.kt Adds ViewModel tests covering request-once and refresh/poll behavior.
modules/features/transcripts/src/main/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/ui/TranscriptPage.kt Adds UI for new transcript generation states + lifecycle start/stop hooks.
modules/features/transcripts/src/main/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/TranscriptViewModel.kt Implements on-demand request + visible-only polling refresh and tracking events.
modules/features/transcripts/build.gradle.kts Adds lifecycle-compose runtime dependency for LifecycleStartEffect.
modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragmentViewModel.kt Computes “can request on-demand transcript” eligibility for tab behavior.
modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragment.kt Keeps Transcript tab accessible for eligible users; adjusts tracking params.
modules/features/player/src/test/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfViewModelTest.kt Updates tests for new UserManager dependency.
modules/features/player/src/test/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfSharedViewModelTest.kt Adds tests for transcript availability gating with the new feature flag.
modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfViewModel.kt Marks transcript “available” when eligible for on-demand, even if missing.
modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfSharedViewModel.kt Same eligibility-based transcript availability in shared shelf UI state.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Prevent terminal-state re-entry and reduce polling work while improving outcome handling, previews, and behavioral coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
@stefanosala

Copy link
Copy Markdown
Author

Review follow-up

Fixed

  • Preserve terminal generation states on same-episode re-entry, with regression coverage.
  • Poll immediately, map authentication/throttling/unknown outcomes to accurate retry or delayed states, and update timing tests.
  • Reduce polling from up to ~60 requests to 20 podcast checks plus one show-notes refresh when availability flips.
  • Use a targeted has_generated_transcript update to avoid stale full-row writes and skip unchanged writes.
  • Process the one required show-notes refresh on Dispatchers.IO.
  • Add behavioral repository tests for protobuf mapping, cancellation, update ordering, and missing/unchanged data.
  • Add all four generation-state previews, remove fully-qualified types/duplicate shelf logic, and use the analytics missing-value sentinel consistently.

Not fixed

  • Paid listeners still see the missing-transcript entry point by design: opening it is the approved request flow, while the server owns eligibility.
  • Protobuf enum names remain aligned with the provisional canonical Sync API schema; any namespace rename needs a coordinated server-first contract change.
  • Prototype strings remain translatable because localization is required even before Design finalizes copy for beta.

Validation

  • :modules:features:transcripts:testDebugUnitTest --tests au.com.shiftyjelly.pocketcasts.transcripts.TranscriptViewModelTest
  • :modules:services:repositories:testDebugUnitTest --tests au.com.shiftyjelly.pocketcasts.repositories.transcript.OnDemandTranscriptRepositoryTest
  • spotlessCheck
  • IDE diagnostics: no errors

Commit: 1cea818

@stefanosala
stefanosala requested a lite review from Copilot August 20, 2026 15:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Suppressed comments (2)

modules/features/transcripts/src/main/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/TranscriptViewModel.kt:270

  • When returning to the transcript screen after polling has stopped (e.g., GenerationDelayed/other terminal generation states), loadTranscript() currently short-circuits and onScreenStarted() does not attempt to load an already-generated transcript from local DB. This can leave users stuck on “Check back later” even after the transcript becomes available, until the ViewModel is recreated.

Consider checking for an existing transcript once on screen start when in a terminal generation state (without re-requesting generation).

    fun onScreenStarted() {
        isScreenStarted = true
        if (_uiState.value.transcriptState is TranscriptState.Generating) {
            startGenerationRefresh()
        }

modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/transcript/OnDemandTranscriptRepository.kt:127

  • String.lowercase() is locale-sensitive, which can lead to inconsistent analytics values on some locales (e.g., Turkish locale casing). Analytics keys should be locale-independent.
private val OnDemandTranscriptReason.analyticsValue
    get() = name.lowercase()

private val OnDemandTranscriptEnablement.analyticsValue
    get() = name.lowercase()

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants