From 188efde9824b4fb0f89a36b8f6a5fb9909c881f7 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Wed, 22 Apr 2026 18:58:15 +0200 Subject: [PATCH] Refactor JellyfinApiClient into thin API wrapper --- .../DefaultPlayableMediaRepository.kt | 12 +- .../data/jellyfin/JellyfinBindingsModule.kt | 10 +- .../data/jellyfin/client/JellyfinApiClient.kt | 378 ++++-------------- .../download/JellyfinDownloadMappers.kt | 83 ++++ .../JellyfinDownloadMediaSourceResolver.kt | 124 ++++++ .../AndroidCodecCatalog.kt | 2 +- .../AndroidDeviceProfileCapabilities.kt | 2 +- .../{client => playback}/CodecDebugHelper.kt | 2 +- .../JellyfinAndroidMobileDeviceProfile.kt | 2 +- .../JellyfinAndroidTvDeviceProfile.kt | 2 +- .../playback/JellyfinPlaybackResolver.kt | 80 ++++ .../{client => playback}/PlaybackCacheKeys.kt | 2 +- .../{client => playback}/PlaybackDecision.kt | 2 +- .../PlaybackDecisionResolver.kt | 2 +- .../PlaybackProfileModule.kt | 2 +- .../PlaybackProfilePolicy.kt | 3 +- .../JellyfinAuthenticationRepository.kt | 35 ++ .../session/JellyfinSessionBootstrapper.kt | 15 + .../AndroidDeviceProfileTest.kt | 2 +- .../PlaybackCacheKeysTest.kt | 2 +- .../PlaybackDecisionResolverTest.kt | 2 +- 21 files changed, 433 insertions(+), 331 deletions(-) create mode 100644 data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMappers.kt create mode 100644 data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMediaSourceResolver.kt rename data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/{client => playback}/AndroidCodecCatalog.kt (99%) rename data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/{client => playback}/AndroidDeviceProfileCapabilities.kt (99%) rename data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/{client => playback}/CodecDebugHelper.kt (97%) rename data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/{client => playback}/JellyfinAndroidMobileDeviceProfile.kt (99%) rename data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/{client => playback}/JellyfinAndroidTvDeviceProfile.kt (99%) create mode 100644 data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/JellyfinPlaybackResolver.kt rename data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/{client => playback}/PlaybackCacheKeys.kt (92%) rename data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/{client => playback}/PlaybackDecision.kt (83%) rename data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/{client => playback}/PlaybackDecisionResolver.kt (97%) rename data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/{client => playback}/PlaybackProfileModule.kt (94%) rename data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/{client => playback}/PlaybackProfilePolicy.kt (89%) create mode 100644 data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/session/JellyfinAuthenticationRepository.kt create mode 100644 data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/session/JellyfinSessionBootstrapper.kt rename data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/{client => playback}/AndroidDeviceProfileTest.kt (99%) rename data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/{client => playback}/PlaybackCacheKeysTest.kt (96%) rename data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/{client => playback}/PlaybackDecisionResolverTest.kt (99%) diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt index 656ae845..1acd6ed7 100644 --- a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt @@ -7,12 +7,13 @@ import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.util.UnstableApi import hu.bbara.purefin.core.data.PlayableMediaRepository -import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient -import hu.bbara.purefin.data.jellyfin.client.PlaybackDecision import hu.bbara.purefin.core.data.PlaybackReportContext -import hu.bbara.purefin.data.jellyfin.client.playbackCustomCacheKey import hu.bbara.purefin.core.data.session.UserSessionRepository import hu.bbara.purefin.core.image.ImageUrlBuilder +import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient +import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver +import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecision +import hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -26,11 +27,12 @@ import org.jellyfin.sdk.model.api.MediaSourceInfo @Singleton class DefaultPlayableMediaRepository @Inject constructor( private val jellyfinApiClient: JellyfinApiClient, + private val jellyfinPlaybackResolver: JellyfinPlaybackResolver, private val userSessionRepository: UserSessionRepository, ) : PlayableMediaRepository { override suspend fun getMediaItem(mediaId: UUID): Pair? = withContext(Dispatchers.IO) { - val playbackDecision = jellyfinApiClient.getPlaybackDecision(mediaId) ?: return@withContext null + val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(mediaId) ?: return@withContext null val baseItem = jellyfinApiClient.getItemInfo(mediaId) val resumePositionMs = calculateResumePosition(baseItem, playbackDecision.mediaSource) @@ -64,7 +66,7 @@ class DefaultPlayableMediaRepository @Inject constructor( if (existingIds.contains(stringId)) { return@mapNotNull null } - val playbackDecision = jellyfinApiClient.getPlaybackDecision(id) ?: return@mapNotNull null + val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(id) ?: return@mapNotNull null val artworkUrl = ImageUrlBuilder.toImageUrl(serverUrl, id, ArtworkKind.PRIMARY) createMediaItem( mediaId = stringId, diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/JellyfinBindingsModule.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/JellyfinBindingsModule.kt index c80bf30b..d6375ecd 100644 --- a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/JellyfinBindingsModule.kt +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/JellyfinBindingsModule.kt @@ -10,7 +10,9 @@ import hu.bbara.purefin.core.data.NetworkMonitor import hu.bbara.purefin.core.data.PlayableMediaRepository import hu.bbara.purefin.core.data.PlaybackProgressReporter import hu.bbara.purefin.core.data.SessionBootstrapper -import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient +import hu.bbara.purefin.data.jellyfin.download.JellyfinDownloadMediaSourceResolver +import hu.bbara.purefin.data.jellyfin.session.JellyfinAuthenticationRepository +import hu.bbara.purefin.data.jellyfin.session.JellyfinSessionBootstrapper import javax.inject.Singleton @Module @@ -19,15 +21,15 @@ abstract class JellyfinBindingsModule { @Binds @Singleton - abstract fun bindSessionBootstrapper(impl: JellyfinApiClient): SessionBootstrapper + abstract fun bindSessionBootstrapper(impl: JellyfinSessionBootstrapper): SessionBootstrapper @Binds @Singleton - abstract fun bindAuthenticationRepository(impl: JellyfinApiClient): AuthenticationRepository + abstract fun bindAuthenticationRepository(impl: JellyfinAuthenticationRepository): AuthenticationRepository @Binds @Singleton - abstract fun bindDownloadMediaSourceResolver(impl: JellyfinApiClient): DownloadMediaSourceResolver + abstract fun bindDownloadMediaSourceResolver(impl: JellyfinDownloadMediaSourceResolver): DownloadMediaSourceResolver @Binds abstract fun bindPlayableMediaRepository(impl: DefaultPlayableMediaRepository): PlayableMediaRepository diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt index 38ce420b..5c37de12 100644 --- a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt @@ -3,20 +3,9 @@ package hu.bbara.purefin.data.jellyfin.client import android.content.Context import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext -import hu.bbara.purefin.core.data.AuthenticationRepository -import hu.bbara.purefin.core.data.DownloadMediaSourceResolver -import hu.bbara.purefin.core.data.EpisodeDownloadSource -import hu.bbara.purefin.core.data.MovieDownloadSource import hu.bbara.purefin.core.data.PlaybackMethod -import hu.bbara.purefin.core.data.PlaybackProfileFamily import hu.bbara.purefin.core.data.PlaybackReportContext -import hu.bbara.purefin.core.data.SessionBootstrapper import hu.bbara.purefin.core.data.session.UserSessionRepository -import hu.bbara.purefin.core.image.ImageUrlBuilder -import hu.bbara.purefin.core.model.Episode -import hu.bbara.purefin.core.model.Movie -import hu.bbara.purefin.core.model.Season -import hu.bbara.purefin.core.model.Series import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext @@ -34,27 +23,27 @@ import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.api.operations.SystemApi import org.jellyfin.sdk.createJellyfin import org.jellyfin.sdk.model.ClientInfo -import org.jellyfin.sdk.model.ServerVersion +import org.jellyfin.sdk.model.api.AuthenticationResult import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlaybackInfoDto +import org.jellyfin.sdk.model.api.PlaybackInfoResponse import org.jellyfin.sdk.model.api.PlaybackOrder import org.jellyfin.sdk.model.api.PlaybackProgressInfo import org.jellyfin.sdk.model.api.PlaybackStartInfo import org.jellyfin.sdk.model.api.PlaybackStopInfo -import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.RepeatMode import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @@ -62,15 +51,22 @@ import javax.inject.Singleton class JellyfinApiClient @Inject constructor( @ApplicationContext private val applicationContext: Context, private val userSessionRepository: UserSessionRepository, - private val playbackProfilePolicy: PlaybackProfilePolicy, -) : SessionBootstrapper, AuthenticationRepository, DownloadMediaSourceResolver { +) { private val jellyfin = createJellyfin { context = applicationContext clientInfo = ClientInfo(name = "Purefin", version = "0.0.1") } private val api = jellyfin.createApi() - private val serverVersionCache = ConcurrentHashMap() + + private val itemFields = + listOf( + ItemFields.CHILD_COUNT, + ItemFields.PARENT_ID, + ItemFields.DATE_LAST_REFRESHED, + ItemFields.OVERVIEW, + ItemFields.SEASON_USER_DATA, + ) private suspend fun getUserId(): UUID? = userSessionRepository.userId.first() @@ -85,37 +81,24 @@ class JellyfinApiClient @Inject constructor( return true } - override suspend fun login(url: String, username: String, password: String): Boolean = withContext(Dispatchers.IO) { + suspend fun configureFromSession(): Boolean = withContext(Dispatchers.IO) { + ensureConfigured() + } + + suspend fun authenticate( + url: String, + username: String, + password: String, + ): AuthenticationResult? = withContext(Dispatchers.IO) { val trimmedUrl = url.trim() if (trimmedUrl.isBlank()) { - return@withContext false + return@withContext null } + api.update(baseUrl = trimmedUrl) - try { - val response = api.userApi.authenticateUserByName(username = username, password = password) - val authResult = response.content - - 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) - api.update(accessToken = token) - true - } catch (e: Exception) { - Log.e("JellyfinApiClient", "Login failed", e) - false - } + api.userApi.authenticateUserByName(username = username, password = password).content } - override suspend fun initialize() { - withContext(Dispatchers.IO) { - ensureConfigured() - } - } - - suspend fun updateApiClient() = initialize() - suspend fun getLibraries(): List = withContext(Dispatchers.IO) { if (!ensureConfigured()) { return@withContext emptyList() @@ -129,15 +112,6 @@ class JellyfinApiClient @Inject constructor( response.content.items } - private val itemFields = - listOf( - ItemFields.CHILD_COUNT, - ItemFields.PARENT_ID, - ItemFields.DATE_LAST_REFRESHED, - ItemFields.OVERVIEW, - ItemFields.SEASON_USER_DATA, - ) - suspend fun getLibraryContent(libraryId: UUID): List = withContext(Dispatchers.IO) { if (!ensureConfigured()) { return@withContext emptyList() @@ -160,16 +134,13 @@ class JellyfinApiClient @Inject constructor( if (!ensureConfigured()) { return@withContext emptyList() } - val userId = getUserId() - if (userId == null) { - return@withContext emptyList() - } + val userId = getUserId() ?: return@withContext emptyList() val response = api.suggestionsApi.getSuggestions( userId = userId, mediaType = listOf(MediaType.VIDEO), type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), limit = 8, - enableTotalRecordCount = true + enableTotalRecordCount = true, ) Log.d("getSuggestions", response.content.toString()) response.content.items @@ -179,10 +150,7 @@ class JellyfinApiClient @Inject constructor( if (!ensureConfigured()) { return@withContext emptyList() } - val userId = getUserId() - if (userId == null) { - return@withContext emptyList() - } + val userId = getUserId() ?: return@withContext emptyList() val getResumeItemsRequest = GetResumeItemsRequest( userId = userId, fields = itemFields, @@ -209,12 +177,6 @@ class JellyfinApiClient @Inject constructor( result.content.items } - /** - * Fetches the latest media items from a specified library including Movie, Episode, Season. - * - * @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 = withContext(Dispatchers.IO) { if (!ensureConfigured()) { return@withContext emptyList() @@ -224,7 +186,7 @@ class JellyfinApiClient @Inject constructor( parentId = libraryId, fields = itemFields, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON), - limit = 10 + limit = 10, ) Log.d("getLatestFromLibrary", response.content.toString()) response.content @@ -234,10 +196,7 @@ class JellyfinApiClient @Inject constructor( if (!ensureConfigured()) { return@withContext null } - val result = api.userLibraryApi.getItem( - itemId = mediaId, - userId = getUserId() - ) + val result = api.userLibraryApi.getItem(itemId = mediaId, userId = getUserId()) Log.d("getItemInfo", result.content.toString()) result.content } @@ -250,7 +209,7 @@ class JellyfinApiClient @Inject constructor( userId = getUserId(), seriesId = seriesId, fields = itemFields, - enableUserData = true + enableUserData = true, ) Log.d("getSeasons", result.content.toString()) result.content.items @@ -265,7 +224,7 @@ class JellyfinApiClient @Inject constructor( seriesId = seriesId, seasonId = seasonId, fields = itemFields, - enableUserData = true + enableUserData = true, ) Log.d("getEpisodesInSeason", result.content.toString()) result.content.items @@ -275,7 +234,6 @@ class JellyfinApiClient @Inject constructor( if (!ensureConfigured()) { return@withContext emptyList() } - // TODO pass complete Episode object not only an id val episodeInfo = getItemInfo(episodeId) ?: return@withContext emptyList() val seriesId = episodeInfo.seriesId ?: return@withContext emptyList() val nextUpEpisodesResult = api.tvShowsApi.getEpisodes( @@ -283,9 +241,8 @@ class JellyfinApiClient @Inject constructor( seriesId = seriesId, enableUserData = true, startItemId = episodeId, - limit = count + 1 + limit = count + 1, ) - //Remove first element as we need only the next episodes val nextUpEpisodes = nextUpEpisodesResult.content.items.drop(1) Log.d("getNextEpisodes", nextUpEpisodes.toString()) nextUpEpisodes @@ -295,29 +252,26 @@ class JellyfinApiClient @Inject constructor( if (!ensureConfigured()) { return@withContext emptyList() } - val result = api.mediaInfoApi - .getPostedPlaybackInfo( - mediaId, - PlaybackInfoDto( - userId = getUserId(), - deviceProfile = null, - maxStreamingBitrate = 100_000_000, - ), - ) + val result = api.mediaInfoApi.getPostedPlaybackInfo( + mediaId, + PlaybackInfoDto( + userId = getUserId(), + deviceProfile = null, + maxStreamingBitrate = 100_000_000, + ), + ) Log.d("getMediaSources", result.toString()) result.content.mediaSources } - suspend fun getPlaybackDecision(mediaId: UUID): PlaybackDecision? = withContext(Dispatchers.IO) { + suspend fun getPlaybackInfo( + mediaId: UUID, + deviceProfile: DeviceProfile, + ): PlaybackInfoResponse? = withContext(Dispatchers.IO) { if (!ensureConfigured()) { return@withContext null } - - val serverUrl = userSessionRepository.serverUrl.first().trim() - val serverVersion = getServerVersion(serverUrl) - val deviceProfile = playbackProfilePolicy.create(serverVersion) - - val response = api.mediaInfoApi.getPostedPlaybackInfo( + api.mediaInfoApi.getPostedPlaybackInfo( mediaId, PlaybackInfoDto( userId = getUserId(), @@ -329,118 +283,31 @@ class JellyfinApiClient @Inject constructor( allowAudioStreamCopy = true, autoOpenLiveStream = false, ), - ) - - val playbackInfo = response.content - if (playbackInfo.errorCode != null) { - Log.w(TAG, "Playback info failed for $mediaId with ${playbackInfo.errorCode}") - return@withContext null - } - - val decision = PlaybackDecisionResolver.resolve( - mediaSources = playbackInfo.mediaSources, - playSessionId = playbackInfo.playSessionId, - serverUrl = serverUrl, - directPlayUrl = { mediaSource -> - api.videosApi.getVideoStreamUrl( - itemId = mediaId, - container = mediaSource.container, - mediaSourceId = mediaSource.id, - static = true, - tag = mediaSource.eTag, - playSessionId = playbackInfo.playSessionId, - liveStreamId = mediaSource.liveStreamId, - ) - }, - ) - - if (decision == null) { - Log.w(TAG, "No compatible playback path for $mediaId") - } else { - Log.d(TAG, "Playback decision for $mediaId resolved as ${decision.reportContext.playMethod}") - } - decision + ).content } - suspend fun getMediaPlaybackUrl(mediaId: UUID, mediaSource: MediaSourceInfo): String? = withContext(Dispatchers.IO) { + fun getVideoStreamUrl( + itemId: UUID, + mediaSourceId: String?, + container: String? = null, + tag: String? = null, + playSessionId: String? = null, + liveStreamId: String? = null, + ): String = api.videosApi.getVideoStreamUrl( + itemId = itemId, + container = container, + mediaSourceId = mediaSourceId, + static = true, + tag = tag, + playSessionId = playSessionId, + liveStreamId = liveStreamId, + ) + + suspend fun getPublicSystemInfoVersion(): String? = withContext(Dispatchers.IO) { if (!ensureConfigured()) { return@withContext null } - - val shouldTranscode = mediaSource.supportsTranscoding == true && - (mediaSource.supportsDirectPlay == false || mediaSource.transcodingUrl != null) - - val url = if (shouldTranscode && !mediaSource.transcodingUrl.isNullOrBlank()) { - val baseUrl = userSessionRepository.serverUrl.first().trim().trimEnd('/') - "$baseUrl${mediaSource.transcodingUrl}" - } else { - api.videosApi.getVideoStreamUrl( - itemId = mediaId, - static = true, - mediaSourceId = mediaSource.id, - ) - } - - Log.d("getMediaPlaybackUrl", "Direct play: ${!shouldTranscode}, URL: $url") - url - } - - override suspend fun resolveMovieDownload(movieId: UUID): MovieDownloadSource? = withContext(Dispatchers.IO) { - val serverUrl = userSessionRepository.serverUrl.first().trim() - val source = getMediaSources(movieId).firstOrNull() ?: return@withContext null - val playbackUrl = getMediaPlaybackUrl(movieId, source) ?: return@withContext null - val itemInfo = getItemInfo(movieId) ?: return@withContext null - val movie = itemInfo.toMovie(serverUrl) - - MovieDownloadSource( - movie = movie, - playbackUrl = playbackUrl, - customCacheKey = source.customCacheKey(movieId, playbackUrl), - ) - } - - override suspend fun resolveEpisodeDownload(episodeId: UUID): EpisodeDownloadSource? = withContext(Dispatchers.IO) { - val serverUrl = userSessionRepository.serverUrl.first().trim() - val source = getMediaSources(episodeId).firstOrNull() ?: return@withContext null - val playbackUrl = getMediaPlaybackUrl(episodeId, source) ?: return@withContext null - val episodeDto = getItemInfo(episodeId) ?: return@withContext null - val episode = episodeDto.toEpisode(serverUrl) - val series = getItemInfo(episode.seriesId)?.toSeries(serverUrl) ?: return@withContext null - val season = getItemInfo(episode.seasonId)?.toSeason(series.id) ?: return@withContext null - - EpisodeDownloadSource( - episode = episode, - series = series, - season = season, - playbackUrl = playbackUrl, - customCacheKey = source.customCacheKey(episodeId, playbackUrl), - ) - } - - override suspend fun isEpisodeWatched(episodeId: UUID): Boolean { - return getItemInfo(episodeId)?.userData?.played == true - } - - override suspend fun getUnwatchedEpisodeIds( - seriesId: UUID, - excludedEpisodeIds: Set, - limit: Int, - ): List = withContext(Dispatchers.IO) { - if (limit <= 0) { - return@withContext emptyList() - } - val seasons = getSeasons(seriesId) - val episodes = buildList { - seasons.forEach { season -> - addAll(getEpisodesInSeason(seriesId, season.id)) - } - } - episodes - .filter { episode -> - episode.userData?.played != true && episode.id !in excludedEpisodeIds - } - .take(limit) - .map { it.id } + SystemApi(api).getPublicSystemInfo().content.version } suspend fun reportPlaybackStart( @@ -464,7 +331,7 @@ class JellyfinApiClient @Inject constructor( playMethod = reportContext.playMethod.toJellyfinPlayMethod(), repeatMode = RepeatMode.REPEAT_NONE, playbackOrder = PlaybackOrder.DEFAULT, - ) + ), ) } @@ -490,7 +357,7 @@ class JellyfinApiClient @Inject constructor( playMethod = reportContext.playMethod.toJellyfinPlayMethod(), repeatMode = RepeatMode.REPEAT_NONE, playbackOrder = PlaybackOrder.DEFAULT, - ) + ), ) } @@ -508,118 +375,13 @@ class JellyfinApiClient @Inject constructor( liveStreamId = reportContext.liveStreamId, playSessionId = reportContext.playSessionId, failed = false, - ) + ), ) } - private suspend fun getServerVersion(serverUrl: String): ServerVersion { - serverVersionCache[serverUrl]?.let { return it } - - val parsedVersion = runCatching { - val versionString = SystemApi(api).getPublicSystemInfo().content.version - versionString?.let(ServerVersion::fromString) - }.onFailure { error -> - Log.w(TAG, "Unable to fetch server version for $serverUrl", error) - }.getOrNull() - - val resolvedVersion = parsedVersion ?: PlaybackProfileDefaults.fallbackServerVersion - serverVersionCache[serverUrl] = resolvedVersion - return resolvedVersion - } - - private fun BaseItemDto.toMovie(serverUrl: String): Movie { - return Movie( - id = id, - libraryId = parentId ?: UUID.randomUUID(), - title = name ?: "Unknown title", - progress = userData?.playedPercentage, - watched = userData?.played ?: false, - year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(), - rating = officialRating ?: "NR", - runtime = formatRuntime(runTimeTicks), - format = container?.uppercase() ?: "VIDEO", - synopsis = overview ?: "No synopsis available", - imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(serverUrl, id), - audioTrack = "ENG", - subtitles = "ENG", - cast = emptyList(), - ) - } - - private fun BaseItemDto.toEpisode(serverUrl: String): Episode { - return Episode( - id = id, - seriesId = seriesId ?: UUID.randomUUID(), - seasonId = parentId ?: UUID.randomUUID(), - title = name ?: "Unknown title", - index = indexNumber ?: 0, - synopsis = overview ?: "No synopsis available.", - releaseDate = productionYear?.toString() ?: "—", - rating = officialRating ?: "NR", - runtime = formatRuntime(runTimeTicks), - progress = userData?.playedPercentage, - watched = userData?.played ?: false, - format = container?.uppercase() ?: "VIDEO", - imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(serverUrl, id), - cast = emptyList(), - ) - } - - private fun BaseItemDto.toSeries(serverUrl: String): Series { - return Series( - id = id, - libraryId = parentId ?: UUID.randomUUID(), - name = name ?: "Unknown", - synopsis = overview ?: "No synopsis available", - year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(), - imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(serverUrl, id), - unwatchedEpisodeCount = userData?.unplayedItemCount ?: 0, - seasonCount = childCount ?: 0, - seasons = emptyList(), - cast = emptyList(), - ) - } - - private fun BaseItemDto.toSeason(seriesId: UUID): Season { - return Season( - id = id, - seriesId = this.seriesId ?: seriesId, - name = name ?: "Unknown", - index = indexNumber ?: 0, - unwatchedEpisodeCount = userData?.unplayedItemCount ?: 0, - episodeCount = childCount ?: 0, - episodes = emptyList(), - ) - } - - private fun MediaSourceInfo.customCacheKey(mediaId: UUID, playbackUrl: String): String? { - val shouldTranscode = supportsTranscoding == true && - (supportsDirectPlay == false || transcodingUrl != null) - if (shouldTranscode) { - return null - } - return playbackCustomCacheKey( - mediaId = mediaId.toString(), - playbackUrl = playbackUrl, - playMethod = PlaybackMethod.DIRECT_PLAY, - ) - } - - private fun formatRuntime(ticks: Long?): String { - if (ticks == null || ticks <= 0) return "—" - val totalSeconds = ticks / 10_000_000 - val hours = TimeUnit.SECONDS.toHours(totalSeconds) - val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60 - return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m" - } - private fun PlaybackMethod.toJellyfinPlayMethod(): PlayMethod = when (this) { PlaybackMethod.DIRECT_PLAY -> PlayMethod.DIRECT_PLAY PlaybackMethod.DIRECT_STREAM -> PlayMethod.DIRECT_STREAM PlaybackMethod.TRANSCODE -> PlayMethod.TRANSCODE } - - private companion object { - private const val TAG = "JellyfinApiClient" - } } diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMappers.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMappers.kt new file mode 100644 index 00000000..c3a532c0 --- /dev/null +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMappers.kt @@ -0,0 +1,83 @@ +package hu.bbara.purefin.data.jellyfin.download + +import hu.bbara.purefin.core.image.ImageUrlBuilder +import hu.bbara.purefin.core.model.Episode +import hu.bbara.purefin.core.model.Movie +import hu.bbara.purefin.core.model.Season +import hu.bbara.purefin.core.model.Series +import java.util.UUID +import java.util.concurrent.TimeUnit +import org.jellyfin.sdk.model.api.BaseItemDto + +internal fun BaseItemDto.toMovie(serverUrl: String): Movie { + return Movie( + id = id, + libraryId = parentId ?: UUID.randomUUID(), + title = name ?: "Unknown title", + progress = userData?.playedPercentage, + watched = userData?.played ?: false, + year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(), + rating = officialRating ?: "NR", + runtime = formatRuntime(runTimeTicks), + format = container?.uppercase() ?: "VIDEO", + synopsis = overview ?: "No synopsis available", + imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(serverUrl, id), + audioTrack = "ENG", + subtitles = "ENG", + cast = emptyList(), + ) +} + +internal fun BaseItemDto.toEpisode(serverUrl: String): Episode { + return Episode( + id = id, + seriesId = seriesId ?: UUID.randomUUID(), + seasonId = parentId ?: UUID.randomUUID(), + title = name ?: "Unknown title", + index = indexNumber ?: 0, + synopsis = overview ?: "No synopsis available.", + releaseDate = productionYear?.toString() ?: "—", + rating = officialRating ?: "NR", + runtime = formatRuntime(runTimeTicks), + progress = userData?.playedPercentage, + watched = userData?.played ?: false, + format = container?.uppercase() ?: "VIDEO", + imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(serverUrl, id), + cast = emptyList(), + ) +} + +internal fun BaseItemDto.toSeries(serverUrl: String): Series { + return Series( + id = id, + libraryId = parentId ?: UUID.randomUUID(), + name = name ?: "Unknown", + synopsis = overview ?: "No synopsis available", + year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(), + imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(serverUrl, id), + unwatchedEpisodeCount = userData?.unplayedItemCount ?: 0, + seasonCount = childCount ?: 0, + seasons = emptyList(), + cast = emptyList(), + ) +} + +internal fun BaseItemDto.toSeason(seriesId: UUID): Season { + return Season( + id = id, + seriesId = this.seriesId ?: seriesId, + name = name ?: "Unknown", + index = indexNumber ?: 0, + unwatchedEpisodeCount = userData?.unplayedItemCount ?: 0, + episodeCount = childCount ?: 0, + episodes = emptyList(), + ) +} + +private fun formatRuntime(ticks: Long?): String { + if (ticks == null || ticks <= 0) return "—" + val totalSeconds = ticks / 10_000_000 + val hours = TimeUnit.SECONDS.toHours(totalSeconds) + val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60 + return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m" +} diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMediaSourceResolver.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMediaSourceResolver.kt new file mode 100644 index 00000000..bad7fb97 --- /dev/null +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMediaSourceResolver.kt @@ -0,0 +1,124 @@ +package hu.bbara.purefin.data.jellyfin.download + +import hu.bbara.purefin.core.data.DownloadMediaSourceResolver +import hu.bbara.purefin.core.data.EpisodeDownloadSource +import hu.bbara.purefin.core.data.MovieDownloadSource +import hu.bbara.purefin.core.data.PlaybackMethod +import hu.bbara.purefin.core.data.session.UserSessionRepository +import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient +import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecisionResolver +import hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.model.api.MediaSourceInfo + +@Singleton +class JellyfinDownloadMediaSourceResolver @Inject constructor( + private val jellyfinApiClient: JellyfinApiClient, + private val userSessionRepository: UserSessionRepository, +) : DownloadMediaSourceResolver { + override suspend fun resolveMovieDownload(movieId: UUID): MovieDownloadSource? = withContext(Dispatchers.IO) { + val serverUrl = userSessionRepository.serverUrl.first().trim() + if (serverUrl.isBlank()) { + return@withContext null + } + + val mediaSource = jellyfinApiClient.getMediaSources(movieId).firstOrNull() ?: return@withContext null + val playbackUrl = resolvePlaybackUrl(movieId, mediaSource, serverUrl) ?: return@withContext null + val itemInfo = jellyfinApiClient.getItemInfo(movieId) ?: return@withContext null + + MovieDownloadSource( + movie = itemInfo.toMovie(serverUrl), + playbackUrl = playbackUrl, + customCacheKey = mediaSource.downloadCustomCacheKey(movieId, playbackUrl), + ) + } + + override suspend fun resolveEpisodeDownload(episodeId: UUID): EpisodeDownloadSource? = withContext(Dispatchers.IO) { + val serverUrl = userSessionRepository.serverUrl.first().trim() + if (serverUrl.isBlank()) { + return@withContext null + } + + val mediaSource = jellyfinApiClient.getMediaSources(episodeId).firstOrNull() ?: return@withContext null + val playbackUrl = resolvePlaybackUrl(episodeId, mediaSource, serverUrl) ?: return@withContext null + val episodeDto = jellyfinApiClient.getItemInfo(episodeId) ?: return@withContext null + val episode = episodeDto.toEpisode(serverUrl) + val series = jellyfinApiClient.getItemInfo(episode.seriesId)?.toSeries(serverUrl) ?: return@withContext null + val season = jellyfinApiClient.getItemInfo(episode.seasonId)?.toSeason(series.id) ?: return@withContext null + + EpisodeDownloadSource( + episode = episode, + series = series, + season = season, + playbackUrl = playbackUrl, + customCacheKey = mediaSource.downloadCustomCacheKey(episodeId, playbackUrl), + ) + } + + override suspend fun isEpisodeWatched(episodeId: UUID): Boolean { + return jellyfinApiClient.getItemInfo(episodeId)?.userData?.played == true + } + + override suspend fun getUnwatchedEpisodeIds( + seriesId: UUID, + excludedEpisodeIds: Set, + limit: Int, + ): List = withContext(Dispatchers.IO) { + if (limit <= 0) { + return@withContext emptyList() + } + + val seasons = jellyfinApiClient.getSeasons(seriesId) + val episodes = buildList { + seasons.forEach { season -> + addAll(jellyfinApiClient.getEpisodesInSeason(seriesId, season.id)) + } + } + + episodes + .filter { episode -> + episode.userData?.played != true && episode.id !in excludedEpisodeIds + } + .take(limit) + .map { it.id } + } + + private fun resolvePlaybackUrl( + mediaId: UUID, + mediaSource: MediaSourceInfo, + serverUrl: String, + ): String? { + val shouldTranscode = mediaSource.supportsTranscoding == true && + (mediaSource.supportsDirectPlay == false || mediaSource.transcodingUrl != null) + + return if (shouldTranscode && !mediaSource.transcodingUrl.isNullOrBlank()) { + PlaybackDecisionResolver.absolutePlaybackUrl(serverUrl, requireNotNull(mediaSource.transcodingUrl)) + } else { + jellyfinApiClient.getVideoStreamUrl( + itemId = mediaId, + mediaSourceId = mediaSource.id, + ) + } + } + + private fun MediaSourceInfo.downloadCustomCacheKey( + mediaId: UUID, + playbackUrl: String, + ): String? { + val shouldTranscode = supportsTranscoding == true && + (supportsDirectPlay == false || transcodingUrl != null) + if (shouldTranscode) { + return null + } + return playbackCustomCacheKey( + mediaId = mediaId.toString(), + playbackUrl = playbackUrl, + playMethod = PlaybackMethod.DIRECT_PLAY, + ) + } +} diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/AndroidCodecCatalog.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/AndroidCodecCatalog.kt similarity index 99% rename from data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/AndroidCodecCatalog.kt rename to data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/AndroidCodecCatalog.kt index 5d49d2fb..6675ca3c 100644 --- a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/AndroidCodecCatalog.kt +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/AndroidCodecCatalog.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.data.jellyfin.client +package hu.bbara.purefin.data.jellyfin.playback import android.media.MediaCodecInfo import android.media.MediaCodecInfo.CodecProfileLevel diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/AndroidDeviceProfileCapabilities.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/AndroidDeviceProfileCapabilities.kt similarity index 99% rename from data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/AndroidDeviceProfileCapabilities.kt rename to data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/AndroidDeviceProfileCapabilities.kt index f9d14ff7..0d07a9b7 100644 --- a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/AndroidDeviceProfileCapabilities.kt +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/AndroidDeviceProfileCapabilities.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.data.jellyfin.client +package hu.bbara.purefin.data.jellyfin.playback import android.media.MediaCodecInfo.CodecProfileLevel import android.media.MediaCodecList diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/CodecDebugHelper.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/CodecDebugHelper.kt similarity index 97% rename from data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/CodecDebugHelper.kt rename to data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/CodecDebugHelper.kt index 959b665a..9d636316 100644 --- a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/CodecDebugHelper.kt +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/CodecDebugHelper.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.data.jellyfin.client +package hu.bbara.purefin.data.jellyfin.playback import android.media.MediaCodecList import android.util.Log diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinAndroidMobileDeviceProfile.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/JellyfinAndroidMobileDeviceProfile.kt similarity index 99% rename from data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinAndroidMobileDeviceProfile.kt rename to data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/JellyfinAndroidMobileDeviceProfile.kt index 8ff3483a..62f1324c 100644 --- a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinAndroidMobileDeviceProfile.kt +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/JellyfinAndroidMobileDeviceProfile.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.data.jellyfin.client +package hu.bbara.purefin.data.jellyfin.playback import org.jellyfin.sdk.model.api.CodecProfile import org.jellyfin.sdk.model.api.CodecType diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinAndroidTvDeviceProfile.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/JellyfinAndroidTvDeviceProfile.kt similarity index 99% rename from data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinAndroidTvDeviceProfile.kt rename to data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/JellyfinAndroidTvDeviceProfile.kt index 4c17c4f7..c71f2b45 100644 --- a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinAndroidTvDeviceProfile.kt +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/JellyfinAndroidTvDeviceProfile.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.data.jellyfin.client +package hu.bbara.purefin.data.jellyfin.playback import androidx.media3.common.MimeTypes import org.jellyfin.sdk.model.ServerVersion diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/JellyfinPlaybackResolver.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/JellyfinPlaybackResolver.kt new file mode 100644 index 00000000..a17986fa --- /dev/null +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/JellyfinPlaybackResolver.kt @@ -0,0 +1,80 @@ +package hu.bbara.purefin.data.jellyfin.playback + +import android.util.Log +import hu.bbara.purefin.core.data.session.UserSessionRepository +import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.model.ServerVersion + +@Singleton +class JellyfinPlaybackResolver @Inject constructor( + private val jellyfinApiClient: JellyfinApiClient, + private val userSessionRepository: UserSessionRepository, + private val playbackProfilePolicy: PlaybackProfilePolicy, +) { + private val serverVersionCache = ConcurrentHashMap() + + suspend fun getPlaybackDecision(mediaId: UUID): PlaybackDecision? = withContext(Dispatchers.IO) { + val serverUrl = userSessionRepository.serverUrl.first().trim() + if (serverUrl.isBlank()) { + return@withContext null + } + + val serverVersion = getServerVersion(serverUrl) + val playbackInfo = jellyfinApiClient.getPlaybackInfo( + mediaId = mediaId, + deviceProfile = playbackProfilePolicy.create(serverVersion), + ) ?: return@withContext null + + if (playbackInfo.errorCode != null) { + Log.w(TAG, "Playback info failed for $mediaId with ${playbackInfo.errorCode}") + return@withContext null + } + + val decision = PlaybackDecisionResolver.resolve( + mediaSources = playbackInfo.mediaSources, + playSessionId = playbackInfo.playSessionId, + serverUrl = serverUrl, + directPlayUrl = { mediaSource -> + jellyfinApiClient.getVideoStreamUrl( + itemId = mediaId, + container = mediaSource.container, + mediaSourceId = mediaSource.id, + tag = mediaSource.eTag, + playSessionId = playbackInfo.playSessionId, + liveStreamId = mediaSource.liveStreamId, + ) + }, + ) + + if (decision == null) { + Log.w(TAG, "No compatible playback path for $mediaId") + } else { + Log.d(TAG, "Playback decision for $mediaId resolved as ${decision.reportContext.playMethod}") + } + decision + } + + private suspend fun getServerVersion(serverUrl: String): ServerVersion { + serverVersionCache[serverUrl]?.let { return it } + + val resolvedVersion = runCatching { + jellyfinApiClient.getPublicSystemInfoVersion()?.let(ServerVersion::fromString) + }.onFailure { error -> + Log.w(TAG, "Unable to fetch server version for $serverUrl", error) + }.getOrNull() ?: PlaybackProfileDefaults.fallbackServerVersion + + serverVersionCache[serverUrl] = resolvedVersion + return resolvedVersion + } + + private companion object { + private const val TAG = "PlaybackResolver" + } +} diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/PlaybackCacheKeys.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackCacheKeys.kt similarity index 92% rename from data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/PlaybackCacheKeys.kt rename to data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackCacheKeys.kt index d9bd12c2..c401f115 100644 --- a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/PlaybackCacheKeys.kt +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackCacheKeys.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.data.jellyfin.client +package hu.bbara.purefin.data.jellyfin.playback import hu.bbara.purefin.core.data.PlaybackMethod diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/PlaybackDecision.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackDecision.kt similarity index 83% rename from data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/PlaybackDecision.kt rename to data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackDecision.kt index 3708f95a..a90177c1 100644 --- a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/PlaybackDecision.kt +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackDecision.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.data.jellyfin.client +package hu.bbara.purefin.data.jellyfin.playback import hu.bbara.purefin.core.data.PlaybackReportContext import org.jellyfin.sdk.model.api.MediaSourceInfo diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/PlaybackDecisionResolver.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackDecisionResolver.kt similarity index 97% rename from data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/PlaybackDecisionResolver.kt rename to data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackDecisionResolver.kt index d7786d8e..0b21ed70 100644 --- a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/PlaybackDecisionResolver.kt +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackDecisionResolver.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.data.jellyfin.client +package hu.bbara.purefin.data.jellyfin.playback import hu.bbara.purefin.core.data.PlaybackMethod import hu.bbara.purefin.core.data.PlaybackReportContext diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/PlaybackProfileModule.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackProfileModule.kt similarity index 94% rename from data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/PlaybackProfileModule.kt rename to data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackProfileModule.kt index 45293f6a..330fb6de 100644 --- a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/PlaybackProfileModule.kt +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackProfileModule.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.data.jellyfin.client +package hu.bbara.purefin.data.jellyfin.playback import dagger.Module import dagger.Provides diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/PlaybackProfilePolicy.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackProfilePolicy.kt similarity index 89% rename from data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/PlaybackProfilePolicy.kt rename to data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackProfilePolicy.kt index b72fc755..77409064 100644 --- a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/client/PlaybackProfilePolicy.kt +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackProfilePolicy.kt @@ -1,6 +1,5 @@ -package hu.bbara.purefin.data.jellyfin.client +package hu.bbara.purefin.data.jellyfin.playback -import hu.bbara.purefin.core.data.PlaybackProfileFamily import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.api.DeviceProfile diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/session/JellyfinAuthenticationRepository.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/session/JellyfinAuthenticationRepository.kt new file mode 100644 index 00000000..48002217 --- /dev/null +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/session/JellyfinAuthenticationRepository.kt @@ -0,0 +1,35 @@ +package hu.bbara.purefin.data.jellyfin.session + +import android.util.Log +import hu.bbara.purefin.core.data.AuthenticationRepository +import hu.bbara.purefin.core.data.session.UserSessionRepository +import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class JellyfinAuthenticationRepository @Inject constructor( + private val jellyfinApiClient: JellyfinApiClient, + private val userSessionRepository: UserSessionRepository, +) : AuthenticationRepository { + override suspend fun login(url: String, username: String, password: String): Boolean { + return try { + val authResult = jellyfinApiClient.authenticate(url = url, username = username, password = password) + ?: return false + val token = authResult.accessToken ?: return false + val userId = authResult.user?.id ?: return false + + userSessionRepository.setAccessToken(accessToken = token) + userSessionRepository.setUserId(userId) + userSessionRepository.setLoggedIn(true) + true + } catch (e: Exception) { + Log.e(TAG, "Login failed", e) + false + } + } + + private companion object { + private const val TAG = "JellyfinAuthRepo" + } +} diff --git a/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/session/JellyfinSessionBootstrapper.kt b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/session/JellyfinSessionBootstrapper.kt new file mode 100644 index 00000000..f3f36181 --- /dev/null +++ b/data/jellyfin/src/main/java/hu/bbara/purefin/data/jellyfin/session/JellyfinSessionBootstrapper.kt @@ -0,0 +1,15 @@ +package hu.bbara.purefin.data.jellyfin.session + +import hu.bbara.purefin.core.data.SessionBootstrapper +import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class JellyfinSessionBootstrapper @Inject constructor( + private val jellyfinApiClient: JellyfinApiClient, +) : SessionBootstrapper { + override suspend fun initialize() { + jellyfinApiClient.configureFromSession() + } +} diff --git a/data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/client/AndroidDeviceProfileTest.kt b/data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/playback/AndroidDeviceProfileTest.kt similarity index 99% rename from data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/client/AndroidDeviceProfileTest.kt rename to data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/playback/AndroidDeviceProfileTest.kt index 96c92c5e..afab3560 100644 --- a/data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/client/AndroidDeviceProfileTest.kt +++ b/data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/playback/AndroidDeviceProfileTest.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.data.jellyfin.client +package hu.bbara.purefin.data.jellyfin.playback import androidx.media3.common.MimeTypes import org.jellyfin.sdk.model.ServerVersion diff --git a/data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/client/PlaybackCacheKeysTest.kt b/data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackCacheKeysTest.kt similarity index 96% rename from data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/client/PlaybackCacheKeysTest.kt rename to data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackCacheKeysTest.kt index 0cb43e81..e58b9953 100644 --- a/data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/client/PlaybackCacheKeysTest.kt +++ b/data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackCacheKeysTest.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.data.jellyfin.client +package hu.bbara.purefin.data.jellyfin.playback import hu.bbara.purefin.core.data.PlaybackMethod import org.junit.Assert.assertEquals diff --git a/data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/client/PlaybackDecisionResolverTest.kt b/data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackDecisionResolverTest.kt similarity index 99% rename from data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/client/PlaybackDecisionResolverTest.kt rename to data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackDecisionResolverTest.kt index b2dd60e9..c8da70ab 100644 --- a/data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/client/PlaybackDecisionResolverTest.kt +++ b/data/jellyfin/src/test/java/hu/bbara/purefin/data/jellyfin/playback/PlaybackDecisionResolverTest.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.data.jellyfin.client +package hu.bbara.purefin.data.jellyfin.playback import hu.bbara.purefin.core.data.PlaybackMethod import org.jellyfin.sdk.model.api.MediaProtocol