From 8ca3f1a3cca1a49a16d61ae15d817411338aa117 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Mon, 9 Feb 2026 20:42:40 +0100 Subject: [PATCH] fix: move all network I/O off the main thread to prevent UI freezes Wrap all JellyfinApiClient suspend functions with withContext(Dispatchers.IO) so callers (ViewModels on Main dispatcher) no longer block the UI thread. Replace runBlocking in JellyfinAuthInterceptor with a reactive cached token to avoid blocking OkHttp threads. Add IO dispatching to player MediaRepository for DataStore reads. --- .../bbara/purefin/client/JellyfinApiClient.kt | 95 ++++++++++--------- .../purefin/client/JellyfinAuthInterceptor.kt | 29 ++++-- .../purefin/player/data/MediaRepository.kt | 14 +-- 3 files changed, 77 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt index f7f55ae..241cee2 100644 --- a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt +++ b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt @@ -4,7 +4,9 @@ import android.content.Context import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext import hu.bbara.purefin.session.UserSessionRepository +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.extensions.authenticateUserByName import org.jellyfin.sdk.api.client.extensions.itemsApi @@ -65,18 +67,18 @@ class JellyfinApiClient @Inject constructor( return true } - suspend fun login(url: String, username: String, password: String): Boolean { + suspend fun login(url: String, username: String, password: String): Boolean = withContext(Dispatchers.IO) { val trimmedUrl = url.trim() if (trimmedUrl.isBlank()) { - return false + return@withContext false } api.update(baseUrl = trimmedUrl) - return try { + try { val response = api.userApi.authenticateUserByName(username = username, password = password) val authResult = response.content - val token = authResult.accessToken ?: return false - val userId = authResult.user?.id ?: return false + val token = authResult.accessToken ?: return@withContext false + val userId = authResult.user?.id ?: return@withContext false userSessionRepository.setAccessToken(accessToken = token) userSessionRepository.setUserId(userId) userSessionRepository.setLoggedIn(true) @@ -88,13 +90,13 @@ class JellyfinApiClient @Inject constructor( } } - suspend fun updateApiClient() { + suspend fun updateApiClient() = withContext(Dispatchers.IO) { ensureConfigured() } - suspend fun getLibraries(): List { + suspend fun getLibraries(): List = withContext(Dispatchers.IO) { if (!ensureConfigured()) { - return emptyList() + return@withContext emptyList() } val response = api.userViewsApi.getUserViews( userId = getUserId(), @@ -102,8 +104,7 @@ class JellyfinApiClient @Inject constructor( includeHidden = false, ) Log.d("getLibraries", response.content.toString()) - val libraries = response.content.items - return libraries + response.content.items } private val itemFields = @@ -115,9 +116,9 @@ class JellyfinApiClient @Inject constructor( ItemFields.SEASON_USER_DATA ) - suspend fun getLibraryContent(libraryId: UUID): List { + suspend fun getLibraryContent(libraryId: UUID): List = withContext(Dispatchers.IO) { if (!ensureConfigured()) { - return emptyList() + return@withContext emptyList() } val getItemsRequest = GetItemsRequest( userId = getUserId(), @@ -130,16 +131,16 @@ class JellyfinApiClient @Inject constructor( ) val response = api.itemsApi.getItems(getItemsRequest) Log.d("getLibraryContent", response.content.toString()) - return response.content.items + response.content.items } - suspend fun getContinueWatching(): List { + suspend fun getContinueWatching(): List = withContext(Dispatchers.IO) { if (!ensureConfigured()) { - return emptyList() + return@withContext emptyList() } val userId = getUserId() if (userId == null) { - return emptyList() + return@withContext emptyList() } val getResumeItemsRequest = GetResumeItemsRequest( userId = userId, @@ -150,10 +151,10 @@ class JellyfinApiClient @Inject constructor( ) val response: Response = api.itemsApi.getResumeItems(getResumeItemsRequest) Log.d("getContinueWatching", response.content.toString()) - return response.content.items + response.content.items } - suspend fun getNextUpEpisodes(mediaId: UUID): List { + suspend fun getNextUpEpisodes(mediaId: UUID): List = withContext(Dispatchers.IO) { if (!ensureConfigured()) { throw IllegalStateException("Not configured") } @@ -165,7 +166,7 @@ class JellyfinApiClient @Inject constructor( ) val result = api.tvShowsApi.getNextUp(getNextUpRequest) Log.d("getNextUpEpisodes", result.content.toString()) - return result.content.items + result.content.items } /** @@ -174,9 +175,9 @@ class JellyfinApiClient @Inject constructor( * @param libraryId The UUID of the library to fetch from * @return A list of [BaseItemDto] representing the latest media items that includes Movie, Episode, Season, or an empty list if not configured */ - suspend fun getLatestFromLibrary(libraryId: UUID): List { + suspend fun getLatestFromLibrary(libraryId: UUID): List = withContext(Dispatchers.IO) { if (!ensureConfigured()) { - return emptyList() + return@withContext emptyList() } val response = api.userLibraryApi.getLatestMedia( userId = getUserId(), @@ -186,24 +187,24 @@ class JellyfinApiClient @Inject constructor( limit = 10 ) Log.d("getLatestFromLibrary", response.content.toString()) - return response.content + response.content } - suspend fun getItemInfo(mediaId: UUID): BaseItemDto? { + suspend fun getItemInfo(mediaId: UUID): BaseItemDto? = withContext(Dispatchers.IO) { if (!ensureConfigured()) { - return null + return@withContext null } val result = api.userLibraryApi.getItem( itemId = mediaId, userId = getUserId() ) Log.d("getItemInfo", result.content.toString()) - return result.content + result.content } - suspend fun getSeasons(seriesId: UUID): List { + suspend fun getSeasons(seriesId: UUID): List = withContext(Dispatchers.IO) { if (!ensureConfigured()) { - return emptyList() + return@withContext emptyList() } val result = api.tvShowsApi.getSeasons( userId = getUserId(), @@ -212,12 +213,12 @@ class JellyfinApiClient @Inject constructor( enableUserData = true ) Log.d("getSeasons", result.content.toString()) - return result.content.items + result.content.items } - suspend fun getEpisodesInSeason(seriesId: UUID, seasonId: UUID): List { + suspend fun getEpisodesInSeason(seriesId: UUID, seasonId: UUID): List = withContext(Dispatchers.IO) { if (!ensureConfigured()) { - return emptyList() + return@withContext emptyList() } val result = api.tvShowsApi.getEpisodes( userId = getUserId(), @@ -227,16 +228,16 @@ class JellyfinApiClient @Inject constructor( enableUserData = true ) Log.d("getEpisodesInSeason", result.content.toString()) - return result.content.items + result.content.items } - suspend fun getNextEpisodes(episodeId: UUID, count: Int = 10): List { + suspend fun getNextEpisodes(episodeId: UUID, count: Int = 10): List = withContext(Dispatchers.IO) { if (!ensureConfigured()) { - return emptyList() + return@withContext emptyList() } // TODO pass complete Episode object not only an id - val episodeInfo = getItemInfo(episodeId) ?: return emptyList() - val seriesId = episodeInfo.seriesId ?: return emptyList() + val episodeInfo = getItemInfo(episodeId) ?: return@withContext emptyList() + val seriesId = episodeInfo.seriesId ?: return@withContext emptyList() val nextUpEpisodesResult = api.tvShowsApi.getEpisodes( userId = getUserId(), seriesId = seriesId, @@ -247,10 +248,10 @@ class JellyfinApiClient @Inject constructor( //Remove first element as we need only the next episodes val nextUpEpisodes = nextUpEpisodesResult.content.items.drop(1) Log.d("getNextEpisodes", nextUpEpisodes.toString()) - return nextUpEpisodes + nextUpEpisodes } - suspend fun getMediaSources(mediaId: UUID): List { + suspend fun getMediaSources(mediaId: UUID): List = withContext(Dispatchers.IO) { val result = api.mediaInfoApi .getPostedPlaybackInfo( mediaId, @@ -276,12 +277,12 @@ class JellyfinApiClient @Inject constructor( ), ) Log.d("getMediaSources", result.toString()) - return result.content.mediaSources + result.content.mediaSources } - suspend fun getMediaPlaybackUrl(mediaId: UUID, mediaSourceId: String? = null): String? { + suspend fun getMediaPlaybackUrl(mediaId: UUID, mediaSourceId: String? = null): String? = withContext(Dispatchers.IO) { if (!ensureConfigured()) { - return null + return@withContext null } val response = api.videosApi.getVideoStreamUrl( itemId = mediaId, @@ -289,11 +290,11 @@ class JellyfinApiClient @Inject constructor( mediaSourceId = mediaSourceId, ) Log.d("getMediaPlaybackUrl", response) - return response + response } - suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long = 0L) { - if (!ensureConfigured()) return + suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long = 0L) = withContext(Dispatchers.IO) { + if (!ensureConfigured()) return@withContext api.playStateApi.reportPlaybackStart( PlaybackStartInfo( itemId = itemId, @@ -308,8 +309,8 @@ class JellyfinApiClient @Inject constructor( ) } - suspend fun reportPlaybackProgress(itemId: UUID, positionTicks: Long, isPaused: Boolean) { - if (!ensureConfigured()) return + suspend fun reportPlaybackProgress(itemId: UUID, positionTicks: Long, isPaused: Boolean) = withContext(Dispatchers.IO) { + if (!ensureConfigured()) return@withContext api.playStateApi.reportPlaybackProgress( PlaybackProgressInfo( itemId = itemId, @@ -324,8 +325,8 @@ class JellyfinApiClient @Inject constructor( ) } - suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long) { - if (!ensureConfigured()) return + suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long) = withContext(Dispatchers.IO) { + if (!ensureConfigured()) return@withContext api.playStateApi.reportPlaybackStopped( PlaybackStopInfo( itemId = itemId, diff --git a/app/src/main/java/hu/bbara/purefin/client/JellyfinAuthInterceptor.kt b/app/src/main/java/hu/bbara/purefin/client/JellyfinAuthInterceptor.kt index d68d7b5..dd670c4 100644 --- a/app/src/main/java/hu/bbara/purefin/client/JellyfinAuthInterceptor.kt +++ b/app/src/main/java/hu/bbara/purefin/client/JellyfinAuthInterceptor.kt @@ -1,22 +1,35 @@ package hu.bbara.purefin.client import hu.bbara.purefin.session.UserSessionRepository -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import okhttp3.Interceptor import okhttp3.Response import javax.inject.Inject +import javax.inject.Singleton -class JellyfinAuthInterceptor @Inject constructor ( - private val userSessionRepository: UserSessionRepository +@Singleton +class JellyfinAuthInterceptor @Inject constructor( + userSessionRepository: UserSessionRepository ) : Interceptor { + + @Volatile + private var cachedToken: String = "" + + init { + userSessionRepository.accessToken + .onEach { cachedToken = it } + .launchIn(CoroutineScope(SupervisorJob() + Dispatchers.IO)) + } + override fun intercept(chain: Interceptor.Chain): Response { - val token = runBlocking { userSessionRepository.accessToken.first() } + val token = cachedToken val request = chain.request().newBuilder() .addHeader("X-Emby-Token", token) - // Some Jellyfin versions prefer the Authorization header: - // .addHeader("Authorization", "MediaBrowser Client=\"YourAppName\", Device=\"YourDevice\", DeviceId=\"123\", Version=\"1.0.0\", Token=\"$token\"") .build() return chain.proceed(request) } -} \ No newline at end of file +} diff --git a/app/src/main/java/hu/bbara/purefin/player/data/MediaRepository.kt b/app/src/main/java/hu/bbara/purefin/player/data/MediaRepository.kt index fb72a5c..6dfc7b0 100644 --- a/app/src/main/java/hu/bbara/purefin/player/data/MediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/player/data/MediaRepository.kt @@ -11,7 +11,9 @@ import javax.inject.Inject import androidx.core.net.toUri import hu.bbara.purefin.image.JellyfinImageHelper import hu.bbara.purefin.session.UserSessionRepository +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.ImageType @ViewModelScoped @@ -20,13 +22,13 @@ class MediaRepository @Inject constructor( private val userSessionRepository: UserSessionRepository ) { - suspend fun getMediaItem(mediaId: UUID): Pair? { + suspend fun getMediaItem(mediaId: UUID): Pair? = withContext(Dispatchers.IO) { val mediaSources = jellyfinApiClient.getMediaSources(mediaId) - val selectedMediaSource = mediaSources.firstOrNull() ?: return null + val selectedMediaSource = mediaSources.firstOrNull() ?: return@withContext null val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl( mediaId = mediaId, mediaSourceId = selectedMediaSource.id - ) ?: return null + ) ?: return@withContext null val baseItem = jellyfinApiClient.getItemInfo(mediaId) // Calculate resume position @@ -43,7 +45,7 @@ class MediaRepository @Inject constructor( artworkUrl = artworkUrl ) - return Pair(mediaItem, resumePositionMs) + Pair(mediaItem, resumePositionMs) } private fun calculateResumePosition( @@ -70,10 +72,10 @@ class MediaRepository @Inject constructor( return if (percentage in 5.0..95.0) positionMs else null } - suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set, count: Int = 5): List { + suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set, count: Int = 5): List = withContext(Dispatchers.IO) { val serverUrl = userSessionRepository.serverUrl.first() val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count) - return episodes.mapNotNull { episode -> + episodes.mapNotNull { episode -> val id = episode.id ?: return@mapNotNull null val stringId = id.toString() if (existingIds.contains(stringId)) {