Improve player error reporting

This commit is contained in:
2026-04-06 20:20:44 +02:00
parent 60291dc78e
commit 9e8772467c
12 changed files with 476 additions and 99 deletions

View File

@@ -39,4 +39,5 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.jellyfin.core)
implementation(libs.okhttp)
testImplementation(libs.junit)
}

View File

@@ -0,0 +1,15 @@
package hu.bbara.purefin.core.player.data
import androidx.media3.common.MediaItem
import hu.bbara.purefin.core.player.model.PlayerError
sealed interface PlayerMediaLoadResult {
data class Success(
val mediaItem: MediaItem,
val resumePositionMs: Long?
) : PlayerMediaLoadResult
data class Failure(
val error: PlayerError
) : PlayerMediaLoadResult
}

View File

@@ -16,6 +16,7 @@ import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.player.model.PlayerError
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@@ -34,9 +35,34 @@ class PlayerMediaRepository @Inject constructor(
private val mediaRepository: MediaRepository,
private val downloadManager: DownloadManager
) {
companion object {
private const val TAG = "PlayerMediaRepo"
}
suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
buildOnlineMediaItem(mediaId, forceTranscode) ?: buildOfflineMediaItem(mediaId)
suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): PlayerMediaLoadResult = withContext(Dispatchers.IO) {
when (val onlineResult = buildOnlineMediaItem(mediaId, forceTranscode)) {
is PlayerMediaLoadResult.Success -> onlineResult
is PlayerMediaLoadResult.Failure -> {
when (val offlineResult = buildOfflineMediaItem(mediaId)) {
is OfflineLoadResult.Success -> {
Log.w(
TAG,
"Using offline fallback for $mediaId after online load failure (forceTranscode=$forceTranscode): ${onlineResult.error.detailText ?: onlineResult.error.summary}"
)
offlineResult.result
}
is OfflineLoadResult.Unavailable -> {
val finalError = onlineResult.error.withAdditionalTechnicalDetail(offlineResult.detail)
Log.w(
TAG,
"Unable to resolve media $mediaId (forceTranscode=$forceTranscode): ${finalError.detailText ?: finalError.summary}"
)
PlayerMediaLoadResult.Failure(finalError)
}
}
}
}
}
private fun calculateResumePosition(
@@ -88,17 +114,25 @@ class PlayerMediaRepository @Inject constructor(
)
}
}.getOrElse { error ->
Log.w("PlayerMediaRepo", "Unable to load next-up items for $episodeId", error)
Log.w(TAG, "Unable to load next-up items for $episodeId", error)
emptyList()
}
}
private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): Pair<MediaItem, Long?>? {
return runCatching {
private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): PlayerMediaLoadResult {
return try {
val playbackDecision = jellyfinApiClient.getPlaybackDecision(
mediaId = mediaId,
forceTranscode = forceTranscode
) ?: return null
) ?: run {
val detail = if (forceTranscode) {
"Jellyfin did not return a transcoded playback source."
} else {
"Jellyfin did not return a playable media source."
}
Log.w(TAG, "No playback decision for $mediaId (forceTranscode=$forceTranscode)")
return PlayerMediaLoadResult.Failure(PlayerError.loadFailure(technicalDetail = detail))
}
val selectedMediaSource = playbackDecision.mediaSource
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
@@ -117,42 +151,50 @@ class PlayerMediaRepository @Inject constructor(
tag = playbackDecision.reportContext
)
mediaItem to resumePositionMs
}.getOrElse { error ->
Log.w("PlayerMediaRepo", "Falling back to offline playback for $mediaId", error)
null
PlayerMediaLoadResult.Success(mediaItem, resumePositionMs)
} catch (error: Exception) {
Log.w(TAG, "Online load failed for $mediaId (forceTranscode=$forceTranscode)", error)
PlayerMediaLoadResult.Failure(PlayerError.fromThrowable(error))
}
}
@OptIn(UnstableApi::class)
private suspend fun buildOfflineMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? {
val download = downloadManager.downloadIndex.getDownload(mediaId.toString())
?.takeIf { it.state == Download.STATE_COMPLETED }
?: return null
private suspend fun buildOfflineMediaItem(mediaId: UUID): OfflineLoadResult {
return try {
val download = downloadManager.downloadIndex.getDownload(mediaId.toString())
?.takeIf { it.state == Download.STATE_COMPLETED }
?: return OfflineLoadResult.Unavailable("Offline fallback unavailable: no completed download.")
val serverUrl = userSessionRepository.serverUrl.first()
val movie = mediaRepository.movies.value[mediaId]
val episode = mediaRepository.episodes.value[mediaId]
val serverUrl = userSessionRepository.serverUrl.first()
val movie = mediaRepository.movies.value[mediaId]
val episode = mediaRepository.episodes.value[mediaId]
val title = movie?.title ?: episode?.title ?: String(download.request.data, Charsets.UTF_8).ifBlank {
"Offline media"
val title = movie?.title ?: episode?.title ?: String(download.request.data, Charsets.UTF_8).ifBlank {
"Offline media"
}
val subtitle = episode?.let { episodeSubtitle(null, it.index) }
val artworkUrl = when {
movie != null -> JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY)
episode != null -> JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY)
else -> JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY)
}
val resumePositionMs = resumePositionFor(movie, episode)
val mediaItem = createMediaItem(
mediaId = mediaId.toString(),
playbackUrl = download.request.uri.toString(),
title = title,
subtitle = subtitle,
artworkUrl = artworkUrl,
)
OfflineLoadResult.Success(PlayerMediaLoadResult.Success(mediaItem, resumePositionMs))
} catch (error: Exception) {
Log.w(TAG, "Offline fallback failed for $mediaId", error)
val technicalDetail = PlayerError.fromThrowable(error).detailText ?: error.javaClass.simpleName
OfflineLoadResult.Unavailable(
"Offline fallback failed: $technicalDetail"
)
}
val subtitle = episode?.let { episodeSubtitle(null, it.index) }
val artworkUrl = when {
movie != null -> JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY)
episode != null -> JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY)
else -> JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY)
}
val resumePositionMs = resumePositionFor(movie, episode)
val mediaItem = createMediaItem(
mediaId = mediaId.toString(),
playbackUrl = download.request.uri.toString(),
title = title,
subtitle = subtitle,
artworkUrl = artworkUrl,
)
return mediaItem to resumePositionMs
}
private fun resumePositionFor(movie: hu.bbara.purefin.core.model.Movie?, episode: hu.bbara.purefin.core.model.Episode?): Long? {
@@ -211,7 +253,7 @@ class PlayerMediaRepository @Inject constructor(
"$baseUrl/Videos/$mediaId/$mediaSourceId/Subtitles/${stream.index}/0/Stream.$format"
}
Log.d("PlayerMediaRepo", "External subtitle: ${stream.displayTitle} ($codec) -> $url")
Log.d(TAG, "External subtitle: ${stream.displayTitle} ($codec) -> $url")
MediaItem.SubtitleConfiguration.Builder(url.toUri())
.setMimeType(mimeType)
@@ -235,7 +277,7 @@ class PlayerMediaRepository @Inject constructor(
"sub", "microdvd" -> MimeTypes.APPLICATION_SUBRIP // sub often converted to srt by Jellyfin
"pgs", "pgssub" -> MimeTypes.APPLICATION_PGS
else -> {
Log.w("PlayerMediaRepo", "Unknown subtitle codec: $codec")
Log.w(TAG, "Unknown subtitle codec: $codec")
null
}
}
@@ -262,4 +304,9 @@ class PlayerMediaRepository @Inject constructor(
.setSubtitleConfigurations(subtitleConfigurations)
.build()
}
private sealed interface OfflineLoadResult {
data class Success(val result: PlayerMediaLoadResult.Success) : OfflineLoadResult
data class Unavailable(val detail: String) : OfflineLoadResult
}
}

View File

@@ -10,6 +10,7 @@ import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.player.model.PlayerError
import hu.bbara.purefin.core.player.model.QueueItemUi
import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.model.TrackType
@@ -74,8 +75,7 @@ class PlayerManager @Inject constructor(
state.copy(
isBuffering = buffering,
isEnded = ended,
error = if (playbackState == Player.STATE_IDLE) state.error else null,
errorCode = if (playbackState == Player.STATE_IDLE) state.errorCode else null
error = if (playbackState == Player.STATE_IDLE) state.error else null
)
}
if (ended) player.pause()
@@ -84,8 +84,7 @@ class PlayerManager @Inject constructor(
override fun onPlayerError(error: PlaybackException) {
_playbackState.update {
it.copy(
error = error.errorCodeName ?: error.localizedMessage ?: "Playback error",
errorCode = error.errorCode
error = PlayerError.fromPlaybackException(error)
)
}
}
@@ -135,7 +134,7 @@ class PlayerManager @Inject constructor(
_progress.value = PlaybackProgressSnapshot()
refreshMetadata(mediaItem)
refreshQueue()
_playbackState.update { it.copy(isEnded = false, error = null, errorCode = null) }
_playbackState.update { it.copy(isEnded = false, error = null) }
}
fun replaceCurrentMediaItem(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
@@ -155,7 +154,7 @@ class PlayerManager @Inject constructor(
player.playWhenReady = true
refreshMetadata(mediaItem)
refreshQueue()
_playbackState.update { it.copy(isEnded = false, error = null, errorCode = null) }
_playbackState.update { it.copy(isEnded = false, error = null) }
}
fun addToQueue(mediaItem: MediaItem) {
@@ -259,7 +258,7 @@ class PlayerManager @Inject constructor(
}
fun clearError() {
_playbackState.update { it.copy(error = null, errorCode = null) }
_playbackState.update { it.copy(error = null) }
}
fun snapshotProgress(): PlaybackProgressSnapshot {
@@ -386,8 +385,7 @@ data class PlaybackStateSnapshot(
val isPlaying: Boolean = false,
val isBuffering: Boolean = false,
val isEnded: Boolean = false,
val error: String? = null,
val errorCode: Int? = null
val error: PlayerError? = null
)
data class PlaybackProgressSnapshot(

View File

@@ -0,0 +1,110 @@
package hu.bbara.purefin.core.player.model
import androidx.media3.common.PlaybackException
enum class PlayerErrorSource {
LOAD,
PLAYBACK
}
data class PlayerError(
val summary: String,
val technicalDetail: String? = null,
val source: PlayerErrorSource,
val errorCode: Int? = null,
val errorCodeName: String? = null,
val retryable: Boolean = false
) {
val detailText: String?
get() = technicalDetail?.takeIf { detail ->
detail.isNotBlank() && !detail.equals(summary, ignoreCase = true)
}
fun withAdditionalTechnicalDetail(additionalDetail: String?): PlayerError {
val normalizedAdditionalDetail = additionalDetail?.trim()?.takeIf { it.isNotEmpty() } ?: return this
val mergedDetail = linkedSetOf<String>().apply {
technicalDetail?.trim()?.takeIf { it.isNotEmpty() }?.let(::add)
add(normalizedAdditionalDetail)
}.joinToString(" | ")
return copy(technicalDetail = mergedDetail)
}
companion object {
fun loadFailure(
summary: String = "Unable to load media",
technicalDetail: String? = null,
retryable: Boolean = true
): PlayerError {
return PlayerError(
summary = summary,
technicalDetail = technicalDetail,
source = PlayerErrorSource.LOAD,
retryable = retryable
)
}
fun invalidMediaId(mediaId: String): PlayerError {
return PlayerError(
summary = "Invalid media id",
technicalDetail = "The requested media id is not a valid UUID: $mediaId",
source = PlayerErrorSource.LOAD,
retryable = false
)
}
fun fromPlaybackException(error: PlaybackException): PlayerError {
return playbackFailure(
errorCode = error.errorCode,
errorCodeName = error.errorCodeName,
technicalDetail = error.localizedMessage,
cause = error.cause
)
}
fun playbackFailure(
errorCode: Int? = null,
errorCodeName: String? = null,
technicalDetail: String? = null,
cause: Throwable? = null,
retryable: Boolean = true
): PlayerError {
val mergedTechnicalDetail = linkedSetOf<String>().apply {
errorCodeName?.takeIf { it.isNotBlank() }?.let(::add)
technicalDetail?.takeIf { it.isNotBlank() }?.let(::add)
cause?.toTechnicalDetail()?.let(::add)
}.joinToString(" | ").ifBlank { null }
return PlayerError(
summary = "Playback error",
technicalDetail = mergedTechnicalDetail,
source = PlayerErrorSource.PLAYBACK,
errorCode = errorCode,
errorCodeName = errorCodeName,
retryable = retryable
)
}
fun fromThrowable(
throwable: Throwable,
summary: String = "Unable to load media",
retryable: Boolean = true
): PlayerError {
return loadFailure(
summary = summary,
technicalDetail = throwable.toTechnicalDetail(),
retryable = retryable
)
}
internal fun Throwable.toTechnicalDetail(): String {
val typeName = this::class.simpleName ?: javaClass.simpleName.ifBlank { javaClass.name }
val message = message?.trim().takeIf { !it.isNullOrEmpty() }
return if (message != null) {
"$typeName: $message"
} else {
typeName
}
}
}
}

View File

@@ -10,7 +10,7 @@ data class PlayerUiState(
val durationMs: Long = 0L,
val positionMs: Long = 0L,
val bufferedMs: Long = 0L,
val error: String? = null,
val error: PlayerError? = null,
val playbackSpeed: Float = 1f,
val chapters: List<TimedMarker> = emptyList(),
val ads: List<TimedMarker> = emptyList(),

View File

@@ -0,0 +1,50 @@
package hu.bbara.purefin.core.player.viewmodel
import androidx.media3.common.PlaybackException
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.player.model.PlayerError
import hu.bbara.purefin.core.player.model.PlayerErrorSource
import org.jellyfin.sdk.model.api.PlayMethod
internal object PlaybackRetryPolicy {
private val retryableErrorCodes = setOf(
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED
)
fun shouldRetryWithTranscoding(
error: PlayerError,
playbackReportContext: PlaybackReportContext
): Boolean {
if (error.source != PlayerErrorSource.PLAYBACK) {
return false
}
if (!playbackReportContext.canRetryWithTranscoding) {
return false
}
if (playbackReportContext.playMethod == PlayMethod.TRANSCODE) {
return false
}
val errorCode = error.errorCode
if (errorCode != null && errorCode in retryableErrorCodes) {
return true
}
val detail = buildString {
append(error.summary)
error.errorCodeName?.let {
append(' ')
append(it)
}
error.detailText?.let {
append(' ')
append(it)
}
}.lowercase()
return "decoder" in detail || "codec" in detail || "unsupported" in detail
}
}

View File

@@ -1,20 +1,20 @@
package hu.bbara.purefin.core.player.viewmodel
import androidx.media3.common.PlaybackException
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.player.data.PlayerMediaLoadResult
import hu.bbara.purefin.core.player.data.PlayerMediaRepository
import hu.bbara.purefin.core.player.manager.MediaContext
import hu.bbara.purefin.core.player.manager.PlaybackStateSnapshot
import hu.bbara.purefin.core.player.manager.PlayerManager
import hu.bbara.purefin.core.player.manager.ProgressManager
import hu.bbara.purefin.core.player.model.PlayerError
import hu.bbara.purefin.core.player.model.PlayerErrorSource
import hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.core.player.model.TrackOption
import org.jellyfin.sdk.model.api.PlayMethod
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -46,9 +46,10 @@ class PlayerViewModel @Inject constructor(
private var autoHideJob: Job? = null
private var lastNextUpMediaId: String? = null
private var dataErrorMessage: String? = null
private var loadError: PlayerError? = null
private var activeMediaId: String? = null
private var transcodingRetryMediaId: String? = null
private var lastLoadRequest: PendingLoadRequest? = null
init {
progressManager.bind(
@@ -72,7 +73,7 @@ class PlayerViewModel @Inject constructor(
isPlaying = state.isPlaying,
isBuffering = state.isBuffering,
isEnded = state.isEnded,
error = state.error ?: dataErrorMessage
error = state.error ?: loadError
)
}
if (state.isEnded) {
@@ -156,35 +157,48 @@ class PlayerViewModel @Inject constructor(
startPositionMsOverride: Long?,
replaceCurrent: Boolean
) {
lastLoadRequest = PendingLoadRequest(
id = id,
forceTranscode = forceTranscode,
startPositionMsOverride = startPositionMsOverride,
replaceCurrent = replaceCurrent
)
val uuid = id.toUuidOrNull()
if (uuid == null) {
dataErrorMessage = "Invalid media id"
_uiState.update { it.copy(error = dataErrorMessage) }
loadError = PlayerError.invalidMediaId(id)
_uiState.update { it.copy(isBuffering = false, error = loadError) }
return
}
loadError = null
_uiState.update { it.copy(isBuffering = true, error = null) }
viewModelScope.launch {
val result = playerMediaRepository.getMediaItem(uuid, forceTranscode = forceTranscode)
if (result != null) {
val (mediaItem, resumePositionMs) = result
when (result) {
is PlayerMediaLoadResult.Success -> {
val mediaItem = result.mediaItem
val resumePositionMs = result.resumePositionMs
// Determine preference key: movies use their own ID, episodes use series ID
val preferenceKey = mediaRepository.episodes.value[uuid]?.seriesId?.toString() ?: id
val mediaContext = MediaContext(mediaId = id, preferenceKey = preferenceKey)
val startPositionMs = startPositionMsOverride ?: resumePositionMs
// Determine preference key: movies use their own ID, episodes use series ID
val preferenceKey = mediaRepository.episodes.value[uuid]?.seriesId?.toString() ?: id
val mediaContext = MediaContext(mediaId = id, preferenceKey = preferenceKey)
val startPositionMs = startPositionMsOverride ?: resumePositionMs
if (replaceCurrent) {
playerManager.replaceCurrentMediaItem(mediaItem, mediaContext, startPositionMs)
} else {
playerManager.play(mediaItem, mediaContext, startPositionMs)
if (replaceCurrent) {
playerManager.replaceCurrentMediaItem(mediaItem, mediaContext, startPositionMs)
} else {
playerManager.play(mediaItem, mediaContext, startPositionMs)
}
if (loadError != null) {
loadError = null
_uiState.update { it.copy(error = null) }
}
}
if (dataErrorMessage != null) {
dataErrorMessage = null
_uiState.update { it.copy(error = null) }
is PlayerMediaLoadResult.Failure -> {
loadError = result.error
_uiState.update { it.copy(isBuffering = false, error = loadError) }
}
} else {
dataErrorMessage = "Unable to load media"
_uiState.update { it.copy(error = dataErrorMessage) }
}
}
}
@@ -192,11 +206,10 @@ class PlayerViewModel @Inject constructor(
private fun maybeRetryWithTranscoding(state: PlaybackStateSnapshot): Boolean {
val currentMediaId = playerManager.metadata.value.mediaId ?: return false
val playbackReportContext = playerManager.metadata.value.playbackReportContext ?: return false
val errorCode = state.errorCode ?: return false
val error = state.error ?: return false
if (currentMediaId == transcodingRetryMediaId) return false
if (!playbackReportContext.canRetryWithTranscoding) return false
if (!isRetryablePlaybackError(errorCode, state.error, playbackReportContext)) return false
if (!PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext)) return false
transcodingRetryMediaId = currentMediaId
loadMediaById(
@@ -208,29 +221,6 @@ class PlayerViewModel @Inject constructor(
return true
}
private fun isRetryablePlaybackError(
errorCode: Int,
errorMessage: String?,
playbackReportContext: PlaybackReportContext
): Boolean {
if (playbackReportContext.playMethod == PlayMethod.TRANSCODE) {
return false
}
if (errorCode in setOf(
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED
)
) {
return true
}
val message = errorMessage?.lowercase().orEmpty()
return "decoder" in message || "codec" in message || "unsupported" in message
}
private fun loadNextUp(currentMediaId: String) {
val uuid = currentMediaId.toUuidOrNull() ?: return
viewModelScope.launch {
@@ -304,6 +294,11 @@ class PlayerViewModel @Inject constructor(
}
fun retry() {
val error = uiState.value.error
if (error?.source == PlayerErrorSource.LOAD) {
retryLoad()
return
}
playerManager.retry()
}
@@ -313,7 +308,7 @@ class PlayerViewModel @Inject constructor(
}
fun clearError() {
dataErrorMessage = null
loadError = null
playerManager.clearError()
_uiState.update { it.copy(error = null) }
}
@@ -326,5 +321,22 @@ class PlayerViewModel @Inject constructor(
playerManager.release()
}
private fun retryLoad() {
val request = lastLoadRequest ?: return
loadMediaById(
id = request.id,
forceTranscode = request.forceTranscode,
startPositionMsOverride = request.startPositionMsOverride,
replaceCurrent = request.replaceCurrent
)
}
private fun String.toUuidOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull()
private data class PendingLoadRequest(
val id: String,
val forceTranscode: Boolean,
val startPositionMsOverride: Long?,
val replaceCurrent: Boolean
)
}

View File

@@ -0,0 +1,48 @@
package hu.bbara.purefin.core.player.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PlayerErrorTest {
@Test
fun `playbackFailure preserves code and detail`() {
val playerError = PlayerError.playbackFailure(
errorCode = 4001,
errorCodeName = "ERROR_CODE_DECODER_INIT_FAILED",
technicalDetail = "Decoder init failed on test device"
)
assertEquals("Playback error", playerError.summary)
assertEquals(PlayerErrorSource.PLAYBACK, playerError.source)
assertEquals(4001, playerError.errorCode)
assertEquals("ERROR_CODE_DECODER_INIT_FAILED", playerError.errorCodeName)
assertNotNull(playerError.detailText)
assertTrue(playerError.detailText!!.contains("ERROR_CODE_DECODER_INIT_FAILED"))
assertTrue(playerError.detailText!!.contains("Decoder init failed on test device"))
}
@Test
fun `withAdditionalTechnicalDetail appends distinct detail`() {
val playerError = PlayerError.loadFailure(technicalDetail = "IllegalStateException: boom")
val mergedError = playerError.withAdditionalTechnicalDetail(
"Offline fallback unavailable: no completed download."
)
assertTrue(mergedError.detailText!!.contains("IllegalStateException: boom"))
assertTrue(mergedError.detailText!!.contains("Offline fallback unavailable: no completed download."))
}
@Test
fun `invalidMediaId is a non retryable load error`() {
val playerError = PlayerError.invalidMediaId("abc")
assertEquals("Invalid media id", playerError.summary)
assertEquals(PlayerErrorSource.LOAD, playerError.source)
assertFalse(playerError.retryable)
assertTrue(playerError.detailText!!.contains("abc"))
}
}

View File

@@ -0,0 +1,77 @@
package hu.bbara.purefin.core.player.viewmodel
import androidx.media3.common.PlaybackException
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.player.model.PlayerError
import hu.bbara.purefin.core.player.model.PlayerErrorSource
import org.jellyfin.sdk.model.api.PlayMethod
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PlaybackRetryPolicyTest {
@Test
fun `decoder failures are retried when transcoding is available`() {
val error = PlayerError(
summary = "Playback error",
technicalDetail = "Decoder init failed",
source = PlayerErrorSource.PLAYBACK,
errorCode = PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
errorCodeName = "ERROR_CODE_DECODER_INIT_FAILED",
retryable = true
)
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_PLAY)
assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
}
@Test
fun `transcoded playback is not retried again`() {
val error = PlayerError(
summary = "Playback error",
technicalDetail = "Unsupported container",
source = PlayerErrorSource.PLAYBACK,
errorCodeName = "ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED",
retryable = true
)
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.TRANSCODE)
assertFalse(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
}
@Test
fun `load errors are never retried as transcoding fallbacks`() {
val error = PlayerError.loadFailure(technicalDetail = "No playable source was returned.")
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_PLAY)
assertFalse(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
}
@Test
fun `unsupported codec detail is retryable without an error code`() {
val error = PlayerError(
summary = "Playback error",
technicalDetail = "Codec unsupported by this device",
source = PlayerErrorSource.PLAYBACK
)
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_STREAM)
assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
}
private fun playbackReportContext(playMethod: PlayMethod): PlaybackReportContext {
return PlaybackReportContext(
playMethod = playMethod,
mediaSourceId = "source-id",
audioStreamIndex = 0,
subtitleStreamIndex = null,
liveStreamId = null,
playSessionId = "session-id",
canRetryWithTranscoding = true
)
}
}