From de326beaf2e39f21799fb1a223bfaae886069bb9 Mon Sep 17 00:00:00 2001 From: birdbird <6892457-bird.bird@users.noreply.gitlab.com> Date: Thu, 1 Oct 2026 21:35:10 +0000 Subject: [PATCH] Little playback fixes --- .../service/PlaybackErrorClassifier.kt | 108 +++++++++++++ .../ultrasonic/service/PlaybackService.kt | 39 +++-- .../service/PlaybackErrorClassifierTest.kt | 142 ++++++++++++++++++ 3 files changed, 267 insertions(+), 22 deletions(-) create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackErrorClassifier.kt create mode 100644 ultrasonic/src/test/kotlin/org/moire/ultrasonic/service/PlaybackErrorClassifierTest.kt diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackErrorClassifier.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackErrorClassifier.kt new file mode 100644 index 00000000..ebd3f1a9 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackErrorClassifier.kt @@ -0,0 +1,108 @@ +/* + * PlaybackErrorClassifier.kt + * Copyright (C) 2009-2026 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ +package org.moire.ultrasonic.service + +import androidx.media3.common.PlaybackException +import java.io.EOFException +import java.io.FileNotFoundException +import java.net.ConnectException +import java.net.SocketException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import org.moire.ultrasonic.data.causeChain + +/** + * Classifies a Media3 playback error into the recovery categories used by + * [PlaybackService.recoverFromPlaybackError]. + * + * Media3 halts in [androidx.media3.common.Player.STATE_IDLE] after any unhandled item + * error and never advances on its own, so an unclassified error leaves playback stopped + * with a dead UI. Anything that is scoped to a single item is recoverable: it either + * deserves a same-item retry (the network may already have recovered) or a skip to the + * next item (the item itself is unplayable, but the rest of the queue is fine). Only + * errors that do not point at an item are left unhandled, so an unexpected failure + * surfaces to the user instead of looping through the queue. + */ +internal object PlaybackErrorClassifier { + + enum class Category { + /** + * Transient transport failure (socket timeout, connection refused, DNS): the + * item is probably fine, retry it before giving up on it. + */ + TRANSIENT_CONNECTIVITY, + + /** + * The library is offline ([OfflineException] reached the loader for a queued + * streaming track): the item cannot play now, but remains queued for later. + */ + OFFLINE, + + /** + * The item itself is unplayable (HTTP error status, malformed container, + * undecodable format, missing or truncated file): re-preparing cannot succeed, + * advance to the next item in play order. + */ + ITEM_LEVEL, + + /** + * Anything that does not point at a single item (remote errors, live window, + * unexpected runtime checks): no automatic recovery, the error is surfaced + * through the player state. + */ + UNEXPECTED + } + + fun classify(error: PlaybackException): Category = when { + error.hasTransientConnectivityFailure() -> Category.TRANSIENT_CONNECTIVITY + error.hasOfflineCause() -> Category.OFFLINE + error.hasItemLevelCause() -> Category.ITEM_LEVEL + else -> Category.UNEXPECTED + } + + private fun PlaybackException.hasTransientConnectivityFailure(): Boolean = + errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED || + errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT || + causeChain().any { + it is SocketTimeoutException || + it is ConnectException || + it is SocketException || + it is UnknownHostException + } + + private fun PlaybackException.hasOfflineCause(): Boolean = + causeChain().any { it is OfflineException } + + private fun PlaybackException.hasItemLevelCause(): Boolean = + errorCode in ITEM_LEVEL_ERROR_CODES || + causeChain().any { it is FileNotFoundException || it is EOFException } + + // Item-scoped Media3 error codes: an I/O failure with the item's source (HTTP + // status, missing or unreadable file, wrong content type), a failure to parse the + // item's container or manifest, and a failure to decode or render the item's + // streams. Cleartext not permitted is deliberately excluded: it is a configuration + // problem that affects every stream, not a property of one item. + private val ITEM_LEVEL_ERROR_CODES = setOf( + PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, + PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, + PlaybackException.ERROR_CODE_IO_NO_PERMISSION, + PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE, + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, + PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, + PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED, + PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED, + PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED, + PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, + PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED, + PlaybackException.ERROR_CODE_DECODING_FAILED, + PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES, + PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, + PlaybackException.ERROR_CODE_DECODING_RESOURCES_RECLAIMED, + PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED, + PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED + ) +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackService.kt index b4b0480a..21895a88 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackService.kt @@ -36,10 +36,6 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession -import java.net.ConnectException -import java.net.SocketException -import java.net.SocketTimeoutException -import java.net.UnknownHostException import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -60,7 +56,6 @@ import org.moire.ultrasonic.audiofx.EqualizerController import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.CachedDataSource import org.moire.ultrasonic.data.LibraryAccessState -import org.moire.ultrasonic.data.causeChain import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.imageloader.ArtworkBitmapLoader import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider @@ -120,6 +115,16 @@ class PlaybackService : override fun onCreate() { Timber.i("onCreate called") super.onCreate() + + // Keep the media notification (and with it the foreground service) whenever the + // queue is non-empty, even while the player sits in STATE_IDLE. The media3 + // default (AFTER_STOP_OR_ERROR) re-hides the notification for a queue that was + // restored but not prepared yet, so a service restart after a fatal error could + // leave playback dead with no notification and no way back into the player. + // A visible notification for a stopped player lets the user resume or dismiss + // it instead of silently losing playback (issue 1313). + setShowNotificationForIdlePlayer(SHOW_NOTIFICATION_FOR_IDLE_PLAYER_ALWAYS) + initializeSessionAndPlayer() setListener(MediaSessionServiceListener()) } @@ -548,8 +553,9 @@ class PlaybackService : /** * Recovers from a failed playback item without disturbing the queue. A transient * connectivity failure gets a bounded number of same-item retries, other recoverable - * failures (e.g. OfflineException for a queued streaming track while offline) advance - * to the next item in play order. The queue itself is never edited here: entries + * failures (e.g. OfflineException for a queued streaming track while offline, or an + * item-level error such as a bad HTTP status or an undecodable file) advance to the + * next item in play order. The queue itself is never edited here: entries * survive so playback continues wherever material is available, and the original * queue is intact once the server is reachable again. * @@ -558,8 +564,10 @@ class PlaybackService : * offline mode. */ private fun recoverFromPlaybackError(error: PlaybackException) { - val connectivity = error.hasTransientConnectivityFailure() - if (!connectivity && !error.hasOfflineCause()) return + val category = PlaybackErrorClassifier.classify(error) + if (category == PlaybackErrorClassifier.Category.UNEXPECTED) return + + val connectivity = category == PlaybackErrorClassifier.Category.TRANSIENT_CONNECTIVITY when (val recovery = failureTracker.onItemFailure(retryable = connectivity)) { is PlaybackFailureTracker.Recovery.RetrySameItem -> @@ -621,19 +629,6 @@ class PlaybackService : pendingRecovery = null } - private fun PlaybackException.hasTransientConnectivityFailure(): Boolean = - errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED || - errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT || - causeChain().any { - it is SocketTimeoutException || - it is ConnectException || - it is SocketException || - it is UnknownHostException - } - - private fun PlaybackException.hasOfflineCause(): Boolean = - causeChain().any { it is OfflineException } - private val listener: Player.Listener = object : Player.Listener { override fun onPlayerError(error: PlaybackException) { Timber.w(error, "Playback error") diff --git a/ultrasonic/src/test/kotlin/org/moire/ultrasonic/service/PlaybackErrorClassifierTest.kt b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/service/PlaybackErrorClassifierTest.kt new file mode 100644 index 00000000..f0287190 --- /dev/null +++ b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/service/PlaybackErrorClassifierTest.kt @@ -0,0 +1,142 @@ +/* + * PlaybackErrorClassifierTest.kt + * Copyright (C) 2009-2026 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.service + +import androidx.media3.common.PlaybackException +import java.io.EOFException +import java.io.FileNotFoundException +import java.io.IOException +import java.net.SocketTimeoutException +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PlaybackErrorClassifierTest { + + private fun error(cause: Throwable?, code: Int) = PlaybackException("test", cause, code) + + @Test + fun `network connection failure is transient connectivity`() { + val category = PlaybackErrorClassifier.classify( + error(null, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED) + ) + + category shouldBeEqualTo PlaybackErrorClassifier.Category.TRANSIENT_CONNECTIVITY + } + + @Test + fun `socket timeout cause is transient connectivity`() { + val category = PlaybackErrorClassifier.classify( + error( + IOException(SocketTimeoutException("read timed out")), + PlaybackException.ERROR_CODE_IO_UNSPECIFIED + ) + ) + + category shouldBeEqualTo PlaybackErrorClassifier.Category.TRANSIENT_CONNECTIVITY + } + + @Test + fun `offline exception is offline`() { + val category = PlaybackErrorClassifier.classify( + error(OfflineException("offline"), PlaybackException.ERROR_CODE_IO_UNSPECIFIED) + ) + + category shouldBeEqualTo PlaybackErrorClassifier.Category.OFFLINE + } + + @Test + fun `bad http status is item level`() { + val category = PlaybackErrorClassifier.classify( + error(null, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS) + ) + + category shouldBeEqualTo PlaybackErrorClassifier.Category.ITEM_LEVEL + } + + @Test + fun `malformed container is item level`() { + val category = PlaybackErrorClassifier.classify( + error(null, PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED) + ) + + category shouldBeEqualTo PlaybackErrorClassifier.Category.ITEM_LEVEL + } + + @Test + fun `decoding failure is item level`() { + val category = PlaybackErrorClassifier.classify( + error(null, PlaybackException.ERROR_CODE_DECODING_FAILED) + ) + + category shouldBeEqualTo PlaybackErrorClassifier.Category.ITEM_LEVEL + } + + @Test + fun `missing file cause is item level`() { + val category = PlaybackErrorClassifier.classify( + error( + IOException(FileNotFoundException("cache file pruned")), + PlaybackException.ERROR_CODE_IO_UNSPECIFIED + ) + ) + + category shouldBeEqualTo PlaybackErrorClassifier.Category.ITEM_LEVEL + } + + @Test + fun `truncated file cause is item level`() { + val category = PlaybackErrorClassifier.classify( + error( + EOFException("unexpected end of file"), + PlaybackException.ERROR_CODE_IO_UNSPECIFIED + ) + ) + + category shouldBeEqualTo PlaybackErrorClassifier.Category.ITEM_LEVEL + } + + @Test + fun `unspecified error without a known cause is unexpected`() { + val category = PlaybackErrorClassifier.classify( + error(IllegalStateException("boom"), PlaybackException.ERROR_CODE_UNSPECIFIED) + ) + + category shouldBeEqualTo PlaybackErrorClassifier.Category.UNEXPECTED + } + + @Test + fun `cleartext not permitted is unexpected because it affects every stream`() { + val category = PlaybackErrorClassifier.classify( + error(null, PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED) + ) + + category shouldBeEqualTo PlaybackErrorClassifier.Category.UNEXPECTED + } + + @Test + fun `remote error is unexpected`() { + val category = PlaybackErrorClassifier.classify( + error(null, PlaybackException.ERROR_CODE_REMOTE_ERROR) + ) + + category shouldBeEqualTo PlaybackErrorClassifier.Category.UNEXPECTED + } + + @Test + fun `connectivity classification wins over other causes`() { + val cause = IOException(SocketTimeoutException("timed out")) + val category = PlaybackErrorClassifier.classify( + error(cause, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS) + ) + + category shouldBeEqualTo PlaybackErrorClassifier.Category.TRANSIENT_CONNECTIVITY + } +}