@@ -58,6 +58,13 @@ class PlaybackService : MediaLibraryService() {
5858 // Skip silence setting listener
5959 private var skipSilenceListener: SettingsListener ? = null
6060
61+ // Retry mechanism for 403 errors
62+ private data class RetryState (
63+ var retryCount : Int = 0 ,
64+ var hasRefreshedStream : Boolean = false
65+ )
66+ private val retryStates = mutableMapOf<String , RetryState >()
67+
6168 private enum class PlaybackButtonState (
6269 val repeatMode : Int ,
6370 val shuffleEnabled : Boolean ,
@@ -113,6 +120,8 @@ class PlaybackService : MediaLibraryService() {
113120
114121 companion object {
115122 private const val SPONSOR_BLOCK_CHECK_INTERVAL_MS = 500L
123+ private const val MAX_RETRIES_BEFORE_REFRESH = 1
124+ private const val MAX_RETRIES_AFTER_REFRESH = 1
116125 }
117126
118127 object CustomCommands {
@@ -588,6 +597,77 @@ class PlaybackService : MediaLibraryService() {
588597 // Return null if prefix not found - don't return the whole error message
589598 return null
590599 }
600+
601+ private fun is403Error (error : PlaybackException ): Boolean {
602+ var cause: Throwable ? = error
603+ while (cause != null ) {
604+ val message = cause.message ? : " "
605+ // Check for HTTP 403 in various forms
606+ if (message.contains(" 403" ) ||
607+ message.contains(" Forbidden" ) ||
608+ message.contains(" responseCode=403" )) {
609+ return true
610+ }
611+ // Check if it's an HttpDataSourceException with response code 403
612+ if (cause.javaClass.simpleName == " HttpDataSourceException" ) {
613+ try {
614+ val field = cause.javaClass.getDeclaredField(" responseCode" )
615+ field.isAccessible = true
616+ val responseCode = field.getInt(cause)
617+ if (responseCode == 403 ) {
618+ return true
619+ }
620+ } catch (e: Exception ) {
621+ // Ignore reflection errors
622+ }
623+ }
624+ cause = cause.cause
625+ }
626+ return false
627+ }
628+
629+ private fun refreshStreamAndRetry (mediaId : String , currentIndex : Int ) {
630+ serviceScope.launch {
631+ try {
632+ // Get service ID from current media item
633+ val currentItem = player.getMediaItemAt(currentIndex)
634+ val serviceId = currentItem.mediaMetadata.extras?.getString(" KEY_SERVICE_ID" )
635+
636+ // Fetch fresh stream info
637+ val streamInfo = withContext(Dispatchers .IO ) {
638+ executeJobFlow(
639+ SupportedJobType .FETCH_INFO ,
640+ mediaId,
641+ serviceId
642+ ).info as StreamInfo
643+ }
644+
645+ // Create new media item with fresh stream URLs
646+ val newMediaItem = streamInfo.toMediaItem()
647+
648+ // Replace the media item at the current position
649+ withContext(Dispatchers .Main ) {
650+ player.removeMediaItem(currentIndex)
651+ player.addMediaItem(currentIndex, newMediaItem)
652+
653+ // Seek to the same position and try to play
654+ player.seekTo(currentIndex, player.currentPosition)
655+ player.prepare()
656+ player.play()
657+ }
658+ } catch (e: Exception ) {
659+ e.printStackTrace()
660+ // If refresh fails, just skip to next
661+ withContext(Dispatchers .Main ) {
662+ player.removeMediaItem(currentIndex)
663+ if (player.mediaItemCount > 0 ) {
664+ player.prepare()
665+ player.play()
666+ }
667+ }
668+ }
669+ }
670+ }
591671 private fun createPlayerListener (): Player .Listener {
592672 return object : Player .Listener {
593673 override fun onMediaItemTransition (mediaItem : MediaItem ? , reason : Int ) {
@@ -597,6 +677,8 @@ class PlaybackService : MediaLibraryService() {
597677 mediaItem?.let {
598678 skippedSegments[it.mediaId] = mutableSetOf ()
599679 loadSponsorBlockForMedia(it)
680+ // Reset retry state when successfully transitioning to a new item
681+ retryStates.remove(it.mediaId)
600682 }
601683 }
602684
@@ -638,37 +720,104 @@ class PlaybackService : MediaLibraryService() {
638720 }
639721
640722 // Extract failed mediaId from error message
641- // Use helper function to properly parse the mediaId
642723 val failedMediaId = extractMediaIdFromError(error)
724+ val mediaId = failedMediaId ? : player.currentMediaItem?.mediaId
643725
726+ // Log the error
644727 MainScope ().launch {
645728 DatabaseOperations .insertErrorLog(
646729 stacktrace = error.stackTraceToString(),
647- request = failedMediaId ? : player.currentMediaItem?. mediaId,
730+ request = mediaId,
648731 task = " PLAY_STREAM" ,
649732 errorCode = " PLAY_000"
650733 )
651734 }
735+
736+ // Check if this is a 403 error and handle retry logic
737+ if (is403Error(error) && mediaId != null ) {
738+ val retryState = retryStates.getOrPut(mediaId) { RetryState () }
739+
740+ // Find the index of the failed item
741+ val itemIndex = if (failedMediaId != null ) {
742+ (0 until player.mediaItemCount).firstOrNull { i ->
743+ player.getMediaItemAt(i).mediaId == failedMediaId
744+ } ? : player.currentMediaItemIndex
745+ } else {
746+ player.currentMediaItemIndex
747+ }
748+
749+ if (itemIndex == C .INDEX_UNSET ) {
750+ // Can't find item, show error and skip
751+ ToastManager .show(MR .strings.playback_error.desc().toString(this @PlaybackService))
752+ if (player.mediaItemCount > 0 ) {
753+ player.prepare()
754+ player.play()
755+ }
756+ return
757+ }
758+
759+ // First phase: retry up to 5 times before refreshing
760+ if (! retryState.hasRefreshedStream && retryState.retryCount < MAX_RETRIES_BEFORE_REFRESH ) {
761+ retryState.retryCount++
762+ ToastManager .show(" 403 error, retrying (${retryState.retryCount} /$MAX_RETRIES_BEFORE_REFRESH )..." )
763+
764+ // Simple retry - just prepare and play again
765+ player.prepare()
766+ player.play()
767+ return
768+ }
769+
770+ // Second phase: refresh stream if we haven't done so yet
771+ if (! retryState.hasRefreshedStream) {
772+ retryState.hasRefreshedStream = true
773+ retryState.retryCount = 0
774+ ToastManager .show(" Refreshing stream after $MAX_RETRIES_BEFORE_REFRESH retries..." )
775+
776+ refreshStreamAndRetry(mediaId, itemIndex)
777+ return
778+ }
779+
780+ // Third phase: retry up to 5 more times after refresh
781+ if (retryState.retryCount < MAX_RETRIES_AFTER_REFRESH ) {
782+ retryState.retryCount++
783+ ToastManager .show(" 403 error after refresh, retrying (${retryState.retryCount} /$MAX_RETRIES_AFTER_REFRESH )..." )
784+
785+ // Simple retry - just prepare and play again
786+ player.prepare()
787+ player.play()
788+ return
789+ }
790+
791+ // Final phase: all retries exhausted, give up and skip to next
792+ ToastManager .show(" Failed after all retries, skipping to next..." )
793+ retryStates.remove(mediaId) // Clean up retry state
794+ player.removeMediaItem(itemIndex)
795+
796+ if (player.mediaItemCount > 0 ) {
797+ player.prepare()
798+ player.play()
799+ }
800+ return
801+ }
802+
803+ // For non-403 errors, use the original logic
652804 ToastManager .show(MR .strings.playback_error.desc().toString(this @PlaybackService))
653805
654- // Find and remove the failed item
655- // If we found a specific failed mediaId, remove that item
656- // Otherwise, remove the current item to prevent infinite retry loop
657806 val itemToRemove = if (failedMediaId != null ) {
658- // Try to find the specific failed item by mediaId
659807 (0 until player.mediaItemCount).firstOrNull { i ->
660808 player.getMediaItemAt(i).mediaId == failedMediaId
661809 }
662810 } else {
663- // Couldn't extract mediaId from error, remove current item as fallback
664811 player.currentMediaItemIndex.takeIf { it != C .INDEX_UNSET }
665812 }
666813
667814 itemToRemove?.let { index ->
815+ // Clean up retry state for this item before removing
816+ val itemMediaId = player.getMediaItemAt(index).mediaId
817+ retryStates.remove(itemMediaId)
668818 player.removeMediaItem(index)
669819 }
670820
671- // Continue playback if there are items left
672821 if (player.mediaItemCount > 0 ) {
673822 player.prepare()
674823 player.play()
0 commit comments