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.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<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 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,

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

View File

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

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

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