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.
This commit is contained in:
2026-02-09 20:42:40 +01:00
parent c1f733d1f3
commit 8ca3f1a3cc
3 changed files with 77 additions and 61 deletions

View File

@@ -4,7 +4,9 @@ import android.content.Context
import android.util.Log import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.session.UserSessionRepository import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.itemsApi
@@ -65,18 +67,18 @@ class JellyfinApiClient @Inject constructor(
return true 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() val trimmedUrl = url.trim()
if (trimmedUrl.isBlank()) { if (trimmedUrl.isBlank()) {
return false return@withContext false
} }
api.update(baseUrl = trimmedUrl) api.update(baseUrl = trimmedUrl)
return try { try {
val response = api.userApi.authenticateUserByName(username = username, password = password) val response = api.userApi.authenticateUserByName(username = username, password = password)
val authResult = response.content val authResult = response.content
val token = authResult.accessToken ?: return false val token = authResult.accessToken ?: return@withContext false
val userId = authResult.user?.id ?: return false val userId = authResult.user?.id ?: return@withContext false
userSessionRepository.setAccessToken(accessToken = token) userSessionRepository.setAccessToken(accessToken = token)
userSessionRepository.setUserId(userId) userSessionRepository.setUserId(userId)
userSessionRepository.setLoggedIn(true) userSessionRepository.setLoggedIn(true)
@@ -88,13 +90,13 @@ class JellyfinApiClient @Inject constructor(
} }
} }
suspend fun updateApiClient() { suspend fun updateApiClient() = withContext(Dispatchers.IO) {
ensureConfigured() ensureConfigured()
} }
suspend fun getLibraries(): List<BaseItemDto> { suspend fun getLibraries(): List<BaseItemDto> = withContext(Dispatchers.IO) {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return emptyList() return@withContext emptyList()
} }
val response = api.userViewsApi.getUserViews( val response = api.userViewsApi.getUserViews(
userId = getUserId(), userId = getUserId(),
@@ -102,8 +104,7 @@ class JellyfinApiClient @Inject constructor(
includeHidden = false, includeHidden = false,
) )
Log.d("getLibraries", response.content.toString()) Log.d("getLibraries", response.content.toString())
val libraries = response.content.items response.content.items
return libraries
} }
private val itemFields = private val itemFields =
@@ -115,9 +116,9 @@ class JellyfinApiClient @Inject constructor(
ItemFields.SEASON_USER_DATA ItemFields.SEASON_USER_DATA
) )
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto> { suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return emptyList() return@withContext emptyList()
} }
val getItemsRequest = GetItemsRequest( val getItemsRequest = GetItemsRequest(
userId = getUserId(), userId = getUserId(),
@@ -130,16 +131,16 @@ class JellyfinApiClient @Inject constructor(
) )
val response = api.itemsApi.getItems(getItemsRequest) val response = api.itemsApi.getItems(getItemsRequest)
Log.d("getLibraryContent", response.content.toString()) Log.d("getLibraryContent", response.content.toString())
return response.content.items response.content.items
} }
suspend fun getContinueWatching(): List<BaseItemDto> { suspend fun getContinueWatching(): List<BaseItemDto> = withContext(Dispatchers.IO) {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return emptyList() return@withContext emptyList()
} }
val userId = getUserId() val userId = getUserId()
if (userId == null) { if (userId == null) {
return emptyList() return@withContext emptyList()
} }
val getResumeItemsRequest = GetResumeItemsRequest( val getResumeItemsRequest = GetResumeItemsRequest(
userId = userId, userId = userId,
@@ -150,10 +151,10 @@ class JellyfinApiClient @Inject constructor(
) )
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest) val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
Log.d("getContinueWatching", response.content.toString()) Log.d("getContinueWatching", response.content.toString())
return response.content.items response.content.items
} }
suspend fun getNextUpEpisodes(mediaId: UUID): List<BaseItemDto> { suspend fun getNextUpEpisodes(mediaId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
if (!ensureConfigured()) { if (!ensureConfigured()) {
throw IllegalStateException("Not configured") throw IllegalStateException("Not configured")
} }
@@ -165,7 +166,7 @@ class JellyfinApiClient @Inject constructor(
) )
val result = api.tvShowsApi.getNextUp(getNextUpRequest) val result = api.tvShowsApi.getNextUp(getNextUpRequest)
Log.d("getNextUpEpisodes", result.content.toString()) 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 * @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 * @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<BaseItemDto> { suspend fun getLatestFromLibrary(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return emptyList() return@withContext emptyList()
} }
val response = api.userLibraryApi.getLatestMedia( val response = api.userLibraryApi.getLatestMedia(
userId = getUserId(), userId = getUserId(),
@@ -186,24 +187,24 @@ class JellyfinApiClient @Inject constructor(
limit = 10 limit = 10
) )
Log.d("getLatestFromLibrary", response.content.toString()) 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()) { if (!ensureConfigured()) {
return null return@withContext null
} }
val result = api.userLibraryApi.getItem( val result = api.userLibraryApi.getItem(
itemId = mediaId, itemId = mediaId,
userId = getUserId() userId = getUserId()
) )
Log.d("getItemInfo", result.content.toString()) Log.d("getItemInfo", result.content.toString())
return result.content result.content
} }
suspend fun getSeasons(seriesId: UUID): List<BaseItemDto> { suspend fun getSeasons(seriesId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return emptyList() return@withContext emptyList()
} }
val result = api.tvShowsApi.getSeasons( val result = api.tvShowsApi.getSeasons(
userId = getUserId(), userId = getUserId(),
@@ -212,12 +213,12 @@ class JellyfinApiClient @Inject constructor(
enableUserData = true enableUserData = true
) )
Log.d("getSeasons", result.content.toString()) Log.d("getSeasons", result.content.toString())
return result.content.items result.content.items
} }
suspend fun getEpisodesInSeason(seriesId: UUID, seasonId: UUID): List<BaseItemDto> { suspend fun getEpisodesInSeason(seriesId: UUID, seasonId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return emptyList() return@withContext emptyList()
} }
val result = api.tvShowsApi.getEpisodes( val result = api.tvShowsApi.getEpisodes(
userId = getUserId(), userId = getUserId(),
@@ -227,16 +228,16 @@ class JellyfinApiClient @Inject constructor(
enableUserData = true enableUserData = true
) )
Log.d("getEpisodesInSeason", result.content.toString()) Log.d("getEpisodesInSeason", result.content.toString())
return result.content.items result.content.items
} }
suspend fun getNextEpisodes(episodeId: UUID, count: Int = 10): List<BaseItemDto> { suspend fun getNextEpisodes(episodeId: UUID, count: Int = 10): List<BaseItemDto> = withContext(Dispatchers.IO) {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return emptyList() return@withContext emptyList()
} }
// TODO pass complete Episode object not only an id // TODO pass complete Episode object not only an id
val episodeInfo = getItemInfo(episodeId) ?: return emptyList() val episodeInfo = getItemInfo(episodeId) ?: return@withContext emptyList()
val seriesId = episodeInfo.seriesId ?: return emptyList() val seriesId = episodeInfo.seriesId ?: return@withContext emptyList()
val nextUpEpisodesResult = api.tvShowsApi.getEpisodes( val nextUpEpisodesResult = api.tvShowsApi.getEpisodes(
userId = getUserId(), userId = getUserId(),
seriesId = seriesId, seriesId = seriesId,
@@ -247,10 +248,10 @@ class JellyfinApiClient @Inject constructor(
//Remove first element as we need only the next episodes //Remove first element as we need only the next episodes
val nextUpEpisodes = nextUpEpisodesResult.content.items.drop(1) val nextUpEpisodes = nextUpEpisodesResult.content.items.drop(1)
Log.d("getNextEpisodes", nextUpEpisodes.toString()) Log.d("getNextEpisodes", nextUpEpisodes.toString())
return nextUpEpisodes nextUpEpisodes
} }
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> { suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> = withContext(Dispatchers.IO) {
val result = api.mediaInfoApi val result = api.mediaInfoApi
.getPostedPlaybackInfo( .getPostedPlaybackInfo(
mediaId, mediaId,
@@ -276,12 +277,12 @@ class JellyfinApiClient @Inject constructor(
), ),
) )
Log.d("getMediaSources", result.toString()) 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()) { if (!ensureConfigured()) {
return null return@withContext null
} }
val response = api.videosApi.getVideoStreamUrl( val response = api.videosApi.getVideoStreamUrl(
itemId = mediaId, itemId = mediaId,
@@ -289,11 +290,11 @@ class JellyfinApiClient @Inject constructor(
mediaSourceId = mediaSourceId, mediaSourceId = mediaSourceId,
) )
Log.d("getMediaPlaybackUrl", response) Log.d("getMediaPlaybackUrl", response)
return response response
} }
suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long = 0L) { suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long = 0L) = withContext(Dispatchers.IO) {
if (!ensureConfigured()) return if (!ensureConfigured()) return@withContext
api.playStateApi.reportPlaybackStart( api.playStateApi.reportPlaybackStart(
PlaybackStartInfo( PlaybackStartInfo(
itemId = itemId, itemId = itemId,
@@ -308,8 +309,8 @@ class JellyfinApiClient @Inject constructor(
) )
} }
suspend fun reportPlaybackProgress(itemId: UUID, positionTicks: Long, isPaused: Boolean) { suspend fun reportPlaybackProgress(itemId: UUID, positionTicks: Long, isPaused: Boolean) = withContext(Dispatchers.IO) {
if (!ensureConfigured()) return if (!ensureConfigured()) return@withContext
api.playStateApi.reportPlaybackProgress( api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo( PlaybackProgressInfo(
itemId = itemId, itemId = itemId,
@@ -324,8 +325,8 @@ class JellyfinApiClient @Inject constructor(
) )
} }
suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long) { suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long) = withContext(Dispatchers.IO) {
if (!ensureConfigured()) return if (!ensureConfigured()) return@withContext
api.playStateApi.reportPlaybackStopped( api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo( PlaybackStopInfo(
itemId = itemId, itemId = itemId,

View File

@@ -1,21 +1,34 @@
package hu.bbara.purefin.client package hu.bbara.purefin.client
import hu.bbara.purefin.session.UserSessionRepository import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.first import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class JellyfinAuthInterceptor @Inject constructor( class JellyfinAuthInterceptor @Inject constructor(
private val userSessionRepository: UserSessionRepository userSessionRepository: UserSessionRepository
) : Interceptor { ) : Interceptor {
@Volatile
private var cachedToken: String = ""
init {
userSessionRepository.accessToken
.onEach { cachedToken = it }
.launchIn(CoroutineScope(SupervisorJob() + Dispatchers.IO))
}
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val token = runBlocking { userSessionRepository.accessToken.first() } val token = cachedToken
val request = chain.request().newBuilder() val request = chain.request().newBuilder()
.addHeader("X-Emby-Token", token) .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() .build()
return chain.proceed(request) return chain.proceed(request)
} }

View File

@@ -11,7 +11,9 @@ import javax.inject.Inject
import androidx.core.net.toUri import androidx.core.net.toUri
import hu.bbara.purefin.image.JellyfinImageHelper import hu.bbara.purefin.image.JellyfinImageHelper
import hu.bbara.purefin.session.UserSessionRepository import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
@ViewModelScoped @ViewModelScoped
@@ -20,13 +22,13 @@ class MediaRepository @Inject constructor(
private val userSessionRepository: UserSessionRepository private val userSessionRepository: UserSessionRepository
) { ) {
suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? { suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
val mediaSources = jellyfinApiClient.getMediaSources(mediaId) val mediaSources = jellyfinApiClient.getMediaSources(mediaId)
val selectedMediaSource = mediaSources.firstOrNull() ?: return null val selectedMediaSource = mediaSources.firstOrNull() ?: return@withContext null
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl( val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
mediaId = mediaId, mediaId = mediaId,
mediaSourceId = selectedMediaSource.id mediaSourceId = selectedMediaSource.id
) ?: return null ) ?: return@withContext null
val baseItem = jellyfinApiClient.getItemInfo(mediaId) val baseItem = jellyfinApiClient.getItemInfo(mediaId)
// Calculate resume position // Calculate resume position
@@ -43,7 +45,7 @@ class MediaRepository @Inject constructor(
artworkUrl = artworkUrl artworkUrl = artworkUrl
) )
return Pair(mediaItem, resumePositionMs) Pair(mediaItem, resumePositionMs)
} }
private fun calculateResumePosition( private fun calculateResumePosition(
@@ -70,10 +72,10 @@ class MediaRepository @Inject constructor(
return if (percentage in 5.0..95.0) positionMs else null return if (percentage in 5.0..95.0) positionMs else null
} }
suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set<String>, count: Int = 5): List<MediaItem> { suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set<String>, count: Int = 5): List<MediaItem> = withContext(Dispatchers.IO) {
val serverUrl = userSessionRepository.serverUrl.first() val serverUrl = userSessionRepository.serverUrl.first()
val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count) val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count)
return episodes.mapNotNull { episode -> episodes.mapNotNull { episode ->
val id = episode.id ?: return@mapNotNull null val id = episode.id ?: return@mapNotNull null
val stringId = id.toString() val stringId = id.toString()
if (existingIds.contains(stringId)) { if (existingIds.contains(stringId)) {