Refactor JellyfinApiClient into thin API wrapper

This commit is contained in:
2026-04-22 18:58:15 +02:00
parent 3ae1425eba
commit 188efde982
21 changed files with 433 additions and 331 deletions

View File

@@ -7,12 +7,13 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import hu.bbara.purefin.core.data.PlayableMediaRepository 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.core.data.PlaybackReportContext
import hu.bbara.purefin.data.jellyfin.client.playbackCustomCacheKey
import hu.bbara.purefin.core.data.session.UserSessionRepository import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.image.ImageUrlBuilder 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 java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -26,11 +27,12 @@ import org.jellyfin.sdk.model.api.MediaSourceInfo
@Singleton @Singleton
class DefaultPlayableMediaRepository @Inject constructor( class DefaultPlayableMediaRepository @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient, private val jellyfinApiClient: JellyfinApiClient,
private val jellyfinPlaybackResolver: JellyfinPlaybackResolver,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
) : PlayableMediaRepository { ) : PlayableMediaRepository {
override suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) { override suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? = 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 baseItem = jellyfinApiClient.getItemInfo(mediaId)
val resumePositionMs = calculateResumePosition(baseItem, playbackDecision.mediaSource) val resumePositionMs = calculateResumePosition(baseItem, playbackDecision.mediaSource)
@@ -64,7 +66,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
if (existingIds.contains(stringId)) { if (existingIds.contains(stringId)) {
return@mapNotNull null 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) val artworkUrl = ImageUrlBuilder.toImageUrl(serverUrl, id, ArtworkKind.PRIMARY)
createMediaItem( createMediaItem(
mediaId = stringId, mediaId = stringId,

View File

@@ -10,7 +10,9 @@ import hu.bbara.purefin.core.data.NetworkMonitor
import hu.bbara.purefin.core.data.PlayableMediaRepository import hu.bbara.purefin.core.data.PlayableMediaRepository
import hu.bbara.purefin.core.data.PlaybackProgressReporter import hu.bbara.purefin.core.data.PlaybackProgressReporter
import hu.bbara.purefin.core.data.SessionBootstrapper 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 import javax.inject.Singleton
@Module @Module
@@ -19,15 +21,15 @@ abstract class JellyfinBindingsModule {
@Binds @Binds
@Singleton @Singleton
abstract fun bindSessionBootstrapper(impl: JellyfinApiClient): SessionBootstrapper abstract fun bindSessionBootstrapper(impl: JellyfinSessionBootstrapper): SessionBootstrapper
@Binds @Binds
@Singleton @Singleton
abstract fun bindAuthenticationRepository(impl: JellyfinApiClient): AuthenticationRepository abstract fun bindAuthenticationRepository(impl: JellyfinAuthenticationRepository): AuthenticationRepository
@Binds @Binds
@Singleton @Singleton
abstract fun bindDownloadMediaSourceResolver(impl: JellyfinApiClient): DownloadMediaSourceResolver abstract fun bindDownloadMediaSourceResolver(impl: JellyfinDownloadMediaSourceResolver): DownloadMediaSourceResolver
@Binds @Binds
abstract fun bindPlayableMediaRepository(impl: DefaultPlayableMediaRepository): PlayableMediaRepository abstract fun bindPlayableMediaRepository(impl: DefaultPlayableMediaRepository): PlayableMediaRepository

View File

@@ -3,20 +3,9 @@ package hu.bbara.purefin.data.jellyfin.client
import android.content.Context 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.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.PlaybackMethod
import hu.bbara.purefin.core.data.PlaybackProfileFamily
import hu.bbara.purefin.core.data.PlaybackReportContext 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.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.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext 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.api.operations.SystemApi
import org.jellyfin.sdk.createJellyfin import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo 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.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType 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.ItemFields
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaType 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.PlaybackInfoDto
import org.jellyfin.sdk.model.api.PlaybackInfoResponse
import org.jellyfin.sdk.model.api.PlaybackOrder import org.jellyfin.sdk.model.api.PlaybackOrder
import org.jellyfin.sdk.model.api.PlaybackProgressInfo import org.jellyfin.sdk.model.api.PlaybackProgressInfo
import org.jellyfin.sdk.model.api.PlaybackStartInfo import org.jellyfin.sdk.model.api.PlaybackStartInfo
import org.jellyfin.sdk.model.api.PlaybackStopInfo 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.RepeatMode
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -62,15 +51,22 @@ import javax.inject.Singleton
class JellyfinApiClient @Inject constructor( class JellyfinApiClient @Inject constructor(
@ApplicationContext private val applicationContext: Context, @ApplicationContext private val applicationContext: Context,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val playbackProfilePolicy: PlaybackProfilePolicy, ) {
) : SessionBootstrapper, AuthenticationRepository, DownloadMediaSourceResolver {
private val jellyfin = createJellyfin { private val jellyfin = createJellyfin {
context = applicationContext context = applicationContext
clientInfo = ClientInfo(name = "Purefin", version = "0.0.1") clientInfo = ClientInfo(name = "Purefin", version = "0.0.1")
} }
private val api = jellyfin.createApi() private val api = jellyfin.createApi()
private val serverVersionCache = ConcurrentHashMap<String, ServerVersion>()
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() private suspend fun getUserId(): UUID? = userSessionRepository.userId.first()
@@ -85,37 +81,24 @@ class JellyfinApiClient @Inject constructor(
return true 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() val trimmedUrl = url.trim()
if (trimmedUrl.isBlank()) { if (trimmedUrl.isBlank()) {
return@withContext false return@withContext null
} }
api.update(baseUrl = trimmedUrl) api.update(baseUrl = trimmedUrl)
try { api.userApi.authenticateUserByName(username = username, password = password).content
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
}
} }
override suspend fun initialize() {
withContext(Dispatchers.IO) {
ensureConfigured()
}
}
suspend fun updateApiClient() = initialize()
suspend fun getLibraries(): List<BaseItemDto> = withContext(Dispatchers.IO) { suspend fun getLibraries(): List<BaseItemDto> = withContext(Dispatchers.IO) {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return@withContext emptyList() return@withContext emptyList()
@@ -129,15 +112,6 @@ class JellyfinApiClient @Inject constructor(
response.content.items 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<BaseItemDto> = withContext(Dispatchers.IO) { suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return@withContext emptyList() return@withContext emptyList()
@@ -160,16 +134,13 @@ class JellyfinApiClient @Inject constructor(
if (!ensureConfigured()) { if (!ensureConfigured()) {
return@withContext emptyList() return@withContext emptyList()
} }
val userId = getUserId() val userId = getUserId() ?: return@withContext emptyList()
if (userId == null) {
return@withContext emptyList()
}
val response = api.suggestionsApi.getSuggestions( val response = api.suggestionsApi.getSuggestions(
userId = userId, userId = userId,
mediaType = listOf(MediaType.VIDEO), mediaType = listOf(MediaType.VIDEO),
type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
limit = 8, limit = 8,
enableTotalRecordCount = true enableTotalRecordCount = true,
) )
Log.d("getSuggestions", response.content.toString()) Log.d("getSuggestions", response.content.toString())
response.content.items response.content.items
@@ -179,10 +150,7 @@ class JellyfinApiClient @Inject constructor(
if (!ensureConfigured()) { if (!ensureConfigured()) {
return@withContext emptyList() return@withContext emptyList()
} }
val userId = getUserId() val userId = getUserId() ?: return@withContext emptyList()
if (userId == null) {
return@withContext emptyList()
}
val getResumeItemsRequest = GetResumeItemsRequest( val getResumeItemsRequest = GetResumeItemsRequest(
userId = userId, userId = userId,
fields = itemFields, fields = itemFields,
@@ -209,12 +177,6 @@ class JellyfinApiClient @Inject constructor(
result.content.items 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<BaseItemDto> = withContext(Dispatchers.IO) { suspend fun getLatestFromLibrary(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return@withContext emptyList() return@withContext emptyList()
@@ -224,7 +186,7 @@ class JellyfinApiClient @Inject constructor(
parentId = libraryId, parentId = libraryId,
fields = itemFields, fields = itemFields,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON), includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
limit = 10 limit = 10,
) )
Log.d("getLatestFromLibrary", response.content.toString()) Log.d("getLatestFromLibrary", response.content.toString())
response.content response.content
@@ -234,10 +196,7 @@ class JellyfinApiClient @Inject constructor(
if (!ensureConfigured()) { if (!ensureConfigured()) {
return@withContext null return@withContext null
} }
val result = api.userLibraryApi.getItem( val result = api.userLibraryApi.getItem(itemId = mediaId, userId = getUserId())
itemId = mediaId,
userId = getUserId()
)
Log.d("getItemInfo", result.content.toString()) Log.d("getItemInfo", result.content.toString())
result.content result.content
} }
@@ -250,7 +209,7 @@ class JellyfinApiClient @Inject constructor(
userId = getUserId(), userId = getUserId(),
seriesId = seriesId, seriesId = seriesId,
fields = itemFields, fields = itemFields,
enableUserData = true enableUserData = true,
) )
Log.d("getSeasons", result.content.toString()) Log.d("getSeasons", result.content.toString())
result.content.items result.content.items
@@ -265,7 +224,7 @@ class JellyfinApiClient @Inject constructor(
seriesId = seriesId, seriesId = seriesId,
seasonId = seasonId, seasonId = seasonId,
fields = itemFields, fields = itemFields,
enableUserData = true enableUserData = true,
) )
Log.d("getEpisodesInSeason", result.content.toString()) Log.d("getEpisodesInSeason", result.content.toString())
result.content.items result.content.items
@@ -275,7 +234,6 @@ class JellyfinApiClient @Inject constructor(
if (!ensureConfigured()) { if (!ensureConfigured()) {
return@withContext emptyList() return@withContext emptyList()
} }
// TODO pass complete Episode object not only an id
val episodeInfo = getItemInfo(episodeId) ?: return@withContext emptyList() val episodeInfo = getItemInfo(episodeId) ?: return@withContext emptyList()
val seriesId = episodeInfo.seriesId ?: return@withContext emptyList() val seriesId = episodeInfo.seriesId ?: return@withContext emptyList()
val nextUpEpisodesResult = api.tvShowsApi.getEpisodes( val nextUpEpisodesResult = api.tvShowsApi.getEpisodes(
@@ -283,9 +241,8 @@ class JellyfinApiClient @Inject constructor(
seriesId = seriesId, seriesId = seriesId,
enableUserData = true, enableUserData = true,
startItemId = episodeId, 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) val nextUpEpisodes = nextUpEpisodesResult.content.items.drop(1)
Log.d("getNextEpisodes", nextUpEpisodes.toString()) Log.d("getNextEpisodes", nextUpEpisodes.toString())
nextUpEpisodes nextUpEpisodes
@@ -295,29 +252,26 @@ class JellyfinApiClient @Inject constructor(
if (!ensureConfigured()) { if (!ensureConfigured()) {
return@withContext emptyList() return@withContext emptyList()
} }
val result = api.mediaInfoApi val result = api.mediaInfoApi.getPostedPlaybackInfo(
.getPostedPlaybackInfo( mediaId,
mediaId, PlaybackInfoDto(
PlaybackInfoDto( userId = getUserId(),
userId = getUserId(), deviceProfile = null,
deviceProfile = null, maxStreamingBitrate = 100_000_000,
maxStreamingBitrate = 100_000_000, ),
), )
)
Log.d("getMediaSources", result.toString()) Log.d("getMediaSources", result.toString())
result.content.mediaSources 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()) { if (!ensureConfigured()) {
return@withContext null return@withContext null
} }
api.mediaInfoApi.getPostedPlaybackInfo(
val serverUrl = userSessionRepository.serverUrl.first().trim()
val serverVersion = getServerVersion(serverUrl)
val deviceProfile = playbackProfilePolicy.create(serverVersion)
val response = api.mediaInfoApi.getPostedPlaybackInfo(
mediaId, mediaId,
PlaybackInfoDto( PlaybackInfoDto(
userId = getUserId(), userId = getUserId(),
@@ -329,118 +283,31 @@ class JellyfinApiClient @Inject constructor(
allowAudioStreamCopy = true, allowAudioStreamCopy = true,
autoOpenLiveStream = false, autoOpenLiveStream = false,
), ),
) ).content
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
} }
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()) { if (!ensureConfigured()) {
return@withContext null return@withContext null
} }
SystemApi(api).getPublicSystemInfo().content.version
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<UUID>,
limit: Int,
): List<UUID> = 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 }
} }
suspend fun reportPlaybackStart( suspend fun reportPlaybackStart(
@@ -464,7 +331,7 @@ class JellyfinApiClient @Inject constructor(
playMethod = reportContext.playMethod.toJellyfinPlayMethod(), playMethod = reportContext.playMethod.toJellyfinPlayMethod(),
repeatMode = RepeatMode.REPEAT_NONE, repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT, playbackOrder = PlaybackOrder.DEFAULT,
) ),
) )
} }
@@ -490,7 +357,7 @@ class JellyfinApiClient @Inject constructor(
playMethod = reportContext.playMethod.toJellyfinPlayMethod(), playMethod = reportContext.playMethod.toJellyfinPlayMethod(),
repeatMode = RepeatMode.REPEAT_NONE, repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT, playbackOrder = PlaybackOrder.DEFAULT,
) ),
) )
} }
@@ -508,118 +375,13 @@ class JellyfinApiClient @Inject constructor(
liveStreamId = reportContext.liveStreamId, liveStreamId = reportContext.liveStreamId,
playSessionId = reportContext.playSessionId, playSessionId = reportContext.playSessionId,
failed = false, 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) { private fun PlaybackMethod.toJellyfinPlayMethod(): PlayMethod = when (this) {
PlaybackMethod.DIRECT_PLAY -> PlayMethod.DIRECT_PLAY PlaybackMethod.DIRECT_PLAY -> PlayMethod.DIRECT_PLAY
PlaybackMethod.DIRECT_STREAM -> PlayMethod.DIRECT_STREAM PlaybackMethod.DIRECT_STREAM -> PlayMethod.DIRECT_STREAM
PlaybackMethod.TRANSCODE -> PlayMethod.TRANSCODE PlaybackMethod.TRANSCODE -> PlayMethod.TRANSCODE
} }
private companion object {
private const val TAG = "JellyfinApiClient"
}
} }

View File

@@ -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"
}

View File

@@ -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<UUID>,
limit: Int,
): List<UUID> = 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,
)
}
}

View File

@@ -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
import android.media.MediaCodecInfo.CodecProfileLevel import android.media.MediaCodecInfo.CodecProfileLevel

View File

@@ -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.MediaCodecInfo.CodecProfileLevel
import android.media.MediaCodecList import android.media.MediaCodecList

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.data.jellyfin.client package hu.bbara.purefin.data.jellyfin.playback
import android.media.MediaCodecList import android.media.MediaCodecList
import android.util.Log import android.util.Log

View File

@@ -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.CodecProfile
import org.jellyfin.sdk.model.api.CodecType import org.jellyfin.sdk.model.api.CodecType

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.data.jellyfin.client package hu.bbara.purefin.data.jellyfin.playback
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.ServerVersion

View File

@@ -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<String, ServerVersion>()
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"
}
}

View File

@@ -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.PlaybackMethod

View File

@@ -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 hu.bbara.purefin.core.data.PlaybackReportContext
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo

View File

@@ -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.PlaybackMethod
import hu.bbara.purefin.core.data.PlaybackReportContext import hu.bbara.purefin.core.data.PlaybackReportContext

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.data.jellyfin.client package hu.bbara.purefin.data.jellyfin.playback
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides

View File

@@ -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.ServerVersion
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile

View File

@@ -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"
}
}

View File

@@ -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()
}
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.data.jellyfin.client package hu.bbara.purefin.data.jellyfin.playback
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.ServerVersion

View File

@@ -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.PlaybackMethod
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals

View File

@@ -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.PlaybackMethod
import org.jellyfin.sdk.model.api.MediaProtocol import org.jellyfin.sdk.model.api.MediaProtocol