mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Refactor JellyfinApiClient into thin API wrapper
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,36 +81,23 @@ 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) {
|
||||||
val trimmedUrl = url.trim()
|
|
||||||
if (trimmedUrl.isBlank()) {
|
|
||||||
return@withContext false
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun initialize() {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
ensureConfigured()
|
ensureConfigured()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun authenticate(
|
||||||
|
url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
): AuthenticationResult? = withContext(Dispatchers.IO) {
|
||||||
|
val trimmedUrl = url.trim()
|
||||||
|
if (trimmedUrl.isBlank()) {
|
||||||
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateApiClient() = initialize()
|
api.update(baseUrl = trimmedUrl)
|
||||||
|
api.userApi.authenticateUserByName(username = username, password = password).content
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getLibraries(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
suspend fun getLibraries(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) {
|
if (!ensureConfigured()) {
|
||||||
@@ -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,8 +252,7 @@ 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(),
|
||||||
@@ -308,16 +264,14 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
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(
|
fun getVideoStreamUrl(
|
||||||
mediaSources = playbackInfo.mediaSources,
|
itemId: UUID,
|
||||||
playSessionId = playbackInfo.playSessionId,
|
mediaSourceId: String?,
|
||||||
serverUrl = serverUrl,
|
container: String? = null,
|
||||||
directPlayUrl = { mediaSource ->
|
tag: String? = null,
|
||||||
api.videosApi.getVideoStreamUrl(
|
playSessionId: String? = null,
|
||||||
itemId = mediaId,
|
liveStreamId: String? = null,
|
||||||
container = mediaSource.container,
|
): String = api.videosApi.getVideoStreamUrl(
|
||||||
mediaSourceId = mediaSource.id,
|
itemId = itemId,
|
||||||
|
container = container,
|
||||||
|
mediaSourceId = mediaSourceId,
|
||||||
static = true,
|
static = true,
|
||||||
tag = mediaSource.eTag,
|
tag = tag,
|
||||||
playSessionId = playbackInfo.playSessionId,
|
playSessionId = playSessionId,
|
||||||
liveStreamId = mediaSource.liveStreamId,
|
liveStreamId = liveStreamId,
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (decision == null) {
|
suspend fun getPublicSystemInfoVersion(): String? = withContext(Dispatchers.IO) {
|
||||||
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) {
|
|
||||||
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,109 +375,8 @@ 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) {
|
||||||
@@ -618,8 +384,4 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user