From 50558ab46763d228f285aad466314125145b1308 Mon Sep 17 00:00:00 2001 From: Stefano Sala Date: Thu, 20 Aug 2026 13:56:42 +0200 Subject: [PATCH 1/2] feat: add on-demand transcript generation Co-authored-by: Cursor --- .../player/viewmodel/ShelfSharedViewModel.kt | 18 +- .../player/viewmodel/ShelfViewModel.kt | 40 ++- .../viewmodel/ShelfSharedViewModelTest.kt | 33 +++ .../player/viewmodel/ShelfViewModelTest.kt | 8 + .../podcasts/view/episode/EpisodeFragment.kt | 25 +- .../view/episode/EpisodeFragmentViewModel.kt | 9 +- modules/features/transcripts/build.gradle.kts | 1 + .../transcripts/TranscriptViewModel.kt | 263 +++++++++++++++--- .../transcripts/ui/TranscriptPage.kt | 71 +++++ .../transcripts/TranscriptViewModelTest.kt | 210 +++++++++++++- .../src/main/res/values/strings.xml | 5 + .../protobuf/src/main/proto/sync_api.proto | 46 +++ .../repositories/di/RepositoryModule.kt | 7 + .../shownotes/ShowNotesManager.kt | 9 + .../OnDemandTranscriptRepository.kt | 127 +++++++++ .../OnDemandTranscriptRepositoryTest.kt | 42 +++ .../servers/ShowNotesServiceManager.kt | 8 +- .../pocketcasts/servers/sync/SyncService.kt | 9 + .../servers/sync/SyncServiceManager.kt | 14 + .../pocketcasts/utils/featureflag/Feature.kt | 9 + 20 files changed, 902 insertions(+), 52 deletions(-) create mode 100644 modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/transcript/OnDemandTranscriptRepository.kt create mode 100644 modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/transcript/OnDemandTranscriptRepositoryTest.kt diff --git a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfSharedViewModel.kt b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfSharedViewModel.kt index b284645dcac..9c14e12e7f2 100644 --- a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfSharedViewModel.kt +++ b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfSharedViewModel.kt @@ -19,6 +19,7 @@ import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.UserEpisodeManager import au.com.shiftyjelly.pocketcasts.repositories.transcript.TranscriptManager +import au.com.shiftyjelly.pocketcasts.repositories.user.UserManager import au.com.shiftyjelly.pocketcasts.settings.onboarding.OnboardingUpgradeSource import au.com.shiftyjelly.pocketcasts.ui.theme.Theme import au.com.shiftyjelly.pocketcasts.utils.featureflag.Feature @@ -46,7 +47,8 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.coroutines.rx2.asFlow +import kotlinx.coroutines.reactive.asFlow as asReactiveFlow +import kotlinx.coroutines.rx2.asFlow as asRxFlow @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel @@ -60,6 +62,7 @@ class ShelfSharedViewModel @Inject constructor( private val settings: Settings, private val userEpisodeManager: UserEpisodeManager, private val transcriptManager: TranscriptManager, + private val userManager: UserManager, private val downloadQueue: DownloadQueue, ) : ViewModel() { private val upNextStateObservable: Observable = @@ -102,11 +105,12 @@ class ShelfSharedViewModel @Inject constructor( val uiState = combine( settings.shelfItems.flow, - shelfUpNextObservable.asFlow(), - shelfUpNextObservable.asFlow() + shelfUpNextObservable.asRxFlow(), + shelfUpNextObservable.asRxFlow() .mapNotNull { state -> (state as? UpNextQueue.State.Loaded)?.episode?.uuid } .flatMapLatest { episodeUuid -> transcriptManager.observeIsTranscriptAvailable(episodeUuid) }, videoStateFlow, + userManager.getSignInState().asReactiveFlow(), ::createUiState, ).stateIn( viewModelScope, @@ -119,6 +123,7 @@ class ShelfSharedViewModel @Inject constructor( shelfUpNext: UpNextQueue.State, isTranscriptAvailable: Boolean, videoState: VideoState, + signInState: au.com.shiftyjelly.pocketcasts.models.type.SignInState, ): UiState { val episode = (shelfUpNext as? UpNextQueue.State.Loaded)?.episode val streamHasVideo = videoState.streamVideoState == StreamVideoState.HasVideo || videoState.streamVideoState == StreamVideoState.Unknown @@ -129,7 +134,12 @@ class ShelfSharedViewModel @Inject constructor( return uiState.value.copy( shelfItems = shelfItems.filter { it.showIf(episode) && (it != ShelfItem.StreamSelector || canToggleVideo) }, episode = episode, - isTranscriptAvailable = isTranscriptAvailable, + isTranscriptAvailable = isTranscriptAvailable || + ( + episode is PodcastEpisode && + signInState.isSignedInAsPlusOrPatron && + FeatureFlag.isEnabled(Feature.ON_DEMAND_TRANSCRIPTS) + ), isVideoRenderingEnabled = videoState.renderingEnabled && streamHasVideo, ) } diff --git a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfViewModel.kt b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfViewModel.kt index ed42560c232..41a66fb139e 100644 --- a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfViewModel.kt +++ b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfViewModel.kt @@ -9,6 +9,9 @@ import au.com.shiftyjelly.pocketcasts.preferences.model.ShelfItem import au.com.shiftyjelly.pocketcasts.preferences.model.ShelfRowItem import au.com.shiftyjelly.pocketcasts.preferences.model.ShelfTitle import au.com.shiftyjelly.pocketcasts.repositories.transcript.TranscriptManager +import au.com.shiftyjelly.pocketcasts.repositories.user.UserManager +import au.com.shiftyjelly.pocketcasts.utils.featureflag.Feature +import au.com.shiftyjelly.pocketcasts.utils.featureflag.FeatureFlag import com.automattic.eventhorizon.EventHorizon import com.automattic.eventhorizon.PlayerShelfOverflowMenuRearrangeActionMovedEvent import com.automattic.eventhorizon.PlayerShelfOverflowMenuRearrangeFinishedEvent @@ -22,9 +25,11 @@ import java.util.Collections import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.reactive.asFlow import timber.log.Timber import au.com.shiftyjelly.pocketcasts.localization.R as LR @@ -33,18 +38,28 @@ class ShelfViewModel @AssistedInject constructor( @Assisted private val episodeId: String, @Assisted private val isEditable: Boolean, private val transcriptManager: TranscriptManager, + private val userManager: UserManager, private val eventHorizon: EventHorizon, private val settings: Settings, ) : ViewModel() { private var _uiState: MutableStateFlow = MutableStateFlow(UiState()) val uiState: StateFlow = _uiState + private var hasTranscriptRow = false + private var isEligiblePaidListener = false init { viewModelScope.launch { - transcriptManager.observeIsTranscriptAvailable(episodeId) + combine( + transcriptManager.observeIsTranscriptAvailable(episodeId), + userManager.getSignInState().asFlow(), + ) { isAvailable, signInState -> + isAvailable to signInState.isSignedInAsPlusOrPatron + } .stateIn(viewModelScope) - .collectLatest { isAvailable -> - _uiState.update { it.copy(isTranscriptAvailable = isAvailable) } + .collectLatest { (isAvailable, isPaid) -> + hasTranscriptRow = isAvailable + isEligiblePaidListener = isPaid + updateTranscriptAvailability() } } } @@ -70,6 +85,25 @@ class ShelfViewModel @AssistedInject constructor( items }, episode = episode, + isTranscriptAvailable = hasTranscriptRow || + ( + episode is au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode && + isEligiblePaidListener && + FeatureFlag.isEnabled(Feature.ON_DEMAND_TRANSCRIPTS) + ), + ) + } + } + + private fun updateTranscriptAvailability() { + _uiState.update { state -> + state.copy( + isTranscriptAvailable = hasTranscriptRow || + ( + state.episode is au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode && + isEligiblePaidListener && + FeatureFlag.isEnabled(Feature.ON_DEMAND_TRANSCRIPTS) + ), ) } } diff --git a/modules/features/player/src/test/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfSharedViewModelTest.kt b/modules/features/player/src/test/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfSharedViewModelTest.kt index c7403021550..ed91493ccb2 100644 --- a/modules/features/player/src/test/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfSharedViewModelTest.kt +++ b/modules/features/player/src/test/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfSharedViewModelTest.kt @@ -9,6 +9,7 @@ import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode import au.com.shiftyjelly.pocketcasts.models.entity.UserEpisode import au.com.shiftyjelly.pocketcasts.models.to.Chapter import au.com.shiftyjelly.pocketcasts.models.to.Chapters +import au.com.shiftyjelly.pocketcasts.models.type.SignInState import au.com.shiftyjelly.pocketcasts.models.type.Subscription import au.com.shiftyjelly.pocketcasts.models.type.SubscriptionPlatform import au.com.shiftyjelly.pocketcasts.payment.BillingCycle @@ -28,6 +29,7 @@ import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.UserEpisodeManager import au.com.shiftyjelly.pocketcasts.repositories.transcript.TranscriptManager +import au.com.shiftyjelly.pocketcasts.repositories.user.UserManager import au.com.shiftyjelly.pocketcasts.settings.onboarding.OnboardingUpgradeSource import au.com.shiftyjelly.pocketcasts.sharedtest.InMemoryFeatureFlagRule import au.com.shiftyjelly.pocketcasts.sharedtest.MainCoroutineRule @@ -41,7 +43,9 @@ import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.rx2.asFlowable import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -86,6 +90,9 @@ class ShelfSharedViewModelTest { @Mock private lateinit var settings: Settings + @Mock + private lateinit var userManager: UserManager + @Mock private lateinit var upNextQueue: UpNextQueue @@ -188,6 +195,28 @@ class ShelfSharedViewModelTest { } } + @Test + fun `paid listener can open missing podcast transcript when feature is enabled`() = runTest { + FeatureFlag.setEnabled(Feature.ON_DEMAND_TRANSCRIPTS, true) + val episode = PodcastEpisode("uuid", publishedDate = Date()) + initViewModel(subscription = plusSubscription, currentEpisode = episode) + + val state = shelfSharedViewModel.uiState.first { it.episode != null } + + assertTrue(state.isTranscriptAvailable) + } + + @Test + fun `free listener cannot open missing podcast transcript`() = runTest { + FeatureFlag.setEnabled(Feature.ON_DEMAND_TRANSCRIPTS, true) + val episode = PodcastEpisode("uuid", publishedDate = Date()) + initViewModel(subscription = null, currentEpisode = episode) + + val state = shelfSharedViewModel.uiState.first { it.episode != null } + + assertFalse(state.isTranscriptAvailable) + } + @Test fun `when download button clicked, then episode download started snackbar message is shown`() = runTest { initViewModel() @@ -450,6 +479,9 @@ class ShelfSharedViewModelTest { val userSubscriptionSetting = mock>() whenever(userSubscriptionSetting.value).thenReturn(subscription) whenever(settings.cachedSubscription).thenReturn(userSubscriptionSetting) + whenever(userManager.getSignInState()).thenReturn( + flowOf(SignInState.SignedIn("email", subscription)).asFlowable(), + ) whenever(playbackManager.streamVideoState).thenReturn(MutableStateFlow(streamVideoState)) whenever(playbackManager.streamHlsAvailable).thenReturn(MutableStateFlow(hlsAvailable)) @@ -469,6 +501,7 @@ class ShelfSharedViewModelTest { settings = settings, userEpisodeManager = userEpisodeManager, transcriptManager = transcriptManager, + userManager = userManager, downloadQueue = mock(), ) } diff --git a/modules/features/player/src/test/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfViewModelTest.kt b/modules/features/player/src/test/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfViewModelTest.kt index 2479e4390db..de63094cffc 100644 --- a/modules/features/player/src/test/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfViewModelTest.kt +++ b/modules/features/player/src/test/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfViewModelTest.kt @@ -4,6 +4,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import au.com.shiftyjelly.pocketcasts.analytics.testing.TestEventSink import au.com.shiftyjelly.pocketcasts.models.converter.SafeDate import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode +import au.com.shiftyjelly.pocketcasts.models.type.SignInState import au.com.shiftyjelly.pocketcasts.player.viewmodel.ShelfViewModel.Companion.ERROR_MINIMUM_SHELF_ITEMS import au.com.shiftyjelly.pocketcasts.player.viewmodel.ShelfViewModel.Companion.ERROR_SHELF_ITEM_INVALID_MOVE_POSITION import au.com.shiftyjelly.pocketcasts.preferences.Settings @@ -11,12 +12,14 @@ import au.com.shiftyjelly.pocketcasts.preferences.UserSetting import au.com.shiftyjelly.pocketcasts.preferences.model.ShelfItem import au.com.shiftyjelly.pocketcasts.preferences.model.ShelfRowItem import au.com.shiftyjelly.pocketcasts.repositories.transcript.TranscriptManager +import au.com.shiftyjelly.pocketcasts.repositories.user.UserManager import au.com.shiftyjelly.pocketcasts.sharedtest.MainCoroutineRule import com.automattic.eventhorizon.EventHorizon import com.automattic.eventhorizon.PlayerShelfOverflowMenuRearrangeActionMovedEvent import com.automattic.eventhorizon.ShelfActionSourceType import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.rx2.asFlowable import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Rule @@ -42,6 +45,9 @@ class ShelfViewModelTest { @Mock private lateinit var settings: Settings + @Mock + private lateinit var userManager: UserManager + private val eventSink = TestEventSink() private lateinit var shelfViewModel: ShelfViewModel @@ -215,11 +221,13 @@ class ShelfViewModelTest { whenever(transcriptManager.observeIsTranscriptAvailable(episodeId)).thenReturn(flowOf(true)) val userSetting = mock>>() whenever(settings.shelfItems).thenReturn(userSetting) + whenever(userManager.getSignInState()).thenReturn(flowOf(SignInState.SignedOut).asFlowable()) shelfViewModel = ShelfViewModel( episodeId = episodeId, isEditable = isEditable, transcriptManager = transcriptManager, + userManager = userManager, eventHorizon = EventHorizon(eventSink), settings = settings, ) diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragment.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragment.kt index bb51c2328cc..967d5d7a394 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragment.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragment.kt @@ -796,7 +796,12 @@ class EpisodeFragment : BaseFragment() { } } - val tabs = buildMergedTabs(transcript, summaryText, hasChapters) + val tabs = buildMergedTabs( + transcript = transcript, + summaryText = summaryText, + hasChapters = hasChapters, + canRequestOnDemandTranscript = pageState.canRequestOnDemandTranscript, + ) val isSelectedTabAvailable = tabs.any { it.labelResId == selectedTab.labelResId } LaunchedEffect(isSelectedTabAvailable) { @@ -1080,7 +1085,12 @@ class EpisodeFragment : BaseFragment() { if (isSummaryEnabled) { val chaptersState = chaptersViewModel.uiState.collectAsState().value val hasChapters = chaptersState.chaptersCount > 0 - val tabs = buildMergedTabs(transcript, summaryText, hasChapters) + val tabs = buildMergedTabs( + transcript = transcript, + summaryText = summaryText, + hasChapters = hasChapters, + canRequestOnDemandTranscript = pageState.canRequestOnDemandTranscript, + ) val askTheEpisodeVisible = FeatureFlag.isEnabled(Feature.EPISODE_CHAT) && transcript != null Column(modifier = Modifier.fillMaxWidth()) { @@ -1177,6 +1187,7 @@ class EpisodeFragment : BaseFragment() { transcript: Transcript.Text?, summaryText: String?, hasChapters: Boolean, + canRequestOnDemandTranscript: Boolean, ): List { val tabClickHandlers = mapOf Unit>( LR.string.details to { viewModel.selectContentTab(EpisodeContentTab.DESCRIPTION) }, @@ -1186,12 +1197,14 @@ class EpisodeFragment : BaseFragment() { }, LR.string.bookmarks to { viewModel.selectContentTab(EpisodeContentTab.BOOKMARKS) }, LR.string.transcript to { - if (transcript != null) { + if (transcript != null || canRequestOnDemandTranscript) { viewModel.selectContentTab(EpisodeContentTab.TRANSCRIPT) eventHorizon.track( EpisodeDetailTranscriptCardTappedEvent( - episodeUuid = transcript.episodeUuid, - podcastUuid = transcript.podcastUuid ?: AnalyticsTracker.INVALID_OR_NULL_VALUE, + episodeUuid = transcript?.episodeUuid ?: viewModel.episode?.uuid.orEmpty(), + podcastUuid = transcript?.podcastUuid + ?: viewModel.podcast?.uuid + ?: AnalyticsTracker.INVALID_OR_NULL_VALUE, ), ) } @@ -1199,7 +1212,7 @@ class EpisodeFragment : BaseFragment() { LR.string.summary to { viewModel.selectContentTab(EpisodeContentTab.SUMMARY) }, ) return mergedTabLabelResIds( - hasTranscript = transcript != null, + hasTranscript = transcript != null || canRequestOnDemandTranscript, hasSummary = summaryText != null, hasChapters = hasChapters, ).map { labelResId -> diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragmentViewModel.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragmentViewModel.kt index 5d08d07e8d0..526790fdf5c 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragmentViewModel.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragmentViewModel.kt @@ -118,6 +118,9 @@ class EpisodeFragmentViewModel @Inject constructor( val episodePublishedDate: Date? = null, val episodeDurationMs: Long? = null, ) { + val canRequestOnDemandTranscript + get() = isPlusUser && FeatureFlag.isEnabled(Feature.ON_DEMAND_TRANSCRIPTS) + internal fun selectContentTab(tab: EpisodeContentTab): EpisodePageState { val contentTab = when (tab) { EpisodeContentTab.DESCRIPTION -> EpisodeContentTab.DESCRIPTION @@ -138,7 +141,11 @@ class EpisodeFragmentViewModel @Inject constructor( } internal fun withTranscript(transcript: Transcript?): EpisodePageState { - val contentTab = if (transcript == null && selectedContentTab == EpisodeContentTab.TRANSCRIPT) { + val contentTab = if ( + transcript == null && + selectedContentTab == EpisodeContentTab.TRANSCRIPT && + !canRequestOnDemandTranscript + ) { EpisodeContentTab.DESCRIPTION } else { selectedContentTab diff --git a/modules/features/transcripts/build.gradle.kts b/modules/features/transcripts/build.gradle.kts index ac37eabbf0b..e3d486570d5 100644 --- a/modules/features/transcripts/build.gradle.kts +++ b/modules/features/transcripts/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { implementation(libs.coroutines.reactive) implementation(libs.coroutines.rx2) implementation(libs.fragment.ktx) + implementation(libs.lifecycle.runtime.compose) implementation(libs.rx2.java) implementation(projects.modules.features.settings) diff --git a/modules/features/transcripts/src/main/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/TranscriptViewModel.kt b/modules/features/transcripts/src/main/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/TranscriptViewModel.kt index 496f6dec9ea..bb9c48fe74c 100644 --- a/modules/features/transcripts/src/main/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/TranscriptViewModel.kt +++ b/modules/features/transcripts/src/main/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/TranscriptViewModel.kt @@ -15,9 +15,12 @@ import au.com.shiftyjelly.pocketcasts.repositories.fingerprint.ChapterSeekResult import au.com.shiftyjelly.pocketcasts.repositories.fingerprint.FingerprintTimingManager import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager +import au.com.shiftyjelly.pocketcasts.repositories.transcript.OnDemandTranscriptRepository import au.com.shiftyjelly.pocketcasts.repositories.transcript.TranscriptManager import au.com.shiftyjelly.pocketcasts.repositories.user.UserManager import au.com.shiftyjelly.pocketcasts.sharing.SharingRequest +import au.com.shiftyjelly.pocketcasts.utils.featureflag.Feature +import au.com.shiftyjelly.pocketcasts.utils.featureflag.FeatureFlag import au.com.shiftyjelly.pocketcasts.utils.search.SearchCoordinates import au.com.shiftyjelly.pocketcasts.utils.search.SearchMatches import au.com.shiftyjelly.pocketcasts.utils.search.kmpSearch @@ -38,6 +41,8 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -46,16 +51,20 @@ import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.reactive.asFlow +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize @HiltViewModel(assistedFactory = TranscriptViewModel.Factory::class) class TranscriptViewModel @AssistedInject constructor( @Assisted private val source: Source, private val transcriptManager: TranscriptManager, + private val onDemandTranscriptRepository: OnDemandTranscriptRepository, private val episodeManager: EpisodeManager, private val userManager: UserManager, private val paymentClient: PaymentClient, @@ -96,9 +105,27 @@ class TranscriptViewModel @AssistedInject constructor( private var podcastUuid: String? = null private var loadTranscriptJob: Job? = null + private var generationRefreshJob: Job? = null private var searchJob: Job? = null + private var requestedEpisodeUuid: String? = null + private var generationRefreshAttempts = 0 + private var generationStartedAtNanos: Long? = null + private var isScreenStarted = false fun loadTranscript(episodeUuid: String) { + if ( + this.episodeUuid == episodeUuid && + (loadTranscriptJob?.isActive == true || _uiState.value.transcriptState is TranscriptState.Generating) + ) { + return + } + if (this.episodeUuid != episodeUuid) { + generationRefreshJob?.cancel() + generationRefreshJob = null + requestedEpisodeUuid = null + generationRefreshAttempts = 0 + generationStartedAtNanos = null + } loadTranscriptJob?.cancel() syncedStateJob?.cancel() loadTranscriptJob = viewModelScope.launch { @@ -113,49 +140,159 @@ class TranscriptViewModel @AssistedInject constructor( } updateEpisodeMetadata(episodeUuid) - val transcriptState = when (val transcript = transcriptManager.loadTranscript(episodeUuid)) { - is Transcript.Text -> if (transcript.entries.isNotEmpty()) { - TranscriptState.Loaded(transcript) - } else { - track { source, podcastUuid, episodeUuid -> - TranscriptErrorEvent( - podcastUuid = podcastUuid, - episodeUuid = episodeUuid, - source = source, - ) - } - TranscriptState.NoContent - } + val isEligibleForOnDemand = FeatureFlag.isEnabled(Feature.ON_DEMAND_TRANSCRIPTS) && + userManager.getSignInState().asFlow().first().isSignedInAsPlusOrPatron + val hasExistingTranscript = loadExistingTranscript( + episodeUuid = episodeUuid, + showMissingFailure = !isEligibleForOnDemand, + ) + if (!hasExistingTranscript && isEligibleForOnDemand) { + requestOnDemandTranscript(episodeUuid) + } + } + } - is Transcript.Web -> { - TranscriptState.Loaded(transcript) + private suspend fun loadExistingTranscript( + episodeUuid: String, + showMissingFailure: Boolean = true, + ): Boolean { + val transcript = transcriptManager.loadTranscript(episodeUuid) + if (transcript == null) { + if (showMissingFailure) { + track { source, podcastUuid, episodeUuid -> + TranscriptErrorEvent( + podcastUuid = podcastUuid, + episodeUuid = episodeUuid, + source = source, + ) } - - null -> { - track { source, podcastUuid, episodeUuid -> - TranscriptErrorEvent( - podcastUuid = podcastUuid, - episodeUuid = episodeUuid, - source = source, - ) - } - TranscriptState.Failure + _uiState.update { state -> state.copy(transcriptState = TranscriptState.Failure) } + } + return false + } + val transcriptState = when (transcript) { + is Transcript.Text -> if (transcript.entries.isNotEmpty()) { + TranscriptState.Loaded(transcript) + } else { + track { source, podcastUuid, episodeUuid -> + TranscriptErrorEvent( + podcastUuid = podcastUuid, + episodeUuid = episodeUuid, + source = source, + ) } + TranscriptState.NoContent + } + + is Transcript.Web -> { + TranscriptState.Loaded(transcript) + } + } + _uiState.update { state -> state.copy(transcriptState = transcriptState) } + + if (transcriptState is TranscriptState.Loaded) { + trackTranscriptShown(transcriptState.transcript) + } + + if (transcriptState is TranscriptState.Loaded && transcriptState.transcript is Transcript.Text) { + val currentPlayingUuid = playbackManager.getCurrentEpisode()?.uuid + if (currentPlayingUuid == episodeUuid) { + fingerprintTimingManager.prepareForCurrentEpisode(FingerprintTimingManager.PrepareTrigger.TRANSCRIPT_VIEW) + _uiState.update { state -> state.copy(syncedState = fingerprintTimingManager.state) } + observeSyncedState() } - _uiState.update { state -> state.copy(transcriptState = transcriptState) } + } + return true + } - if (transcriptState is TranscriptState.Loaded) { - trackTranscriptShown(transcriptState.transcript) + private suspend fun requestOnDemandTranscript(episodeUuid: String) { + if (requestedEpisodeUuid == episodeUuid) { + if (_uiState.value.transcriptState is TranscriptState.Generating) { + startGenerationRefresh() + } + return + } + val podcastUuid = podcastUuid ?: run { + _uiState.update { it.copy(transcriptState = TranscriptState.GenerationFailed) } + return + } + requestedEpisodeUuid = episodeUuid + generationRefreshAttempts = 0 + generationStartedAtNanos = System.nanoTime() + val result = onDemandTranscriptRepository.request(podcastUuid, episodeUuid) + track { source, trackedPodcastUuid, trackedEpisodeUuid -> + OnDemandTranscriptRequestedEvent( + outcome = result.outcome.name.lowercase(), + reason = result.reason, + enablement = result.enablement, + newlyQueuedCount = result.newlyQueuedCount, + podcastUuid = trackedPodcastUuid, + episodeUuid = trackedEpisodeUuid, + source = source, + ) + } + when (result.outcome) { + OnDemandTranscriptRepository.Outcome.Queued, + OnDemandTranscriptRepository.Outcome.InProgress, + OnDemandTranscriptRepository.Outcome.Available, + -> { + _uiState.update { it.copy(transcriptState = TranscriptState.Generating) } + startGenerationRefresh() } - if (transcriptState is TranscriptState.Loaded && transcriptState.transcript is Transcript.Text) { - val currentPlayingUuid = playbackManager.getCurrentEpisode()?.uuid - if (currentPlayingUuid == episodeUuid) { - fingerprintTimingManager.prepareForCurrentEpisode(FingerprintTimingManager.PrepareTrigger.TRANSCRIPT_VIEW) - _uiState.update { state -> state.copy(syncedState = fingerprintTimingManager.state) } - observeSyncedState() + OnDemandTranscriptRepository.Outcome.NotEligible, + OnDemandTranscriptRepository.Outcome.Throttled, + OnDemandTranscriptRepository.Outcome.Unknown, + -> _uiState.update { it.copy(transcriptState = TranscriptState.GenerationUnavailable) } + + OnDemandTranscriptRepository.Outcome.TransientFailure -> + _uiState.update { it.copy(transcriptState = TranscriptState.GenerationFailed) } + } + } + + fun onScreenStarted() { + isScreenStarted = true + if (_uiState.value.transcriptState is TranscriptState.Generating) { + startGenerationRefresh() + } + } + + fun onScreenStopped() { + isScreenStarted = false + generationRefreshJob?.cancel() + generationRefreshJob = null + } + + private fun startGenerationRefresh() { + if (!isScreenStarted || generationRefreshJob?.isActive == true) return + val episodeUuid = episodeUuid ?: return + val podcastUuid = podcastUuid ?: return + generationRefreshJob = viewModelScope.launch { + while (generationRefreshAttempts < GENERATION_REFRESH_MAX_ATTEMPTS) { + delay(GENERATION_REFRESH_INTERVAL) + generationRefreshAttempts++ + try { + onDemandTranscriptRepository.refreshMetadata(podcastUuid, episodeUuid) + } catch (error: Exception) { + if (error is CancellationException) throw error + } + if (transcriptManager.observeIsTranscriptAvailable(episodeUuid).first()) { + transcriptManager.resetInvalidTranscripts(episodeUuid) + val elapsedSeconds = generationStartedAtNanos + ?.let { startedAt -> (System.nanoTime() - startedAt) / 1_000_000_000 } + track { source, trackedPodcastUuid, trackedEpisodeUuid -> + OnDemandTranscriptReadyEvent( + elapsedSeconds = elapsedSeconds, + podcastUuid = trackedPodcastUuid, + episodeUuid = trackedEpisodeUuid, + source = source, + ) + } + loadExistingTranscript(episodeUuid) + return@launch } } + _uiState.update { it.copy(transcriptState = TranscriptState.GenerationDelayed) } } } @@ -296,6 +433,7 @@ class TranscriptViewModel @AssistedInject constructor( override fun onCleared() { super.onCleared() loadTranscriptJob?.cancel() + generationRefreshJob?.cancel() syncedStateJob?.cancel() } @@ -306,6 +444,8 @@ class TranscriptViewModel @AssistedInject constructor( episodeUuid?.let { uuid -> transcriptManager.resetInvalidTranscripts(uuid) + requestedEpisodeUuid = null + generationRefreshAttempts = 0 loadTranscript(uuid) } } @@ -558,6 +698,14 @@ sealed interface TranscriptState { data object NoContent : TranscriptState data object Failure : TranscriptState + + data object Generating : TranscriptState + + data object GenerationUnavailable : TranscriptState + + data object GenerationFailed : TranscriptState + + data object GenerationDelayed : TranscriptState } data class SearchState( @@ -576,3 +724,50 @@ data class SearchState( ) } } + +private const val GENERATION_REFRESH_MAX_ATTEMPTS = 20 +private val GENERATION_REFRESH_INTERVAL = 15.seconds + +@Parcelize +private data class OnDemandTranscriptRequestedEvent( + val outcome: String, + val reason: String, + val enablement: String, + val newlyQueuedCount: Int, + val podcastUuid: String, + val episodeUuid: String, + val source: TranscriptSourceType, +) : Trackable { + @IgnoredOnParcel + override val analyticsName = "transcript_on_demand_requested" + + @IgnoredOnParcel + override val analyticsProperties = mapOf( + "outcome" to outcome, + "reason" to reason, + "enablement" to enablement, + "newly_queued_count" to newlyQueuedCount, + "podcast_uuid" to podcastUuid, + "episode_uuid" to episodeUuid, + "source" to source.toString().lowercase(), + ) +} + +@Parcelize +private data class OnDemandTranscriptReadyEvent( + val elapsedSeconds: Long?, + val podcastUuid: String, + val episodeUuid: String, + val source: TranscriptSourceType, +) : Trackable { + @IgnoredOnParcel + override val analyticsName = "transcript_on_demand_ready" + + @IgnoredOnParcel + override val analyticsProperties = buildMap { + elapsedSeconds?.let { put("elapsed_seconds", it) } + put("podcast_uuid", podcastUuid) + put("episode_uuid", episodeUuid) + put("source", source.toString().lowercase()) + } +} diff --git a/modules/features/transcripts/src/main/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/ui/TranscriptPage.kt b/modules/features/transcripts/src/main/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/ui/TranscriptPage.kt index 9d3b316b6d7..2b193526c41 100644 --- a/modules/features/transcripts/src/main/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/ui/TranscriptPage.kt +++ b/modules/features/transcripts/src/main/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/ui/TranscriptPage.kt @@ -4,6 +4,7 @@ import android.os.SystemClock import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -29,7 +30,13 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleStartEffect +import au.com.shiftyjelly.pocketcasts.compose.components.TextH30 import au.com.shiftyjelly.pocketcasts.compose.loading.LoadingView import au.com.shiftyjelly.pocketcasts.models.to.Transcript import au.com.shiftyjelly.pocketcasts.models.to.TranscriptEntry @@ -77,6 +84,15 @@ fun TranscriptPage( val theme = rememberTranscriptTheme() val listState = rememberLazyListState() + if (viewModel != null) { + LifecycleStartEffect(viewModel) { + viewModel.onScreenStarted() + onStopOrDispose { + viewModel.onScreenStopped() + } + } + } + val syncableEpisodeUuid = uiState.transcriptEpisodeUuid.takeIf { uiState.isTextTranscriptLoaded } DisposableEffect(fingerprintTimingManager, syncableEpisodeUuid) { if (syncableEpisodeUuid != null) { @@ -331,6 +347,61 @@ private fun TranscriptContent( modifier = modifier.fillMaxSize(), ) } + + is TranscriptState.Generating -> { + TranscriptGeneratingContent( + color = theme.primaryText, + modifier = modifier.fillMaxSize(), + ) + } + + TranscriptState.GenerationUnavailable -> { + TranscriptFailureContent( + description = stringResource(LR.string.transcript_generation_unavailable), + colors = theme.failureColors, + modifier = modifier.fillMaxSize(), + ) + } + + TranscriptState.GenerationFailed -> { + TranscriptFailureContent( + description = stringResource(LR.string.transcript_generation_failed), + colors = theme.failureColors, + buttonLabel = stringResource(LR.string.try_again), + onClickButton = onClickReload, + modifier = modifier.fillMaxSize(), + ) + } + + TranscriptState.GenerationDelayed -> { + TranscriptFailureContent( + description = stringResource(LR.string.transcript_generation_delayed), + colors = theme.failureColors, + modifier = modifier.fillMaxSize(), + ) + } + } +} + +@Composable +private fun TranscriptGeneratingContent( + color: androidx.compose.ui.graphics.Color, + modifier: Modifier = Modifier, +) { + val message = stringResource(LR.string.transcript_generation_started) + Column( + modifier = modifier + .semantics { liveRegion = LiveRegionMode.Polite } + .padding(32.dp), + verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + LoadingView(color = color) + TextH30( + text = message, + color = color, + textAlign = TextAlign.Center, + ) } } diff --git a/modules/features/transcripts/src/test/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/TranscriptViewModelTest.kt b/modules/features/transcripts/src/test/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/TranscriptViewModelTest.kt index 5d38f6fa7dc..cd930437dec 100644 --- a/modules/features/transcripts/src/test/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/TranscriptViewModelTest.kt +++ b/modules/features/transcripts/src/test/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/TranscriptViewModelTest.kt @@ -15,6 +15,7 @@ import au.com.shiftyjelly.pocketcasts.repositories.fingerprint.FingerprintTiming import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackState import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager +import au.com.shiftyjelly.pocketcasts.repositories.transcript.OnDemandTranscriptRepository import au.com.shiftyjelly.pocketcasts.repositories.transcript.TranscriptManager import au.com.shiftyjelly.pocketcasts.sharedtest.MainCoroutineRule import au.com.shiftyjelly.pocketcasts.sharing.SharingRequest @@ -25,14 +26,15 @@ import com.automattic.eventhorizon.EventHorizon import com.automattic.eventhorizon.SyncedTranscriptsAutoScrollResumedEvent import com.automattic.eventhorizon.SyncedTranscriptsSeekFailedEvent import com.automattic.eventhorizon.SyncedTranscriptsSeekUsedEvent +import com.automattic.eventhorizon.TranscriptErrorEvent import com.automattic.eventhorizon.TranscriptSourceType import java.util.Date +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch import kotlinx.coroutines.rx2.asFlowable import kotlinx.coroutines.test.advanceTimeBy @@ -63,6 +65,7 @@ class TranscriptViewModelTest { val coroutineRule = MainCoroutineRule() private val transcriptManager = TestTranscriptManager() + private val onDemandTranscriptRepository = TestOnDemandTranscriptRepository() private val signInStateFlow = MutableStateFlow(SignInState.SignedOut) private val playbackStateFlow = MutableStateFlow(PlaybackState(episodeUuid = "")) private val syncedStateFlow = MutableStateFlow(FingerprintTimingManager.State.Idle) @@ -84,6 +87,7 @@ class TranscriptViewModelTest { fun setUp() { viewModel = TranscriptViewModel( transcriptManager = transcriptManager, + onDemandTranscriptRepository = onDemandTranscriptRepository, episodeManager = episodeManager, userManager = mock { on { getSignInState() } doReturn signInStateFlow.asFlowable() @@ -163,6 +167,180 @@ class TranscriptViewModelTest { } } + @Test + fun `paid listener requests missing transcript once and sees generating state`() = runTest { + signInStateFlow.value = SignInState.SignedIn("email", Subscription.PlusPreview) + transcriptManager.isAvailable.value = false + transcriptManager.shouldLoadTranscripts = false + whenever(episodeManager.findByUuid("episode-uuid")).thenReturn( + PodcastEpisode(uuid = "episode-uuid", publishedDate = Date(), podcastUuid = "podcast-uuid"), + ) + + viewModel.loadTranscript("episode-uuid") + runCurrent() + viewModel.loadTranscript("episode-uuid") + runCurrent() + + assertEquals(TranscriptState.Generating, viewModel.uiState.value.transcriptState) + assertEquals(1, transcriptManager.loadCount) + assertEquals(1, onDemandTranscriptRepository.requestCount) + assertFalse(eventSink.pollEvent() is TranscriptErrorEvent) + assertTrue(eventSink.isEmpty()) + } + + @Test + fun `paid listener loads creator transcript before considering on demand generation`() = runTest { + signInStateFlow.value = SignInState.SignedIn("email", Subscription.PlusPreview) + transcriptManager.isAvailable.value = false + whenever(episodeManager.findByUuid("episode-uuid")).thenReturn( + PodcastEpisode(uuid = "episode-uuid", publishedDate = Date(), podcastUuid = "podcast-uuid"), + ) + + viewModel.loadTranscript("episode-uuid") + runCurrent() + + assertEquals(TranscriptState.Loaded(transcriptManager.avaiableTranscript), viewModel.uiState.value.transcriptState) + assertEquals(1, transcriptManager.loadCount) + assertEquals(0, onDemandTranscriptRepository.requestCount) + assertFalse(eventSink.pollEvent() is TranscriptErrorEvent) + assertTrue(eventSink.isEmpty()) + } + + @Test + fun `free listener does not request missing transcript`() = runTest { + signInStateFlow.value = SignInState.SignedIn("email", subscription = null) + transcriptManager.isAvailable.value = false + transcriptManager.shouldLoadTranscripts = false + + viewModel.loadTranscript("episode-uuid") + advanceTimeBy(60.seconds) + runCurrent() + + assertEquals(TranscriptState.Failure, viewModel.uiState.value.transcriptState) + assertEquals(0, onDemandTranscriptRepository.requestCount) + } + + @Test + fun `generation refresh loads transcript when Room reports availability`() = runTest { + signInStateFlow.value = SignInState.SignedIn("email", Subscription.PatronPreview) + transcriptManager.isAvailable.value = false + transcriptManager.shouldLoadTranscripts = false + whenever(episodeManager.findByUuid("episode-uuid")).thenReturn( + PodcastEpisode(uuid = "episode-uuid", publishedDate = Date(), podcastUuid = "podcast-uuid"), + ) + onDemandTranscriptRepository.onRefresh = { + transcriptManager.isAvailable.value = true + } + + viewModel.onScreenStarted() + viewModel.loadTranscript("episode-uuid") + runCurrent() + advanceTimeBy(15.seconds) + runCurrent() + + assertTrue(viewModel.uiState.value.transcriptState is TranscriptState.Loaded) + assertEquals(1, onDemandTranscriptRepository.refreshCount) + } + + @Test + fun `refresh pauses while screen is stopped`() = runTest { + signInStateFlow.value = SignInState.SignedIn("email", Subscription.PlusPreview) + transcriptManager.isAvailable.value = false + transcriptManager.shouldLoadTranscripts = false + whenever(episodeManager.findByUuid("episode-uuid")).thenReturn( + PodcastEpisode(uuid = "episode-uuid", publishedDate = Date(), podcastUuid = "podcast-uuid"), + ) + + viewModel.loadTranscript("episode-uuid") + runCurrent() + advanceTimeBy(30.seconds) + runCurrent() + assertEquals(0, onDemandTranscriptRepository.refreshCount) + + viewModel.onScreenStarted() + advanceTimeBy(15.seconds) + runCurrent() + assertEquals(1, onDemandTranscriptRepository.refreshCount) + + viewModel.onScreenStopped() + advanceTimeBy(30.seconds) + runCurrent() + assertEquals(1, onDemandTranscriptRepository.refreshCount) + } + + @Test + fun `transient request failure does not show generating`() = runTest { + signInStateFlow.value = SignInState.SignedIn("email", Subscription.PlusPreview) + transcriptManager.isAvailable.value = false + transcriptManager.shouldLoadTranscripts = false + onDemandTranscriptRepository.outcome = OnDemandTranscriptRepository.Outcome.TransientFailure + whenever(episodeManager.findByUuid("episode-uuid")).thenReturn( + PodcastEpisode(uuid = "episode-uuid", publishedDate = Date(), podcastUuid = "podcast-uuid"), + ) + + viewModel.loadTranscript("episode-uuid") + runCurrent() + + assertEquals(TranscriptState.GenerationFailed, viewModel.uiState.value.transcriptState) + } + + @Test + fun `generation refresh stops with delayed state after five foreground minutes`() = runTest { + signInStateFlow.value = SignInState.SignedIn("email", Subscription.PlusPreview) + transcriptManager.isAvailable.value = false + transcriptManager.shouldLoadTranscripts = false + whenever(episodeManager.findByUuid("episode-uuid")).thenReturn( + PodcastEpisode(uuid = "episode-uuid", publishedDate = Date(), podcastUuid = "podcast-uuid"), + ) + + viewModel.onScreenStarted() + viewModel.loadTranscript("episode-uuid") + runCurrent() + advanceTimeBy(2.minutes) + runCurrent() + assertEquals(TranscriptState.Generating, viewModel.uiState.value.transcriptState) + assertEquals(8, onDemandTranscriptRepository.refreshCount) + + advanceTimeBy(3.minutes) + runCurrent() + + assertEquals(TranscriptState.GenerationDelayed, viewModel.uiState.value.transcriptState) + assertEquals(20, onDemandTranscriptRepository.refreshCount) + + viewModel.onScreenStopped() + viewModel.onScreenStarted() + advanceTimeBy(1.minutes) + runCurrent() + assertEquals(20, onDemandTranscriptRepository.refreshCount) + } + + @Test + fun `loading another episode cancels the previous generation refresh`() = runTest { + signInStateFlow.value = SignInState.SignedIn("email", Subscription.PlusPreview) + transcriptManager.isAvailable.value = false + transcriptManager.shouldLoadTranscripts = false + whenever(episodeManager.findByUuid("episode-uuid")).thenReturn( + PodcastEpisode(uuid = "episode-uuid", publishedDate = Date(), podcastUuid = "podcast-uuid"), + ) + whenever(episodeManager.findByUuid("other-episode-uuid")).thenReturn( + PodcastEpisode(uuid = "other-episode-uuid", publishedDate = Date(), podcastUuid = "other-podcast-uuid"), + ) + + viewModel.onScreenStarted() + viewModel.loadTranscript("episode-uuid") + runCurrent() + advanceTimeBy(15.seconds) + runCurrent() + viewModel.loadTranscript("other-episode-uuid") + runCurrent() + advanceTimeBy(15.seconds) + runCurrent() + + assertEquals(2, onDemandTranscriptRepository.requestCount) + assertEquals(2, onDemandTranscriptRepository.refreshCount) + assertEquals(TranscriptState.Generating, viewModel.uiState.value.transcriptState) + } + @Test fun `reload transcript`() = runTest { viewModel.uiState.test { @@ -632,10 +810,13 @@ class TranscriptViewModelTest { private class TestTranscriptManager : TranscriptManager { var avaiableTranscript: Transcript = Transcript.TextPreview var shouldLoadTranscripts = true + var loadCount = 0 + val isAvailable = MutableStateFlow(true) - override fun observeIsTranscriptAvailable(episodeUuid: String) = emptyFlow() + override fun observeIsTranscriptAvailable(episodeUuid: String) = isAvailable override suspend fun loadTranscript(episodeUuid: String): Transcript? { + loadCount++ yield() return avaiableTranscript.takeIf { shouldLoadTranscripts } } @@ -646,3 +827,28 @@ private class TestTranscriptManager : TranscriptManager { override suspend fun loadSummaryText(episodeUuid: String): String? = null } + +private class TestOnDemandTranscriptRepository : OnDemandTranscriptRepository { + var outcome = OnDemandTranscriptRepository.Outcome.Queued + var requestCount = 0 + var refreshCount = 0 + var onRefresh: () -> Unit = {} + + override suspend fun request( + podcastUuid: String, + episodeUuid: String, + ): OnDemandTranscriptRepository.RequestResult { + requestCount++ + return OnDemandTranscriptRepository.RequestResult( + outcome = outcome, + reason = "unspecified", + enablement = "enabled", + newlyQueuedCount = 1, + ) + } + + override suspend fun refreshMetadata(podcastUuid: String, episodeUuid: String) { + refreshCount++ + onRefresh() + } +} diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml index b8c9fbd35e5..3c1a39a3805 100644 --- a/modules/services/localization/src/main/res/values/strings.xml +++ b/modules/services/localization/src/main/res/values/strings.xml @@ -2472,6 +2472,11 @@ This transcript is automatically generated and available to Plus subscribers only. Share transcript Download the episode to tap to seek + + We’re generating this transcript. It will appear here automatically when it’s ready. + This transcript is taking longer than expected. Check back later. + We couldn’t start transcript generation. Check your connection and try again. + This episode isn’t available for transcript generation. Chat with this episode diff --git a/modules/services/protobuf/src/main/proto/sync_api.proto b/modules/services/protobuf/src/main/proto/sync_api.proto index f25d83091a4..4841731c71b 100644 --- a/modules/services/protobuf/src/main/proto/sync_api.proto +++ b/modules/services/protobuf/src/main/proto/sync_api.proto @@ -159,6 +159,52 @@ message BookmarksResponse { repeated BookmarkResponse bookmarks = 1; } +message OnDemandTranscriptRequest { + string podcast_uuid = 1; + string episode_uuid = 2; +} + +enum OnDemandTranscriptOutcome { + OUTCOME_UNSPECIFIED = 0; + QUEUED = 1; + IN_PROGRESS = 2; + AVAILABLE = 3; + NOT_ELIGIBLE = 4; + TRANSIENT_FAILURE = 5; + THROTTLED = 6; +} + +enum OnDemandTranscriptEnablement { + ENABLEMENT_UNSPECIFIED = 0; + ENABLED = 1; + ALREADY_ENABLED = 2; + ALREADY_ELIGIBLE = 3; + NOT_ENABLED = 4; +} + +enum OnDemandTranscriptReason { + REASON_UNSPECIFIED = 0; + FEATURE_DISABLED = 1; + PODCAST_NOT_FOUND = 2; + EPISODE_NOT_FOUND = 3; + EPISODE_NOT_IN_PODCAST = 4; + PODCAST_DISABLED = 5; + PODCAST_DISALLOWED = 6; + HOST_IGNORED = 7; + TRANSCRIPT_INELIGIBLE = 8; + QUEUEING_FAILED = 9; + RETRY_NOT_AVAILABLE = 10; + INTERNAL_ERROR = 11; + UNKNOWN_REASON = 12; +} + +message OnDemandTranscriptResponse { + OnDemandTranscriptOutcome outcome = 1; + OnDemandTranscriptReason reason = 2; + OnDemandTranscriptEnablement enablement = 3; + uint32 newly_queued_count = 4; +} + message UserPodcastListRequest { string v = 1; string m = 2; diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/di/RepositoryModule.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/di/RepositoryModule.kt index 03ee33c1296..64e392b9811 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/di/RepositoryModule.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/di/RepositoryModule.kt @@ -83,6 +83,8 @@ import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManagerImpl import au.com.shiftyjelly.pocketcasts.repositories.transcript.HtmlParser import au.com.shiftyjelly.pocketcasts.repositories.transcript.JsonParser +import au.com.shiftyjelly.pocketcasts.repositories.transcript.OnDemandTranscriptRepository +import au.com.shiftyjelly.pocketcasts.repositories.transcript.OnDemandTranscriptRepositoryImpl import au.com.shiftyjelly.pocketcasts.repositories.transcript.SrtParser import au.com.shiftyjelly.pocketcasts.repositories.transcript.TranscriptManager import au.com.shiftyjelly.pocketcasts.repositories.transcript.TranscriptManagerImpl @@ -245,6 +247,11 @@ abstract class RepositoryModule { @Binds abstract fun provideTranscriptManager(transcriptsManagerImpl: TranscriptManagerImpl): TranscriptManager + @Binds + abstract fun provideOnDemandTranscriptRepository( + repository: OnDemandTranscriptRepositoryImpl, + ): OnDemandTranscriptRepository + @Binds abstract fun providePlaylistManager(playlistManagerImpl: PlaylistManagerImpl): PlaylistManager diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/shownotes/ShowNotesManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/shownotes/ShowNotesManager.kt index 6b5f2523eb4..031d32c505e 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/shownotes/ShowNotesManager.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/shownotes/ShowNotesManager.kt @@ -56,6 +56,15 @@ class ShowNotesManager @Inject constructor( ) } + suspend fun refreshTranscriptMetadata(podcastUuid: String, episodeUuid: String) { + val showNotes = showNotesServiceManager.downloadShowNotes(podcastUuid) ?: return + showNotesProcessor.process( + podcastUuid = podcastUuid, + episodeUuid = episodeUuid, + showNotes = showNotes, + ) + } + suspend fun loadShowNotes(podcastUuid: String, episodeUuid: String): ShowNotesState = showNotesServiceManager.loadShowNotes( podcastUuid = podcastUuid, episodeUuid = episodeUuid, diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/transcript/OnDemandTranscriptRepository.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/transcript/OnDemandTranscriptRepository.kt new file mode 100644 index 00000000000..0905b6c61c7 --- /dev/null +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/transcript/OnDemandTranscriptRepository.kt @@ -0,0 +1,127 @@ +package au.com.shiftyjelly.pocketcasts.repositories.transcript + +import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager +import au.com.shiftyjelly.pocketcasts.repositories.shownotes.ShowNotesManager +import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager +import au.com.shiftyjelly.pocketcasts.servers.podcast.PodcastCacheServiceManager +import au.com.shiftyjelly.pocketcasts.servers.sync.SyncServiceManager +import com.pocketcasts.service.api.OnDemandTranscriptEnablement +import com.pocketcasts.service.api.OnDemandTranscriptOutcome +import com.pocketcasts.service.api.OnDemandTranscriptReason +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CancellationException +import retrofit2.HttpException + +interface OnDemandTranscriptRepository { + suspend fun request(podcastUuid: String, episodeUuid: String): RequestResult + + suspend fun refreshMetadata(podcastUuid: String, episodeUuid: String) + + data class RequestResult( + val outcome: Outcome, + val reason: String, + val enablement: String, + val newlyQueuedCount: Int, + ) + + enum class Outcome { + Queued, + InProgress, + Available, + NotEligible, + TransientFailure, + Throttled, + Unknown, + } +} + +@Singleton +class OnDemandTranscriptRepositoryImpl @Inject constructor( + private val syncManager: SyncManager, + private val syncServiceManager: SyncServiceManager, + private val podcastCacheServiceManager: PodcastCacheServiceManager, + private val episodeManager: EpisodeManager, + private val showNotesManager: ShowNotesManager, +) : OnDemandTranscriptRepository { + override suspend fun request( + podcastUuid: String, + episodeUuid: String, + ): OnDemandTranscriptRepository.RequestResult { + return try { + val response = syncManager.getCacheTokenOrLogin { token -> + syncServiceManager.requestOnDemandTranscript( + podcastUuid = podcastUuid, + episodeUuid = episodeUuid, + token = token, + ) + } + OnDemandTranscriptRepository.RequestResult( + outcome = response.outcome.toDomain(), + reason = response.reason.analyticsValue, + enablement = response.enablement.analyticsValue, + newlyQueuedCount = response.newlyQueuedCount, + ) + } catch (error: HttpException) { + OnDemandTranscriptRepository.RequestResult( + outcome = error.code().toOnDemandTranscriptOutcome(), + reason = "http_${error.code()}", + enablement = "unspecified", + newlyQueuedCount = 0, + ) + } catch (error: Exception) { + if (error is CancellationException) throw error + OnDemandTranscriptRepository.RequestResult( + outcome = OnDemandTranscriptRepository.Outcome.TransientFailure, + reason = "network_error", + enablement = "unspecified", + newlyQueuedCount = 0, + ) + } + } + + override suspend fun refreshMetadata( + podcastUuid: String, + episodeUuid: String, + ) { + val remotePodcast = podcastCacheServiceManager.getPodcastAndEpisode(podcastUuid, episodeUuid) + val remoteEpisode = remotePodcast.episodes.firstOrNull { it.uuid == episodeUuid } + val localEpisode = episodeManager.findByUuid(episodeUuid) + if (remoteEpisode != null && localEpisode != null) { + localEpisode.hasGeneratedTranscript = remoteEpisode.hasGeneratedTranscript + episodeManager.update(localEpisode) + } + showNotesManager.refreshTranscriptMetadata(podcastUuid, episodeUuid) + } +} + +internal fun Int.toOnDemandTranscriptOutcome() = when (this) { + 400, 401, 403, 404 -> OnDemandTranscriptRepository.Outcome.NotEligible + 429 -> OnDemandTranscriptRepository.Outcome.Throttled + in 500..599 -> OnDemandTranscriptRepository.Outcome.TransientFailure + else -> OnDemandTranscriptRepository.Outcome.Unknown +} + +private fun OnDemandTranscriptOutcome.toDomain() = when (this) { + OnDemandTranscriptOutcome.QUEUED -> OnDemandTranscriptRepository.Outcome.Queued + + OnDemandTranscriptOutcome.IN_PROGRESS -> OnDemandTranscriptRepository.Outcome.InProgress + + OnDemandTranscriptOutcome.AVAILABLE -> OnDemandTranscriptRepository.Outcome.Available + + OnDemandTranscriptOutcome.NOT_ELIGIBLE -> OnDemandTranscriptRepository.Outcome.NotEligible + + OnDemandTranscriptOutcome.TRANSIENT_FAILURE -> OnDemandTranscriptRepository.Outcome.TransientFailure + + OnDemandTranscriptOutcome.THROTTLED -> OnDemandTranscriptRepository.Outcome.Throttled + + OnDemandTranscriptOutcome.OUTCOME_UNSPECIFIED, + OnDemandTranscriptOutcome.UNRECOGNIZED, + -> OnDemandTranscriptRepository.Outcome.Unknown +} + +private val OnDemandTranscriptReason.analyticsValue + get() = name.lowercase() + +private val OnDemandTranscriptEnablement.analyticsValue + get() = name.lowercase() diff --git a/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/transcript/OnDemandTranscriptRepositoryTest.kt b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/transcript/OnDemandTranscriptRepositoryTest.kt new file mode 100644 index 00000000000..b11afe745b7 --- /dev/null +++ b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/transcript/OnDemandTranscriptRepositoryTest.kt @@ -0,0 +1,42 @@ +package au.com.shiftyjelly.pocketcasts.repositories.transcript + +import org.junit.Assert.assertEquals +import org.junit.Test + +class OnDemandTranscriptRepositoryTest { + @Test + fun `approved client errors are not eligible`() { + listOf(400, 401, 403, 404).forEach { statusCode -> + assertEquals( + OnDemandTranscriptRepository.Outcome.NotEligible, + statusCode.toOnDemandTranscriptOutcome(), + ) + } + } + + @Test + fun `rate limiting is throttled`() { + assertEquals( + OnDemandTranscriptRepository.Outcome.Throttled, + 429.toOnDemandTranscriptOutcome(), + ) + } + + @Test + fun `server and proxy errors are transient`() { + listOf(500, 502, 503, 504).forEach { statusCode -> + assertEquals( + OnDemandTranscriptRepository.Outcome.TransientFailure, + statusCode.toOnDemandTranscriptOutcome(), + ) + } + } + + @Test + fun `unexpected status is unknown`() { + assertEquals( + OnDemandTranscriptRepository.Outcome.Unknown, + 418.toOnDemandTranscriptOutcome(), + ) + } +} diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/ShowNotesServiceManager.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/ShowNotesServiceManager.kt index 3e3daffaed0..e73ba47adcc 100644 --- a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/ShowNotesServiceManager.kt +++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/ShowNotesServiceManager.kt @@ -13,6 +13,10 @@ import timber.log.Timber class ShowNotesServiceManager @Inject constructor( private val podcastCacheServiceManager: PodcastCacheServiceManager, ) { + suspend fun downloadShowNotes(podcastUuid: String): ShowNotesResponse? { + if (podcastUuid.isBlank()) return null + return podcastCacheServiceManager.getShowNotes(podcastUuid) + } /** * Check the cache for show notes then download them if not found or update the cache. @@ -106,7 +110,7 @@ class ShowNotesServiceManager @Inject constructor( if (podcastUuid.isBlank() || episodeUuid.isBlank()) { return null } - val response = podcastCacheServiceManager.getShowNotes(podcastUuid = podcastUuid) + val response = downloadShowNotes(podcastUuid) ?: return null processShowNotes(response) return response.findEpisode(episodeUuid)?.showNotes } @@ -118,7 +122,7 @@ class ShowNotesServiceManager @Inject constructor( if (podcastUuid.isBlank()) { return } - val response = podcastCacheServiceManager.getShowNotes(podcastUuid = podcastUuid) + val response = downloadShowNotes(podcastUuid) ?: return processShowNotes(response) } } diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncService.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncService.kt index 82162458c19..e075a38823d 100644 --- a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncService.kt +++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncService.kt @@ -19,6 +19,8 @@ import au.com.shiftyjelly.pocketcasts.servers.sync.register.RegisterRequest import com.pocketcasts.service.api.BookmarkRequest import com.pocketcasts.service.api.BookmarksResponse import com.pocketcasts.service.api.EpisodesResponse +import com.pocketcasts.service.api.OnDemandTranscriptRequest +import com.pocketcasts.service.api.OnDemandTranscriptResponse import com.pocketcasts.service.api.PodcastRatingAddRequest import com.pocketcasts.service.api.PodcastRatingResponse import com.pocketcasts.service.api.PodcastRatingShowRequest @@ -98,6 +100,13 @@ interface SyncService { @POST("/user/sync/update") suspend fun syncUpdate(@Header("Authorization") authorization: String, @Body request: SyncUpdateRequest): SyncUpdateResponse + @Headers("Content-Type: application/octet-stream") + @POST("/user/transcript/on_demand") + suspend fun requestOnDemandTranscript( + @Header("Authorization") authorization: String, + @Body request: OnDemandTranscriptRequest, + ): OnDemandTranscriptResponse + @POST("/up_next/sync") suspend fun upNextSync(@Header("Authorization") authorization: String, @Body request: UpNextSyncRequest): UpNextSyncResponse diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncServiceManager.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncServiceManager.kt index c435809bd2f..7f43641ca80 100644 --- a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncServiceManager.kt +++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SyncServiceManager.kt @@ -28,6 +28,7 @@ import au.com.shiftyjelly.pocketcasts.utils.extensions.parseIsoDate import com.google.protobuf.StringValue import com.pocketcasts.service.api.BookmarksResponse import com.pocketcasts.service.api.EpisodesResponse +import com.pocketcasts.service.api.OnDemandTranscriptResponse import com.pocketcasts.service.api.PodcastRatingAddRequest import com.pocketcasts.service.api.PodcastRatingResponse import com.pocketcasts.service.api.PodcastRatingShowRequest @@ -48,6 +49,7 @@ import com.pocketcasts.service.api.WebFeedCreateRequest import com.pocketcasts.service.api.WebFeedCreateResponse import com.pocketcasts.service.api.WinbackResponse import com.pocketcasts.service.api.bookmarkRequest +import com.pocketcasts.service.api.onDemandTranscriptRequest import com.pocketcasts.service.api.userPlaylistListRequest import com.pocketcasts.service.api.userPodcastListRequest import dagger.Lazy @@ -164,6 +166,18 @@ open class SyncServiceManager @Inject constructor( return service.syncUpdate(addBearer(token), request) } + suspend fun requestOnDemandTranscript( + podcastUuid: String, + episodeUuid: String, + token: AccessToken, + ): OnDemandTranscriptResponse { + val request = onDemandTranscriptRequest { + this.podcastUuid = podcastUuid + this.episodeUuid = episodeUuid + } + return service.requestOnDemandTranscript(addBearer(token), request) + } + suspend fun upNextSync(request: UpNextSyncRequest, token: AccessToken): UpNextSyncResponse = service.upNextSync(addBearer(token), request) suspend fun upNextSyncProtobuf(request: com.pocketcasts.service.api.UpNextSyncRequest, token: AccessToken): UpNextResponse = service.upNextSyncProtobuf(addBearer(token), request) diff --git a/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/featureflag/Feature.kt b/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/featureflag/Feature.kt index 88f653e21fe..3aae80c809d 100644 --- a/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/featureflag/Feature.kt +++ b/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/featureflag/Feature.kt @@ -324,6 +324,15 @@ enum class Feature( hasDevToggle = true, addedOn = LocalDate.parse("2026-05-29"), ), + ON_DEMAND_TRANSCRIPTS( + key = "on_demand_transcripts", + title = "On-demand transcript generation", + defaultValue = isDebugOrPrototypeBuild, + tier = FeatureTier.Plus(), + hasFirebaseRemoteFlag = true, + hasDevToggle = true, + addedOn = LocalDate.parse("2026-08-20"), + ), UP_NEXT_SORT( key = "up_next_sort", title = "Up Next sort by duration", From 1cea818bf75d5cb791b4c3626d0a0f902f730b20 Mon Sep 17 00:00:00 2001 From: Stefano Sala Date: Thu, 20 Aug 2026 17:17:38 +0200 Subject: [PATCH 2/2] fix: harden on-demand transcript generation Prevent terminal-state re-entry and reduce polling work while improving outcome handling, previews, and behavioral coverage. Co-authored-by: Cursor --- .../player/viewmodel/ShelfSharedViewModel.kt | 3 +- .../player/viewmodel/ShelfViewModel.kt | 22 +-- .../podcasts/view/episode/EpisodeFragment.kt | 4 +- .../transcripts/TranscriptViewModel.kt | 31 ++- .../transcripts/ui/TranscriptPage.kt | 58 +++++- .../transcripts/TranscriptViewModelTest.kt | 59 +++++- .../pocketcasts/models/db/dao/EpisodeDao.kt | 3 + .../repositories/podcast/EpisodeManager.kt | 1 + .../podcast/EpisodeManagerImpl.kt | 7 + .../shownotes/ShowNotesManager.kt | 14 +- .../OnDemandTranscriptRepository.kt | 14 +- .../OnDemandTranscriptRepositoryTest.kt | 178 +++++++++++++++++- 12 files changed, 352 insertions(+), 42 deletions(-) diff --git a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfSharedViewModel.kt b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfSharedViewModel.kt index 9c14e12e7f2..9798c5e476c 100644 --- a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfSharedViewModel.kt +++ b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfSharedViewModel.kt @@ -8,6 +8,7 @@ import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode import au.com.shiftyjelly.pocketcasts.models.entity.Podcast import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode import au.com.shiftyjelly.pocketcasts.models.entity.UserEpisode +import au.com.shiftyjelly.pocketcasts.models.type.SignInState import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.preferences.model.ShelfItem import au.com.shiftyjelly.pocketcasts.repositories.chromecast.ChromeCastAnalytics @@ -123,7 +124,7 @@ class ShelfSharedViewModel @Inject constructor( shelfUpNext: UpNextQueue.State, isTranscriptAvailable: Boolean, videoState: VideoState, - signInState: au.com.shiftyjelly.pocketcasts.models.type.SignInState, + signInState: SignInState, ): UiState { val episode = (shelfUpNext as? UpNextQueue.State.Loaded)?.episode val streamHasVideo = videoState.streamVideoState == StreamVideoState.HasVideo || videoState.streamVideoState == StreamVideoState.Unknown diff --git a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfViewModel.kt b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfViewModel.kt index 41a66fb139e..1d5a4d268e0 100644 --- a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfViewModel.kt +++ b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/ShelfViewModel.kt @@ -3,6 +3,7 @@ package au.com.shiftyjelly.pocketcasts.player.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode +import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode import au.com.shiftyjelly.pocketcasts.player.viewmodel.ShelfSharedViewModel.Companion.MIN_SHELF_ITEMS_SIZE import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.preferences.model.ShelfItem @@ -85,12 +86,7 @@ class ShelfViewModel @AssistedInject constructor( items }, episode = episode, - isTranscriptAvailable = hasTranscriptRow || - ( - episode is au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode && - isEligiblePaidListener && - FeatureFlag.isEnabled(Feature.ON_DEMAND_TRANSCRIPTS) - ), + isTranscriptAvailable = isTranscriptAvailableFor(episode), ) } } @@ -98,16 +94,18 @@ class ShelfViewModel @AssistedInject constructor( private fun updateTranscriptAvailability() { _uiState.update { state -> state.copy( - isTranscriptAvailable = hasTranscriptRow || - ( - state.episode is au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode && - isEligiblePaidListener && - FeatureFlag.isEnabled(Feature.ON_DEMAND_TRANSCRIPTS) - ), + isTranscriptAvailable = isTranscriptAvailableFor(state.episode), ) } } + private fun isTranscriptAvailableFor(episode: BaseEpisode?) = hasTranscriptRow || + ( + episode is PodcastEpisode && + isEligiblePaidListener && + FeatureFlag.isEnabled(Feature.ON_DEMAND_TRANSCRIPTS) + ) + fun onShelfItemMove( fromPosition: Int, toPosition: Int, diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragment.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragment.kt index 967d5d7a394..c153240d845 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragment.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragment.kt @@ -1201,7 +1201,9 @@ class EpisodeFragment : BaseFragment() { viewModel.selectContentTab(EpisodeContentTab.TRANSCRIPT) eventHorizon.track( EpisodeDetailTranscriptCardTappedEvent( - episodeUuid = transcript?.episodeUuid ?: viewModel.episode?.uuid.orEmpty(), + episodeUuid = transcript?.episodeUuid + ?: viewModel.episode?.uuid + ?: AnalyticsTracker.INVALID_OR_NULL_VALUE, podcastUuid = transcript?.podcastUuid ?: viewModel.podcast?.uuid ?: AnalyticsTracker.INVALID_OR_NULL_VALUE, diff --git a/modules/features/transcripts/src/main/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/TranscriptViewModel.kt b/modules/features/transcripts/src/main/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/TranscriptViewModel.kt index bb9c48fe74c..35dc1dc1b23 100644 --- a/modules/features/transcripts/src/main/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/TranscriptViewModel.kt +++ b/modules/features/transcripts/src/main/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/TranscriptViewModel.kt @@ -113,9 +113,17 @@ class TranscriptViewModel @AssistedInject constructor( private var isScreenStarted = false fun loadTranscript(episodeUuid: String) { + val transcriptState = _uiState.value.transcriptState if ( this.episodeUuid == episodeUuid && - (loadTranscriptJob?.isActive == true || _uiState.value.transcriptState is TranscriptState.Generating) + ( + loadTranscriptJob?.isActive == true || + transcriptState is TranscriptState.Generating || + ( + requestedEpisodeUuid == episodeUuid && + transcriptState.isTerminalGenerationState + ) + ) ) { return } @@ -240,10 +248,14 @@ class TranscriptViewModel @AssistedInject constructor( startGenerationRefresh() } - OnDemandTranscriptRepository.Outcome.NotEligible, - OnDemandTranscriptRepository.Outcome.Throttled, - OnDemandTranscriptRepository.Outcome.Unknown, - -> _uiState.update { it.copy(transcriptState = TranscriptState.GenerationUnavailable) } + OnDemandTranscriptRepository.Outcome.NotEligible -> + _uiState.update { it.copy(transcriptState = TranscriptState.GenerationUnavailable) } + + OnDemandTranscriptRepository.Outcome.Throttled -> + _uiState.update { it.copy(transcriptState = TranscriptState.GenerationDelayed) } + + OnDemandTranscriptRepository.Outcome.Unknown -> + _uiState.update { it.copy(transcriptState = TranscriptState.GenerationFailed) } OnDemandTranscriptRepository.Outcome.TransientFailure -> _uiState.update { it.copy(transcriptState = TranscriptState.GenerationFailed) } @@ -269,7 +281,9 @@ class TranscriptViewModel @AssistedInject constructor( val podcastUuid = podcastUuid ?: return generationRefreshJob = viewModelScope.launch { while (generationRefreshAttempts < GENERATION_REFRESH_MAX_ATTEMPTS) { - delay(GENERATION_REFRESH_INTERVAL) + if (generationRefreshAttempts > 0) { + delay(GENERATION_REFRESH_INTERVAL) + } generationRefreshAttempts++ try { onDemandTranscriptRepository.refreshMetadata(podcastUuid, episodeUuid) @@ -708,6 +722,11 @@ sealed interface TranscriptState { data object GenerationDelayed : TranscriptState } +private val TranscriptState.isTerminalGenerationState + get() = this is TranscriptState.GenerationUnavailable || + this is TranscriptState.GenerationFailed || + this is TranscriptState.GenerationDelayed + data class SearchState( val isSearchOpen: Boolean, val searchTerm: String, diff --git a/modules/features/transcripts/src/main/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/ui/TranscriptPage.kt b/modules/features/transcripts/src/main/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/ui/TranscriptPage.kt index 2b193526c41..bc4c42b946d 100644 --- a/modules/features/transcripts/src/main/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/ui/TranscriptPage.kt +++ b/modules/features/transcripts/src/main/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/ui/TranscriptPage.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView @@ -34,10 +35,14 @@ import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleStartEffect +import au.com.shiftyjelly.pocketcasts.compose.AppThemeWithBackground import au.com.shiftyjelly.pocketcasts.compose.components.TextH30 import au.com.shiftyjelly.pocketcasts.compose.loading.LoadingView +import au.com.shiftyjelly.pocketcasts.compose.preview.ThemePreviewParameterProvider import au.com.shiftyjelly.pocketcasts.models.to.Transcript import au.com.shiftyjelly.pocketcasts.models.to.TranscriptEntry import au.com.shiftyjelly.pocketcasts.repositories.fingerprint.FingerprintTimingManager @@ -46,6 +51,7 @@ import au.com.shiftyjelly.pocketcasts.transcripts.TranscriptMessage import au.com.shiftyjelly.pocketcasts.transcripts.TranscriptState import au.com.shiftyjelly.pocketcasts.transcripts.TranscriptViewModel import au.com.shiftyjelly.pocketcasts.transcripts.UiState +import au.com.shiftyjelly.pocketcasts.ui.theme.Theme.ThemeType import au.com.shiftyjelly.pocketcasts.utils.featureflag.Feature import au.com.shiftyjelly.pocketcasts.utils.featureflag.FeatureFlag import au.com.shiftyjelly.pocketcasts.utils.search.SearchCoordinates @@ -385,10 +391,9 @@ private fun TranscriptContent( @Composable private fun TranscriptGeneratingContent( - color: androidx.compose.ui.graphics.Color, + color: Color, modifier: Modifier = Modifier, ) { - val message = stringResource(LR.string.transcript_generation_started) Column( modifier = modifier .semantics { liveRegion = LiveRegionMode.Polite } @@ -398,13 +403,60 @@ private fun TranscriptGeneratingContent( ) { LoadingView(color = color) TextH30( - text = message, + text = stringResource(LR.string.transcript_generation_started), color = color, textAlign = TextAlign.Center, ) } } +@Preview +@Composable +private fun TranscriptGeneratingPreview( + @PreviewParameter(ThemePreviewParameterProvider::class) theme: ThemeType, +) { + TranscriptStatePreview(theme, TranscriptState.Generating) +} + +@Preview +@Composable +private fun TranscriptGenerationUnavailablePreview( + @PreviewParameter(ThemePreviewParameterProvider::class) theme: ThemeType, +) { + TranscriptStatePreview(theme, TranscriptState.GenerationUnavailable) +} + +@Preview +@Composable +private fun TranscriptGenerationFailedPreview( + @PreviewParameter(ThemePreviewParameterProvider::class) theme: ThemeType, +) { + TranscriptStatePreview(theme, TranscriptState.GenerationFailed) +} + +@Preview +@Composable +private fun TranscriptGenerationDelayedPreview( + @PreviewParameter(ThemePreviewParameterProvider::class) theme: ThemeType, +) { + TranscriptStatePreview(theme, TranscriptState.GenerationDelayed) +} + +@Composable +private fun TranscriptStatePreview(themeType: ThemeType, transcriptState: TranscriptState) { + AppThemeWithBackground(themeType) { + val theme = rememberTranscriptTheme() + TranscriptContent( + uiState = UiState.Empty.copy(transcriptState = transcriptState), + listState = rememberLazyListState(), + theme = theme, + onClickReload = {}, + onHighlightText = null, + modifier = Modifier.fillMaxSize(), + ) + } +} + @Composable private fun ScrollToItemEffect( searchCoordinates: SearchCoordinates?, diff --git a/modules/features/transcripts/src/test/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/TranscriptViewModelTest.kt b/modules/features/transcripts/src/test/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/TranscriptViewModelTest.kt index cd930437dec..34cc9053d1f 100644 --- a/modules/features/transcripts/src/test/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/TranscriptViewModelTest.kt +++ b/modules/features/transcripts/src/test/kotlin/au/com/shiftyjelly/pocketcasts/transcripts/TranscriptViewModelTest.kt @@ -235,8 +235,6 @@ class TranscriptViewModelTest { viewModel.onScreenStarted() viewModel.loadTranscript("episode-uuid") runCurrent() - advanceTimeBy(15.seconds) - runCurrent() assertTrue(viewModel.uiState.value.transcriptState is TranscriptState.Loaded) assertEquals(1, onDemandTranscriptRepository.refreshCount) @@ -258,7 +256,6 @@ class TranscriptViewModelTest { assertEquals(0, onDemandTranscriptRepository.refreshCount) viewModel.onScreenStarted() - advanceTimeBy(15.seconds) runCurrent() assertEquals(1, onDemandTranscriptRepository.refreshCount) @@ -284,6 +281,58 @@ class TranscriptViewModelTest { assertEquals(TranscriptState.GenerationFailed, viewModel.uiState.value.transcriptState) } + @Test + fun `re-entry after unavailable generation preserves terminal state`() = runTest { + signInStateFlow.value = SignInState.SignedIn("email", Subscription.PlusPreview) + transcriptManager.isAvailable.value = false + transcriptManager.shouldLoadTranscripts = false + onDemandTranscriptRepository.outcome = OnDemandTranscriptRepository.Outcome.NotEligible + whenever(episodeManager.findByUuid("episode-uuid")).thenReturn( + PodcastEpisode(uuid = "episode-uuid", publishedDate = Date(), podcastUuid = "podcast-uuid"), + ) + + viewModel.loadTranscript("episode-uuid") + runCurrent() + viewModel.loadTranscript("episode-uuid") + runCurrent() + + assertEquals(TranscriptState.GenerationUnavailable, viewModel.uiState.value.transcriptState) + assertEquals(1, transcriptManager.loadCount) + assertEquals(1, onDemandTranscriptRepository.requestCount) + } + + @Test + fun `throttled request shows delayed state`() = runTest { + signInStateFlow.value = SignInState.SignedIn("email", Subscription.PlusPreview) + transcriptManager.isAvailable.value = false + transcriptManager.shouldLoadTranscripts = false + onDemandTranscriptRepository.outcome = OnDemandTranscriptRepository.Outcome.Throttled + whenever(episodeManager.findByUuid("episode-uuid")).thenReturn( + PodcastEpisode(uuid = "episode-uuid", publishedDate = Date(), podcastUuid = "podcast-uuid"), + ) + + viewModel.loadTranscript("episode-uuid") + runCurrent() + + assertEquals(TranscriptState.GenerationDelayed, viewModel.uiState.value.transcriptState) + } + + @Test + fun `unknown request outcome shows retryable failure`() = runTest { + signInStateFlow.value = SignInState.SignedIn("email", Subscription.PlusPreview) + transcriptManager.isAvailable.value = false + transcriptManager.shouldLoadTranscripts = false + onDemandTranscriptRepository.outcome = OnDemandTranscriptRepository.Outcome.Unknown + whenever(episodeManager.findByUuid("episode-uuid")).thenReturn( + PodcastEpisode(uuid = "episode-uuid", publishedDate = Date(), podcastUuid = "podcast-uuid"), + ) + + viewModel.loadTranscript("episode-uuid") + runCurrent() + + assertEquals(TranscriptState.GenerationFailed, viewModel.uiState.value.transcriptState) + } + @Test fun `generation refresh stops with delayed state after five foreground minutes`() = runTest { signInStateFlow.value = SignInState.SignedIn("email", Subscription.PlusPreview) @@ -299,7 +348,7 @@ class TranscriptViewModelTest { advanceTimeBy(2.minutes) runCurrent() assertEquals(TranscriptState.Generating, viewModel.uiState.value.transcriptState) - assertEquals(8, onDemandTranscriptRepository.refreshCount) + assertEquals(9, onDemandTranscriptRepository.refreshCount) advanceTimeBy(3.minutes) runCurrent() @@ -337,7 +386,7 @@ class TranscriptViewModelTest { runCurrent() assertEquals(2, onDemandTranscriptRepository.requestCount) - assertEquals(2, onDemandTranscriptRepository.refreshCount) + assertEquals(4, onDemandTranscriptRepository.refreshCount) assertEquals(TranscriptState.Generating, viewModel.uiState.value.transcriptState) } diff --git a/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/db/dao/EpisodeDao.kt b/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/db/dao/EpisodeDao.kt index 67b14acb115..45988319509 100644 --- a/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/db/dao/EpisodeDao.kt +++ b/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/db/dao/EpisodeDao.kt @@ -408,6 +408,9 @@ abstract class EpisodeDao { @Query("UPDATE podcast_episodes SET download_url = :url WHERE uuid = :uuid") abstract suspend fun updateDownloadUrl(url: String, uuid: String) + @Query("UPDATE podcast_episodes SET has_generated_transcript = :hasGeneratedTranscript WHERE uuid = :uuid") + abstract suspend fun updateHasGeneratedTranscript(hasGeneratedTranscript: Boolean, uuid: String) + @Query("UPDATE podcast_episodes SET downloaded_file_path = :downloadedFilePath WHERE uuid = :uuid") abstract fun updateDownloadedFilePathBlocking(downloadedFilePath: String, uuid: String) diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/podcast/EpisodeManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/podcast/EpisodeManager.kt index 538c704b2cb..123087494b6 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/podcast/EpisodeManager.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/podcast/EpisodeManager.kt @@ -62,6 +62,7 @@ interface EpisodeManager { suspend fun update(episode: PodcastEpisode?) suspend fun updateAll(episodes: Collection) suspend fun updateAllSyncFields(episodes: Collection) + suspend fun updateHasGeneratedTranscript(episodeUuid: String, hasGeneratedTranscript: Boolean) fun updatePlayedUpToBlocking(episode: BaseEpisode?, playedUpTo: Double, forceUpdate: Boolean) fun updateDurationBlocking(episode: BaseEpisode?, durationInSecs: Double, syncChanges: Boolean) diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/podcast/EpisodeManagerImpl.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/podcast/EpisodeManagerImpl.kt index d3ac117df0e..98eb761b0b7 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/podcast/EpisodeManagerImpl.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/podcast/EpisodeManagerImpl.kt @@ -526,6 +526,13 @@ class EpisodeManagerImpl @Inject constructor( episodeDao.updateAllSyncFields(episodes) } + override suspend fun updateHasGeneratedTranscript( + episodeUuid: String, + hasGeneratedTranscript: Boolean, + ) { + episodeDao.updateHasGeneratedTranscript(hasGeneratedTranscript, episodeUuid) + } + override fun clearPlaybackErrorBlocking(episode: BaseEpisode?) { if (episode?.playErrorDetails == null) { return diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/shownotes/ShowNotesManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/shownotes/ShowNotesManager.kt index 031d32c505e..5ba4ab3cf1c 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/shownotes/ShowNotesManager.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/shownotes/ShowNotesManager.kt @@ -8,9 +8,11 @@ import au.com.shiftyjelly.pocketcasts.servers.podcast.TranscriptService import au.com.shiftyjelly.pocketcasts.servers.shownotes.ShowNotesState import javax.inject.Inject import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import okhttp3.CacheControl import okhttp3.HttpUrl.Companion.toHttpUrl @@ -58,11 +60,13 @@ class ShowNotesManager @Inject constructor( suspend fun refreshTranscriptMetadata(podcastUuid: String, episodeUuid: String) { val showNotes = showNotesServiceManager.downloadShowNotes(podcastUuid) ?: return - showNotesProcessor.process( - podcastUuid = podcastUuid, - episodeUuid = episodeUuid, - showNotes = showNotes, - ) + withContext(Dispatchers.IO) { + showNotesProcessor.process( + podcastUuid = podcastUuid, + episodeUuid = episodeUuid, + showNotes = showNotes, + ) + } } suspend fun loadShowNotes(podcastUuid: String, episodeUuid: String): ShowNotesState = showNotesServiceManager.loadShowNotes( diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/transcript/OnDemandTranscriptRepository.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/transcript/OnDemandTranscriptRepository.kt index 0905b6c61c7..5de2ab787a3 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/transcript/OnDemandTranscriptRepository.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/transcript/OnDemandTranscriptRepository.kt @@ -85,18 +85,18 @@ class OnDemandTranscriptRepositoryImpl @Inject constructor( episodeUuid: String, ) { val remotePodcast = podcastCacheServiceManager.getPodcastAndEpisode(podcastUuid, episodeUuid) - val remoteEpisode = remotePodcast.episodes.firstOrNull { it.uuid == episodeUuid } - val localEpisode = episodeManager.findByUuid(episodeUuid) - if (remoteEpisode != null && localEpisode != null) { - localEpisode.hasGeneratedTranscript = remoteEpisode.hasGeneratedTranscript - episodeManager.update(localEpisode) - } + val remoteEpisode = remotePodcast.episodes.firstOrNull { it.uuid == episodeUuid } ?: return + val localEpisode = episodeManager.findByUuid(episodeUuid) ?: return + if (!remoteEpisode.hasGeneratedTranscript || localEpisode.hasGeneratedTranscript) return + + episodeManager.updateHasGeneratedTranscript(episodeUuid, hasGeneratedTranscript = true) showNotesManager.refreshTranscriptMetadata(podcastUuid, episodeUuid) } } internal fun Int.toOnDemandTranscriptOutcome() = when (this) { - 400, 401, 403, 404 -> OnDemandTranscriptRepository.Outcome.NotEligible + 403, 404 -> OnDemandTranscriptRepository.Outcome.NotEligible + 401 -> OnDemandTranscriptRepository.Outcome.TransientFailure 429 -> OnDemandTranscriptRepository.Outcome.Throttled in 500..599 -> OnDemandTranscriptRepository.Outcome.TransientFailure else -> OnDemandTranscriptRepository.Outcome.Unknown diff --git a/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/transcript/OnDemandTranscriptRepositoryTest.kt b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/transcript/OnDemandTranscriptRepositoryTest.kt index b11afe745b7..c23a29aecd1 100644 --- a/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/transcript/OnDemandTranscriptRepositoryTest.kt +++ b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/transcript/OnDemandTranscriptRepositoryTest.kt @@ -1,12 +1,46 @@ package au.com.shiftyjelly.pocketcasts.repositories.transcript +import au.com.shiftyjelly.pocketcasts.models.entity.Podcast +import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode +import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager +import au.com.shiftyjelly.pocketcasts.repositories.shownotes.ShowNotesManager +import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager +import au.com.shiftyjelly.pocketcasts.servers.podcast.PodcastCacheServiceManager +import au.com.shiftyjelly.pocketcasts.servers.sync.SyncServiceManager +import com.pocketcasts.service.api.OnDemandTranscriptEnablement +import com.pocketcasts.service.api.OnDemandTranscriptOutcome +import com.pocketcasts.service.api.OnDemandTranscriptReason +import com.pocketcasts.service.api.OnDemandTranscriptResponse +import java.util.Date +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever class OnDemandTranscriptRepositoryTest { + private val syncManager = mock() + private val syncServiceManager = mock() + private val podcastCacheServiceManager = mock() + private val episodeManager = mock() + private val showNotesManager = mock() + private val repository = OnDemandTranscriptRepositoryImpl( + syncManager = syncManager, + syncServiceManager = syncServiceManager, + podcastCacheServiceManager = podcastCacheServiceManager, + episodeManager = episodeManager, + showNotesManager = showNotesManager, + ) + @Test - fun `approved client errors are not eligible`() { - listOf(400, 401, 403, 404).forEach { statusCode -> + fun `episode access errors are not eligible`() { + listOf(403, 404).forEach { statusCode -> assertEquals( OnDemandTranscriptRepository.Outcome.NotEligible, statusCode.toOnDemandTranscriptOutcome(), @@ -14,6 +48,22 @@ class OnDemandTranscriptRepositoryTest { } } + @Test + fun `authentication errors are transient`() { + assertEquals( + OnDemandTranscriptRepository.Outcome.TransientFailure, + 401.toOnDemandTranscriptOutcome(), + ) + } + + @Test + fun `malformed requests are unknown`() { + assertEquals( + OnDemandTranscriptRepository.Outcome.Unknown, + 400.toOnDemandTranscriptOutcome(), + ) + } + @Test fun `rate limiting is throttled`() { assertEquals( @@ -39,4 +89,128 @@ class OnDemandTranscriptRepositoryTest { 418.toOnDemandTranscriptOutcome(), ) } + + @Test + fun `request maps protobuf outcomes`() = runTest { + val outcomes = mapOf( + OnDemandTranscriptOutcome.QUEUED to OnDemandTranscriptRepository.Outcome.Queued, + OnDemandTranscriptOutcome.IN_PROGRESS to OnDemandTranscriptRepository.Outcome.InProgress, + OnDemandTranscriptOutcome.AVAILABLE to OnDemandTranscriptRepository.Outcome.Available, + OnDemandTranscriptOutcome.NOT_ELIGIBLE to OnDemandTranscriptRepository.Outcome.NotEligible, + OnDemandTranscriptOutcome.TRANSIENT_FAILURE to OnDemandTranscriptRepository.Outcome.TransientFailure, + OnDemandTranscriptOutcome.THROTTLED to OnDemandTranscriptRepository.Outcome.Throttled, + OnDemandTranscriptOutcome.OUTCOME_UNSPECIFIED to OnDemandTranscriptRepository.Outcome.Unknown, + OnDemandTranscriptOutcome.UNRECOGNIZED to OnDemandTranscriptRepository.Outcome.Unknown, + ) + + outcomes.forEach { (protobufOutcome, domainOutcome) -> + val outcomeValue = if (protobufOutcome == OnDemandTranscriptOutcome.UNRECOGNIZED) { + Int.MAX_VALUE + } else { + protobufOutcome.number + } + val response = OnDemandTranscriptResponse.newBuilder() + .setOutcomeValue(outcomeValue) + .setReason(OnDemandTranscriptReason.REASON_UNSPECIFIED) + .setEnablement(OnDemandTranscriptEnablement.ENABLEMENT_UNSPECIFIED) + .setNewlyQueuedCount(2) + .build() + whenever(syncManager.getCacheTokenOrLogin(any())).thenReturn(response) + + val result = repository.request(PODCAST_UUID, EPISODE_UUID) + + assertEquals(domainOutcome, result.outcome) + assertEquals("reason_unspecified", result.reason) + assertEquals("enablement_unspecified", result.enablement) + assertEquals(2, result.newlyQueuedCount) + } + } + + @Test + fun `request rethrows cancellation`() = runTest { + whenever(syncManager.getCacheTokenOrLogin(any())) + .thenThrow(CancellationException("cancelled")) + + var wasCancelled = false + try { + repository.request(PODCAST_UUID, EPISODE_UUID) + } catch (_: CancellationException) { + wasCancelled = true + } + + assertTrue(wasCancelled) + } + + @Test + fun `refresh updates generated transcript flag before refreshing show notes`() = runTest { + val localEpisode = podcastEpisode(hasGeneratedTranscript = false) + val remotePodcast = Podcast(uuid = PODCAST_UUID).apply { + episodes += podcastEpisode(hasGeneratedTranscript = true) + } + whenever(podcastCacheServiceManager.getPodcastAndEpisode(PODCAST_UUID, EPISODE_UUID)) + .thenReturn(remotePodcast) + whenever(episodeManager.findByUuid(EPISODE_UUID)).thenReturn(localEpisode) + + repository.refreshMetadata(PODCAST_UUID, EPISODE_UUID) + + inOrder(episodeManager, showNotesManager).apply { + verify(episodeManager).updateHasGeneratedTranscript(EPISODE_UUID, true) + verify(showNotesManager).refreshTranscriptMetadata(PODCAST_UUID, EPISODE_UUID) + } + verify(episodeManager, never()).update(any()) + } + + @Test + fun `refresh skips writes and show notes while transcript is unavailable`() = runTest { + val remotePodcast = Podcast(uuid = PODCAST_UUID).apply { + episodes += podcastEpisode(hasGeneratedTranscript = false) + } + whenever(podcastCacheServiceManager.getPodcastAndEpisode(PODCAST_UUID, EPISODE_UUID)) + .thenReturn(remotePodcast) + whenever(episodeManager.findByUuid(EPISODE_UUID)) + .thenReturn(podcastEpisode(hasGeneratedTranscript = false)) + + repository.refreshMetadata(PODCAST_UUID, EPISODE_UUID) + + verify(episodeManager, never()).updateHasGeneratedTranscript(any(), any()) + verify(showNotesManager, never()).refreshTranscriptMetadata(any(), any()) + } + + @Test + fun `refresh no-ops when remote episode is missing`() = runTest { + whenever(podcastCacheServiceManager.getPodcastAndEpisode(PODCAST_UUID, EPISODE_UUID)) + .thenReturn(Podcast(uuid = PODCAST_UUID)) + + repository.refreshMetadata(PODCAST_UUID, EPISODE_UUID) + + verify(episodeManager, never()).findByUuid(any()) + verify(showNotesManager, never()).refreshTranscriptMetadata(any(), any()) + } + + @Test + fun `refresh no-ops when local episode is missing`() = runTest { + val remotePodcast = Podcast(uuid = PODCAST_UUID).apply { + episodes += podcastEpisode(hasGeneratedTranscript = true) + } + whenever(podcastCacheServiceManager.getPodcastAndEpisode(PODCAST_UUID, EPISODE_UUID)) + .thenReturn(remotePodcast) + whenever(episodeManager.findByUuid(EPISODE_UUID)).thenReturn(null) + + repository.refreshMetadata(PODCAST_UUID, EPISODE_UUID) + + verify(episodeManager, never()).updateHasGeneratedTranscript(any(), any()) + verify(showNotesManager, never()).refreshTranscriptMetadata(any(), any()) + } + + private fun podcastEpisode(hasGeneratedTranscript: Boolean) = PodcastEpisode( + uuid = EPISODE_UUID, + publishedDate = Date(), + podcastUuid = PODCAST_UUID, + hasGeneratedTranscript = hasGeneratedTranscript, + ) + + private companion object { + const val PODCAST_UUID = "podcast-uuid" + const val EPISODE_UUID = "episode-uuid" + } }