Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ android {
applicationId = "dev.typetype.android"
minSdk = 23
targetSdk = 37
versionCode = 11102
versionName = "1.11.0-beta.2"
versionCode = 11103
versionName = "1.11.0-beta.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
resValue("string", "app_name", "TypeType")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,14 @@ internal fun SabrPlaybackResponse.windowRequest(
audioItag = audioItag,
audioTrackId = audioTrackId,
playbackRate = playbackRate.sanitizedPlaybackRate(),
bufferGoalMs = playbackRate.rateAwareBufferGoalMs(
isLive,
hasBufferedPlayback(ranges, playerTimeMs, audioOnly),
bufferGoalMs = requestedBufferGoalMs(
videoItag = videoItag,
audioItag = audioItag,
audioOnly = audioOnly,
isLive = isLive,
ranges = ranges,
playerTimeMs = playerTimeMs,
playbackRate = playbackRate,
),
bufferedRanges = ranges.map {
SabrPlaybackBufferedRangeDto(it.itag, it.startMs, it.endMs)
Expand All @@ -68,6 +73,33 @@ internal fun SabrPlaybackWindowResponseDto.requireWindowIdentity(
return this
}

internal fun SabrPlaybackTarget.requestedBufferGoalMs(
ranges: List<SabrPlaybackBufferedRange>,
playerTimeMs: Long,
playbackRate: Float,
): Long = requestedBufferGoalMs(
videoItag = videoItag,
audioItag = audioItag,
audioOnly = audioOnly,
isLive = isLive,
ranges = ranges,
playerTimeMs = playerTimeMs,
playbackRate = playbackRate,
)

private fun requestedBufferGoalMs(
videoItag: Int,
audioItag: Int,
audioOnly: Boolean,
isLive: Boolean,
ranges: List<SabrPlaybackBufferedRange>,
playerTimeMs: Long,
playbackRate: Float,
): Long = playbackRate.rateAwareBufferGoalMs(
isLive,
hasBufferedPlayback(videoItag, audioItag, audioOnly, ranges, playerTimeMs),
)

internal fun SabrPlaybackWindowResponseDto.throwTerminalFailure() {
val message = terminalError?.takeIf { it.isNotBlank() } ?: return
throw SabrPlaybackRecoveryException(
Expand Down Expand Up @@ -145,10 +177,12 @@ private fun Float.rateAwareBufferGoalMs(isLive: Boolean, hasBufferedPlayback: Bo
.toLong()
.coerceAtMost(MAX_BUFFER_GOAL_MS)

private fun SabrPlaybackResponse.hasBufferedPlayback(
private fun hasBufferedPlayback(
videoItag: Int,
audioItag: Int,
audioOnly: Boolean,
ranges: List<SabrPlaybackBufferedRange>,
playerTimeMs: Long,
audioOnly: Boolean,
): Boolean {
val requiredItags = listOfNotNull(videoItag.takeUnless { audioOnly }, audioItag)
return requiredItags.isNotEmpty() && requiredItags.all { itag ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ data class SabrPlaybackSession(
val audioWindow: SabrPlaybackWindowTrack? = null,
val videoWindow: SabrPlaybackWindowTrack? = null,
val live: SabrLivePlaybackWindow? = null,
val requestedBufferGoalMs: Long = 2_500L,
)

data class SabrPlaybackBufferedRange(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ private fun PlaybackFailureKind.messageResource(): Int = when (this) {
PlaybackFailureKind.YouTubeSessionRequired -> R.string.state_youtube_session_required_message
PlaybackFailureKind.SabrServerContract -> R.string.state_sabr_server_contract_failed
PlaybackFailureKind.SabrRecoveryExhausted -> R.string.state_sabr_recovery_exhausted
PlaybackFailureKind.ServerUnavailable -> R.string.state_playback_server_unavailable
PlaybackFailureKind.MediaDelivery -> R.string.state_media_delivery_failed
PlaybackFailureKind.Network -> R.string.state_playback_network_failed
PlaybackFailureKind.UnsupportedFormat -> R.string.state_playback_format_unsupported
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ enum class PlaybackFailureKind {
YouTubeSessionRequired,
SabrServerContract,
SabrRecoveryExhausted,
ServerUnavailable,
MediaDelivery,
Network,
UnsupportedFormat,
Expand Down Expand Up @@ -71,6 +72,7 @@ internal fun classifyHttpPlaybackFailure(
statusCode == 401 -> PlaybackFailureKind.AuthenticationExpired
statusCode == 404 || statusCode == 410 -> PlaybackFailureKind.PlaybackSessionExpired
statusCode == 409 -> PlaybackFailureKind.PlaybackGenerationChanged
statusCode in TRANSIENT_SERVER_STATUS_CODES -> PlaybackFailureKind.ServerUnavailable
isYouTubeSessionRequiredMessage(responseBody) -> PlaybackFailureKind.YouTubeSessionRequired
else -> PlaybackFailureKind.MediaDelivery
}
Expand Down Expand Up @@ -116,6 +118,7 @@ private fun Throwable.findHttpFailure(): HttpDataSource.InvalidResponseCodeExcep

private const val MAX_RESPONSE_BODY_BYTES = 8_192
private const val MAX_CAUSE_DEPTH = 8
private val TRANSIENT_SERVER_STATUS_CODES = setOf(502, 503, 504)
private const val REQUEST_ID_HEADER = "X-Request-ID"
private const val SABR_CONTRACT_FAILURE_CODE = "youtube_sabr_contract_mismatch"
private const val SABR_RECOVERY_EXHAUSTED_FAILURE_CODE = "youtube_sabr_recovery_exhausted"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package dev.typetype.android.services

import android.os.SystemClock
import androidx.media3.common.Player

internal class SabrLivePlaybackFollower(private val player: Player) {
private val follower = LivePlaybackFollower()

fun transition(mediaId: String?) {
follower.transition(mediaId)
}

fun observeSeek(positionMs: Long) {
follower.observeSeek(
currentMediaId = player.currentMediaItem?.mediaId,
positionMs = positionMs,
targetMs = player.currentLiveTargetPositionMs(),
)
}

fun followEdge() {
val mediaId = player.currentMediaItem?.mediaId
val positionMs = player.currentPosition
val targetMs = player.currentLiveTargetPositionMs()
follower.initialize(mediaId, positionMs, targetMs)
follower.nextTarget(
currentMediaId = mediaId,
positionMs = positionMs,
targetMs = targetMs,
playing = player.isPlaying,
busy = player.playbackState != Player.STATE_READY || player.isLoading,
nowMs = SystemClock.elapsedRealtime(),
)?.let(player::seekTo)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ internal class SabrLoadErrorHandlingPolicy(
network: PlaybackNetworkObserver = AlwaysAvailablePlaybackNetworkObserver,
) : LoadErrorHandlingPolicy {
private val transportRetryBudget = PlaybackLoadRetryBudget(network)
private val segmentRetryBudget = SabrSegmentRetryBudget()

override fun getFallbackSelectionFor(
fallbackOptions: LoadErrorHandlingPolicy.FallbackOptions,
Expand All @@ -34,10 +35,11 @@ internal class SabrLoadErrorHandlingPolicy(
response.responseCode == SABR_RETRY_RESPONSE_CODE &&
response.dataSpec.uri.pathSegments.isSabrPlaybackPayloadPath()
) {
return sabrSegmentRetryDelayMs(
return segmentRetryBudget.retryDelayMs(
loadTaskId = loadErrorInfo.loadEventInfo.loadTaskId,
errorCount = loadErrorInfo.errorCount,
requestedDelayMs = response.responseBody.sabrResponseRetryAfterMs(),
)
) ?: C.TIME_UNSET
}
if (isTransientHttpStatus(response.responseCode)) {
return transientHttpRetryDelayMs(
Expand All @@ -60,6 +62,7 @@ internal class SabrLoadErrorHandlingPolicy(

override fun onLoadTaskConcluded(loadTaskId: Long) {
transportRetryBudget.conclude(loadTaskId)
segmentRetryBudget.conclude(loadTaskId)
delegate.onLoadTaskConcluded(loadTaskId)
}
}
Expand Down Expand Up @@ -102,7 +105,7 @@ private fun Throwable.hasFailureCode(code: String): Boolean {
internal fun sabrSegmentRetryDelayMs(errorCount: Int, requestedDelayMs: Long?): Long =
boundedRetryDelayMs(
errorCount = errorCount,
maximumRetries = MAX_SEGMENT_RETRIES,
maximumRetries = MAX_SABR_SEGMENT_RETRIES,
requestedDelayMs = requestedDelayMs,
defaultDelayMs = DEFAULT_SABR_RETRY_MS,
minimumDelayMs = MIN_SABR_RETRY_MS,
Expand Down Expand Up @@ -131,5 +134,4 @@ private const val DEFAULT_SABR_RETRY_MS = 250L
private const val MIN_SABR_RETRY_MS = 100L
private const val MAX_SEGMENT_RETRY_DELAY_MS = 3_000L
private const val MAX_CAUSE_DEPTH = 8
private const val MAX_SEGMENT_RETRIES = 59
private const val MAX_MEDIA_LOAD_RETRIES = 24
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,30 @@ internal fun Throwable.isRecoverableSabrSessionFailure(): Boolean {
return false
}

internal fun Throwable.isRecoverableSabrInstanceInterruption(): Boolean {
var current: Throwable? = this
repeat(MAX_CAUSE_DEPTH) {
val coded = current as? CodedFailure
if (
coded?.failureCode == MISSING_SABR_SESSION_CODE ||
coded?.statusCode in TRANSIENT_SERVER_STATUS_CODES
) {
return true
}
val response = current as? HttpDataSource.InvalidResponseCodeException
if (response?.isTransientSabrPayloadFailure() == true) {
return true
}
current = current?.cause?.takeUnless { it === current }
}
return false
}

@androidx.annotation.OptIn(markerClass = [UnstableApi::class])
private fun HttpDataSource.InvalidResponseCodeException.isTransientSabrPayloadFailure(): Boolean =
responseCode in TRANSIENT_SERVER_STATUS_CODES &&
dataSpec.uri.pathSegments.isSabrPlaybackPayloadPath()

private fun Throwable.sabrPlaybackRecoveryExhaustedFailure():
SabrPlaybackRecoveryExhaustedException? {
var current: Throwable? = this
Expand Down Expand Up @@ -180,11 +204,13 @@ internal fun Throwable.sabrSessionReplacementFailure():
internal fun Int.isRecoverableSabrSessionStatus(): Boolean =
this in RECOVERABLE_SESSION_STATUS_CODES

private val RECOVERABLE_SESSION_STATUS_CODES = setOf(202, 404, 409, 410)
private val RECOVERABLE_SESSION_STATUS_CODES = setOf(202, 404, 409, 410, 502, 503, 504)
private val TRANSIENT_SERVER_STATUS_CODES = setOf(502, 503, 504)
private val RECOVERABLE_WINDOW_ACTIONS = setOf(
"retry_fresh_session",
"retry_fresh_session_lower_video_itag",
)
private const val MISSING_SABR_SESSION_CODE = "sabr_playback_not_found"
private const val MAX_SESSION_RECOVERIES = 2
private const val MAX_CAUSE_DEPTH = 8
private const val STABLE_PLAYBACK_RESET_MS = 30_000L
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ internal class SabrPlaybackServiceBridge(
)
private val recoveryGate = SabrPlaybackRecoveryGate()
private val networkRecoveryGate = PlaybackNetworkRecoveryGate()
private val liveFollower = LivePlaybackFollower()
private val liveFollower = SabrLivePlaybackFollower(player)
private var activeBinding: SabrPlaybackBinding? = null
private var networkRetryJob: Job? = null

Expand Down Expand Up @@ -90,7 +90,7 @@ internal class SabrPlaybackServiceBridge(
nowMs = SystemClock.elapsedRealtime(),
)
}
followLiveEdge()
liveFollower.followEdge()
delay(PLAYBACK_CLOCK_INTERVAL_MS)
}
}
Expand Down Expand Up @@ -127,6 +127,13 @@ internal class SabrPlaybackServiceBridge(
}

override fun onPlayerError(error: PlaybackException) {
if (error.isRecoverableSabrInstanceInterruption()) {
val mediaId = player.currentMediaItem?.mediaId ?: return
scheduleNetworkRecovery(
networkRecoveryGate.failed(mediaId, networkMonitor.snapshot()),
)
return
}
if (!error.isRecoverableSabrSessionFailure()) {
if (error.isNetworkPlaybackFailure()) {
val mediaId = player.currentMediaItem?.mediaId ?: return
Expand Down Expand Up @@ -195,11 +202,7 @@ internal class SabrPlaybackServiceBridge(
reason: Int,
) {
if (reason != Player.DISCONTINUITY_REASON_SEEK) return
liveFollower.observeSeek(
currentMediaId = player.currentMediaItem?.mediaId,
positionMs = newPosition.positionMs,
targetMs = player.currentLiveTargetPositionMs(),
)
liveFollower.observeSeek(newPosition.positionMs)
}

override fun onRecoveryRequired(
Expand Down Expand Up @@ -299,20 +302,6 @@ internal class SabrPlaybackServiceBridge(
player.playWhenReady = playWhenReady
}

private fun followLiveEdge() {
val mediaId = player.currentMediaItem?.mediaId
val targetMs = player.currentLiveTargetPositionMs()
liveFollower.initialize(mediaId, player.currentPosition, targetMs)
liveFollower.nextTarget(
currentMediaId = mediaId,
positionMs = player.currentPosition,
targetMs = targetMs,
playing = player.isPlaying,
busy = player.playbackState != Player.STATE_READY || player.isLoading,
nowMs = SystemClock.elapsedRealtime(),
)?.let(player::seekTo)
}

override fun close() {
player.removeListener(this)
recoveryDispatcher.setListener(null)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package dev.typetype.android.services

import dev.typetype.android.data.stream.requestedBufferGoalMs
import dev.typetype.android.domain.stream.SabrPlaybackBinding
import dev.typetype.android.domain.stream.SabrPlaybackBufferedRange
import dev.typetype.android.domain.stream.SabrPlaybackRepository
Expand Down Expand Up @@ -41,17 +42,26 @@ internal class SabrPlaybackWindowLoader(
): PlaybackLoadCancellation {
return launchWindowRequest(callback) {
val activeBinding = transportState.currentBinding()
var requestedBufferGoalMs = 2_500L
repository.refresh(
target = target,
binding = activeBinding,
) {
val current = request()
val ranges = current.bufferedRanges.mapNotNull(::toDomainRange)
val playerTimeMs = current.positionUs.toMilliseconds()
val currentPlaybackRate = playbackRate()
requestedBufferGoalMs = target.requestedBufferGoalMs(
ranges = ranges,
playerTimeMs = playerTimeMs,
playbackRate = currentPlaybackRate,
)
SabrPlaybackSnapshot(
playerTimeMs = current.positionUs.toMilliseconds(),
bufferedRanges = current.bufferedRanges.mapNotNull(::toDomainRange),
playbackRate = playbackRate(),
playerTimeMs = playerTimeMs,
bufferedRanges = ranges,
playbackRate = currentPlaybackRate,
)
}
}.map { session -> session.copy(requestedBufferGoalMs = requestedBufferGoalMs) }
}
}

Expand Down Expand Up @@ -125,6 +135,7 @@ internal fun SabrPlaybackSession.toPlayerWindow(
val audio = requireNotNull(audioWindow) { "SABR omitted its validated audio window" }
return PlaybackWindow(
generation = generation,
bufferGoalUs = requestedBufferGoalMs.toMicroseconds(),
durationUs = durationMs.toMicroseconds(),
startPositionUs = startTimeMs.toMicroseconds(),
endOfStream = endOfStream,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package dev.typetype.android.services

import android.os.SystemClock

internal class SabrSegmentRetryBudget(
private val elapsedRealtimeMs: () -> Long = SystemClock::elapsedRealtime,
private val maximumRetries: Int = MAX_SABR_SEGMENT_RETRIES,
private val maximumRetryDurationMs: Long = MAX_SEGMENT_RETRY_DURATION_MS,
) {
private val firstFailures = mutableMapOf<Long, Long>()

fun retryDelayMs(
loadTaskId: Long,
errorCount: Int,
requestedDelayMs: Long?,
): Long? = synchronized(firstFailures) {
if (errorCount > maximumRetries) return null
val nowMs = elapsedRealtimeMs()
val firstFailureMs = firstFailures.getOrPut(loadTaskId) { nowMs }
val retryDelayMs = sabrSegmentRetryDelayMs(errorCount, requestedDelayMs)
retryDelayMs.takeIf { nowMs - firstFailureMs + it <= maximumRetryDurationMs }
}

fun conclude(loadTaskId: Long) {
synchronized(firstFailures) {
firstFailures.remove(loadTaskId)
}
}
}

internal const val MAX_SABR_SEGMENT_RETRIES = 8
private const val MAX_SEGMENT_RETRY_DURATION_MS = 20_000L
Loading