mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
feat: introduce core data models and interfaces for playback and downloads
This commit is contained in:
@@ -3,9 +3,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -30,15 +27,7 @@ kotlin {
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
implementation(libs.jellyfin.core)
|
||||
implementation(libs.media3.common)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.hilt)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.datastore)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.logging.interceptor)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
interface AuthenticationRepository {
|
||||
suspend fun login(url: String, username: String, password: String): Boolean
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Singleton
|
||||
class CompositeMediaRepository @Inject constructor(
|
||||
private val offlineRepository: OfflineMediaRepository,
|
||||
private val onlineRepository: InMemoryMediaRepository,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
) : MediaCatalogReader, MediaProgressWriter {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private val activeRepository: Flow<MediaCatalogReader> =
|
||||
networkMonitor.isOnline.flatMapLatest { online ->
|
||||
flowOf(if (online) onlineRepository else offlineRepository)
|
||||
}
|
||||
|
||||
override val movies: StateFlow<Map<UUID, Movie>> = activeRepository
|
||||
.flatMapLatest { it.movies }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override val series: StateFlow<Map<UUID, Series>> = activeRepository
|
||||
.flatMapLatest { it.series }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override val episodes: StateFlow<Map<UUID, Episode>> = activeRepository
|
||||
.flatMapLatest { it.episodes }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
|
||||
return activeRepository.flatMapLatest { it.observeSeriesWithContent(seriesId) }
|
||||
}
|
||||
|
||||
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
|
||||
val repository = if (networkMonitor.isOnline.first()) onlineRepository else offlineRepository
|
||||
repository.updateWatchProgress(mediaId, positionMs, durationMs)
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ConnectivityNetworkMonitor @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : NetworkMonitor {
|
||||
|
||||
override val isOnline: Flow<Boolean> = callbackFlow {
|
||||
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
||||
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
trySend(true)
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
trySend(connectivityManager.isCurrentlyConnected())
|
||||
}
|
||||
}
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
trySend(connectivityManager.isCurrentlyConnected())
|
||||
connectivityManager.registerNetworkCallback(request, callback)
|
||||
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
|
||||
}.distinctUntilChanged()
|
||||
|
||||
private fun ConnectivityManager.isCurrentlyConnected(): Boolean {
|
||||
val network = activeNetwork ?: return false
|
||||
val caps = getNetworkCapabilities(network) ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DefaultEpisodeSeriesLookup @Inject constructor(
|
||||
private val mediaCatalogReader: MediaCatalogReader,
|
||||
) : EpisodeSeriesLookup {
|
||||
override suspend fun preferenceKeyFor(mediaId: UUID): String {
|
||||
return mediaCatalogReader.episodes.value[mediaId]?.seriesId?.toString() ?: mediaId.toString()
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import hu.bbara.purefin.core.data.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.core.data.client.PlaybackDecision
|
||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.data.client.playbackCustomCacheKey
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
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.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
|
||||
@Singleton
|
||||
class DefaultPlayableMediaRepository @Inject constructor(
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
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 baseItem = jellyfinApiClient.getItemInfo(mediaId)
|
||||
|
||||
val resumePositionMs = calculateResumePosition(baseItem, playbackDecision.mediaSource)
|
||||
|
||||
val serverUrl = userSessionRepository.serverUrl.first()
|
||||
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY)
|
||||
|
||||
val mediaItem = createMediaItem(
|
||||
mediaId = mediaId.toString(),
|
||||
playbackDecision = playbackDecision,
|
||||
title = baseItem?.name ?: playbackDecision.mediaSource.name ?: return@withContext null,
|
||||
subtitle = seasonEpisodeLabel(baseItem),
|
||||
artworkUrl = artworkUrl,
|
||||
playbackReportContext = playbackDecision.reportContext,
|
||||
)
|
||||
|
||||
Pair(mediaItem, resumePositionMs)
|
||||
}
|
||||
|
||||
override suspend fun getNextUpMediaItems(
|
||||
episodeId: UUID,
|
||||
existingIds: Set<String>,
|
||||
count: Int,
|
||||
): List<MediaItem> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val serverUrl = userSessionRepository.serverUrl.first()
|
||||
val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count)
|
||||
episodes.mapNotNull { episode ->
|
||||
val id = episode.id ?: return@mapNotNull null
|
||||
val stringId = id.toString()
|
||||
if (existingIds.contains(stringId)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val playbackDecision = jellyfinApiClient.getPlaybackDecision(id) ?: return@mapNotNull null
|
||||
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
|
||||
createMediaItem(
|
||||
mediaId = stringId,
|
||||
playbackDecision = playbackDecision,
|
||||
title = episode.name ?: playbackDecision.mediaSource.name ?: return@mapNotNull null,
|
||||
subtitle = seasonEpisodeLabel(episode),
|
||||
artworkUrl = artworkUrl,
|
||||
playbackReportContext = playbackDecision.reportContext,
|
||||
)
|
||||
}
|
||||
}.getOrElse { error ->
|
||||
Log.w("PlayableMediaRepo", "Unable to load next-up items for $episodeId", error)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun createMediaItem(
|
||||
mediaId: String,
|
||||
playbackDecision: PlaybackDecision,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
artworkUrl: String,
|
||||
playbackReportContext: PlaybackReportContext?,
|
||||
): MediaItem {
|
||||
val metadata = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setArtworkUri(artworkUrl.toUri())
|
||||
.build()
|
||||
val builder = MediaItem.Builder()
|
||||
.setUri(playbackDecision.url.toUri())
|
||||
.setMediaId(mediaId)
|
||||
.setMediaMetadata(metadata)
|
||||
.setTag(playbackReportContext)
|
||||
|
||||
playbackCustomCacheKey(
|
||||
mediaId = mediaId,
|
||||
playbackUrl = playbackDecision.url,
|
||||
playMethod = playbackDecision.reportContext.playMethod,
|
||||
)?.let(builder::setCustomCacheKey)
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun calculateResumePosition(
|
||||
baseItem: BaseItemDto?,
|
||||
mediaSource: MediaSourceInfo,
|
||||
): Long? {
|
||||
val userData = baseItem?.userData ?: return null
|
||||
val runtimeTicks = mediaSource.runTimeTicks ?: baseItem.runTimeTicks ?: 0L
|
||||
if (runtimeTicks == 0L) return null
|
||||
|
||||
val playbackPositionTicks = userData.playbackPositionTicks ?: 0L
|
||||
if (playbackPositionTicks == 0L) return null
|
||||
|
||||
val positionMs = playbackPositionTicks / 10_000
|
||||
val percentage = (playbackPositionTicks.toDouble() / runtimeTicks.toDouble()) * 100.0
|
||||
return if (percentage in 5.0..95.0) positionMs else null
|
||||
}
|
||||
|
||||
private fun seasonEpisodeLabel(item: BaseItemDto?): String? {
|
||||
val seasonNumber = item?.parentIndexNumber ?: return null
|
||||
val episodeNumber = item.indexNumber ?: return null
|
||||
return "S$seasonNumber:E$episodeNumber"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
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
|
||||
|
||||
interface DownloadMediaSourceResolver {
|
||||
suspend fun resolveMovieDownload(movieId: UUID): MovieDownloadSource?
|
||||
suspend fun resolveEpisodeDownload(episodeId: UUID): EpisodeDownloadSource?
|
||||
suspend fun isEpisodeWatched(episodeId: UUID): Boolean
|
||||
suspend fun getUnwatchedEpisodeIds(
|
||||
seriesId: UUID,
|
||||
excludedEpisodeIds: Set<UUID>,
|
||||
limit: Int,
|
||||
): List<UUID>
|
||||
}
|
||||
|
||||
data class MovieDownloadSource(
|
||||
val movie: Movie,
|
||||
val playbackUrl: String,
|
||||
val customCacheKey: String?,
|
||||
)
|
||||
|
||||
data class EpisodeDownloadSource(
|
||||
val episode: Episode,
|
||||
val series: Series,
|
||||
val season: Season,
|
||||
val playbackUrl: String,
|
||||
val customCacheKey: String?,
|
||||
)
|
||||
@@ -1,419 +0,0 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import android.util.Log
|
||||
import androidx.datastore.core.DataStore
|
||||
import hu.bbara.purefin.core.data.cache.CachedMediaItem
|
||||
import hu.bbara.purefin.core.data.cache.HomeCache
|
||||
import hu.bbara.purefin.core.data.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Library
|
||||
import hu.bbara.purefin.core.model.LibraryKind
|
||||
import hu.bbara.purefin.core.model.Media
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.core.model.Season
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
@Singleton
|
||||
class InMemoryAppContentRepository @Inject constructor(
|
||||
val userSessionRepository: UserSessionRepository,
|
||||
val jellyfinApiClient: JellyfinApiClient,
|
||||
private val homeCacheDataStore: DataStore<HomeCache>,
|
||||
private val onlineMediaRepository: InMemoryMediaRepository,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
) : HomeRepository {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val ensureReadyMutex = Mutex()
|
||||
private var refreshJob: Job? = null
|
||||
private var cacheHydrated = false
|
||||
private var initialized = false
|
||||
|
||||
private val librariesState = MutableStateFlow<List<Library>>(emptyList())
|
||||
override val libraries: StateFlow<List<Library>> = librariesState.asStateFlow()
|
||||
|
||||
private val suggestionsState = MutableStateFlow<List<Media>>(emptyList())
|
||||
override val suggestions: StateFlow<List<Media>> = suggestionsState.asStateFlow()
|
||||
|
||||
private val continueWatchingState = MutableStateFlow<List<Media>>(emptyList())
|
||||
override val continueWatching: StateFlow<List<Media>> = continueWatchingState.asStateFlow()
|
||||
|
||||
private val nextUpState = MutableStateFlow<List<Media>>(emptyList())
|
||||
override val nextUp: StateFlow<List<Media>> = nextUpState.asStateFlow()
|
||||
|
||||
private val latestLibraryContentState = MutableStateFlow<Map<UUID, List<Media>>>(emptyMap())
|
||||
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = latestLibraryContentState.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
ensureReady()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun ensureReady() {
|
||||
ensureReadyMutex.withLock {
|
||||
hydrateCacheIfNeeded()
|
||||
if (initialized) {
|
||||
return
|
||||
}
|
||||
initialized = true
|
||||
}
|
||||
refreshHomeData()
|
||||
}
|
||||
|
||||
override suspend fun refreshHomeData() {
|
||||
ensureReadyMutex.withLock {
|
||||
hydrateCacheIfNeeded()
|
||||
}
|
||||
if (refreshJob?.isActive == true) {
|
||||
refreshJob?.join()
|
||||
return
|
||||
}
|
||||
val job = scope.launch {
|
||||
runCatching {
|
||||
if (!networkMonitor.isOnline.first()) {
|
||||
return@runCatching
|
||||
}
|
||||
loadLibraries()
|
||||
loadSuggestions()
|
||||
loadContinueWatching()
|
||||
loadNextUp()
|
||||
loadLatestLibraryContent()
|
||||
persistHomeCache()
|
||||
}.onFailure { error ->
|
||||
Log.w(TAG, "Home refresh failed; keeping cached content", error)
|
||||
}
|
||||
}
|
||||
refreshJob = job
|
||||
job.join()
|
||||
}
|
||||
|
||||
private suspend fun hydrateCacheIfNeeded() {
|
||||
if (cacheHydrated) return
|
||||
loadFromCache()
|
||||
cacheHydrated = true
|
||||
}
|
||||
|
||||
private suspend fun loadFromCache() {
|
||||
val cache = homeCacheDataStore.data.first()
|
||||
if (cache.suggestions.isNotEmpty()) {
|
||||
suggestionsState.value = cache.suggestions.mapNotNull { it.toMedia() }
|
||||
}
|
||||
if (cache.continueWatching.isNotEmpty()) {
|
||||
continueWatchingState.value = cache.continueWatching.mapNotNull { it.toMedia() }
|
||||
}
|
||||
if (cache.nextUp.isNotEmpty()) {
|
||||
nextUpState.value = cache.nextUp.mapNotNull { it.toMedia() }
|
||||
}
|
||||
if (cache.latestLibraryContent.isNotEmpty()) {
|
||||
latestLibraryContentState.value = cache.latestLibraryContent.mapNotNull { (key, items) ->
|
||||
val uuid = runCatching { UUID.fromString(key) }.getOrNull() ?: return@mapNotNull null
|
||||
uuid to items.mapNotNull { it.toMedia() }
|
||||
}.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persistHomeCache() {
|
||||
val cache = HomeCache(
|
||||
suggestions = suggestionsState.value.map { it.toCachedItem() },
|
||||
continueWatching = continueWatchingState.value.map { it.toCachedItem() },
|
||||
nextUp = nextUpState.value.map { it.toCachedItem() },
|
||||
latestLibraryContent = latestLibraryContentState.value.map { (uuid, items) ->
|
||||
uuid.toString() to items.map { it.toCachedItem() }
|
||||
}.toMap(),
|
||||
)
|
||||
homeCacheDataStore.updateData { cache }
|
||||
}
|
||||
|
||||
private fun Media.toCachedItem(): CachedMediaItem = when (this) {
|
||||
is Media.MovieMedia -> CachedMediaItem(type = "MOVIE", id = movieId.toString())
|
||||
is Media.SeriesMedia -> CachedMediaItem(type = "SERIES", id = seriesId.toString())
|
||||
is Media.SeasonMedia -> CachedMediaItem(type = "SEASON", id = seasonId.toString(), seriesId = seriesId.toString())
|
||||
is Media.EpisodeMedia -> CachedMediaItem(type = "EPISODE", id = episodeId.toString(), seriesId = seriesId.toString())
|
||||
}
|
||||
|
||||
private fun CachedMediaItem.toMedia(): Media? {
|
||||
val uuid = runCatching { UUID.fromString(id) }.getOrNull() ?: return null
|
||||
val seriesUuid = seriesId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
||||
return when (type) {
|
||||
"MOVIE" -> Media.MovieMedia(movieId = uuid)
|
||||
"SERIES" -> Media.SeriesMedia(seriesId = uuid)
|
||||
"SEASON" -> Media.SeasonMedia(seasonId = uuid, seriesId = seriesUuid ?: return null)
|
||||
"EPISODE" -> Media.EpisodeMedia(episodeId = uuid, seriesId = seriesUuid ?: return null)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadLibraries() {
|
||||
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
|
||||
.getOrElse { error ->
|
||||
Log.w(TAG, "Unable to load libraries", error)
|
||||
return
|
||||
}
|
||||
val filteredLibraries = librariesItem.filter {
|
||||
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
|
||||
}
|
||||
val emptyLibraries = filteredLibraries.map { it.toLibrary(serverUrl()) }
|
||||
librariesState.value = emptyLibraries
|
||||
|
||||
val filledLibraries = emptyLibraries.map { loadLibrary(it) }
|
||||
librariesState.value = filledLibraries
|
||||
|
||||
val movies = filledLibraries.filter { it.type == LibraryKind.MOVIES }.flatMap { it.movies.orEmpty() }
|
||||
onlineMediaRepository.upsertMovies(movies)
|
||||
|
||||
val series = filledLibraries.filter { it.type == LibraryKind.SERIES }.flatMap { it.series.orEmpty() }
|
||||
onlineMediaRepository.upsertSeries(series)
|
||||
}
|
||||
|
||||
suspend fun loadLibrary(library: Library): Library {
|
||||
val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) }
|
||||
.getOrElse { error ->
|
||||
Log.w(TAG, "Unable to load library ${library.id}", error)
|
||||
return library
|
||||
}
|
||||
return when (library.type) {
|
||||
LibraryKind.MOVIES -> library.copy(
|
||||
movies = contentItem.map { it.toMovie(serverUrl(), library.id) },
|
||||
)
|
||||
LibraryKind.SERIES -> library.copy(
|
||||
series = contentItem.map { it.toSeries(serverUrl(), library.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadSuggestions() {
|
||||
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
|
||||
.getOrElse { error ->
|
||||
Log.w(TAG, "Unable to load suggestions", error)
|
||||
return
|
||||
}
|
||||
suggestionsState.value = suggestionsItems.mapNotNull { item ->
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
|
||||
BaseItemKind.EPISODE -> Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: ${item.type}")
|
||||
}
|
||||
}
|
||||
|
||||
suggestionsItems.forEach { item ->
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadContinueWatching() {
|
||||
val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
|
||||
.getOrElse { error ->
|
||||
Log.w(TAG, "Unable to load continue watching", error)
|
||||
return
|
||||
}
|
||||
continueWatchingState.value = continueWatchingItems.mapNotNull { item ->
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
|
||||
BaseItemKind.EPISODE -> Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: ${item.type}")
|
||||
}
|
||||
}
|
||||
|
||||
continueWatchingItems.forEach { item ->
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadNextUp() {
|
||||
val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() }
|
||||
.getOrElse { error ->
|
||||
Log.w(TAG, "Unable to load next up", error)
|
||||
return
|
||||
}
|
||||
nextUpState.value = nextUpItems.map { item ->
|
||||
Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
|
||||
}
|
||||
|
||||
nextUpItems.forEach { item ->
|
||||
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadLatestLibraryContent() {
|
||||
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
|
||||
.getOrElse { error ->
|
||||
Log.w(TAG, "Unable to load latest library content", error)
|
||||
return
|
||||
}
|
||||
val filteredLibraries = librariesItem.filter {
|
||||
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
|
||||
}
|
||||
val latestLibraryContents = filteredLibraries.associate { library ->
|
||||
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
|
||||
.getOrElse { error ->
|
||||
Log.w(TAG, "Unable to load latest items for library ${library.id}", error)
|
||||
emptyList()
|
||||
}
|
||||
library.id to when (library.collectionType) {
|
||||
CollectionType.MOVIES -> latestFromLibrary.map {
|
||||
val movie = it.toMovie(serverUrl(), library.id)
|
||||
Media.MovieMedia(movieId = movie.id)
|
||||
}
|
||||
CollectionType.TVSHOWS -> latestFromLibrary.map {
|
||||
when (it.type) {
|
||||
BaseItemKind.SERIES -> {
|
||||
val series = it.toSeries(serverUrl(), library.id)
|
||||
Media.SeriesMedia(seriesId = series.id)
|
||||
}
|
||||
BaseItemKind.SEASON -> {
|
||||
val season = it.toSeason()
|
||||
Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId)
|
||||
}
|
||||
BaseItemKind.EPISODE -> {
|
||||
val episode = it.toEpisode(serverUrl())
|
||||
Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId)
|
||||
}
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
|
||||
}
|
||||
}
|
||||
else -> throw UnsupportedOperationException("Unsupported library type: ${library.collectionType}")
|
||||
}
|
||||
}
|
||||
latestLibraryContentState.value = latestLibraryContents
|
||||
}
|
||||
|
||||
private suspend fun serverUrl(): String {
|
||||
return userSessionRepository.serverUrl.first()
|
||||
}
|
||||
|
||||
private fun BaseItemDto.toLibrary(serverUrl: String): Library {
|
||||
return when (collectionType) {
|
||||
CollectionType.MOVIES -> Library(
|
||||
id = id,
|
||||
name = name!!,
|
||||
posterUrl = JellyfinImageHelper.toImageUrl(url = serverUrl, itemId = id, type = ImageType.PRIMARY),
|
||||
type = LibraryKind.MOVIES,
|
||||
movies = emptyList(),
|
||||
)
|
||||
CollectionType.TVSHOWS -> Library(
|
||||
id = id,
|
||||
name = name!!,
|
||||
posterUrl = JellyfinImageHelper.toImageUrl(url = serverUrl, itemId = id, type = ImageType.PRIMARY),
|
||||
type = LibraryKind.SERIES,
|
||||
series = emptyList(),
|
||||
)
|
||||
else -> throw UnsupportedOperationException("Unsupported library type: $collectionType")
|
||||
}
|
||||
}
|
||||
|
||||
private fun BaseItemDto.toMovie(serverUrl: String, libraryId: UUID): Movie {
|
||||
return Movie(
|
||||
id = id,
|
||||
libraryId = libraryId,
|
||||
title = name ?: "Unknown title",
|
||||
progress = userData!!.playedPercentage,
|
||||
watched = userData!!.played,
|
||||
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
|
||||
rating = officialRating ?: "NR",
|
||||
runtime = formatRuntime(runTimeTicks),
|
||||
synopsis = overview ?: "No synopsis available",
|
||||
format = container?.uppercase() ?: "VIDEO",
|
||||
imageUrlPrefix = JellyfinImageHelper.toPrefixImageUrl(url = serverUrl, itemId = id),
|
||||
subtitles = "ENG",
|
||||
audioTrack = "ENG",
|
||||
cast = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun BaseItemDto.toSeries(serverUrl: String, libraryId: UUID): Series {
|
||||
return Series(
|
||||
id = id,
|
||||
libraryId = libraryId,
|
||||
name = name ?: "Unknown",
|
||||
synopsis = overview ?: "No synopsis available",
|
||||
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
|
||||
imageUrlPrefix = JellyfinImageHelper.toPrefixImageUrl(url = serverUrl, itemId = id),
|
||||
unwatchedEpisodeCount = userData!!.unplayedItemCount!!,
|
||||
seasonCount = childCount!!,
|
||||
seasons = emptyList(),
|
||||
cast = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun BaseItemDto.toSeason(): Season {
|
||||
return Season(
|
||||
id = id,
|
||||
seriesId = seriesId!!,
|
||||
name = name ?: "Unknown",
|
||||
index = indexNumber ?: 0,
|
||||
unwatchedEpisodeCount = userData!!.unplayedItemCount!!,
|
||||
episodeCount = childCount!!,
|
||||
episodes = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun BaseItemDto.toEpisode(serverUrl: String): Episode {
|
||||
val releaseDate = formatReleaseDate(premiereDate, productionYear)
|
||||
val imageUrlPrefix = id?.let { itemId ->
|
||||
JellyfinImageHelper.toPrefixImageUrl(url = serverUrl, itemId = itemId)
|
||||
} ?: ""
|
||||
return Episode(
|
||||
id = id,
|
||||
seriesId = seriesId!!,
|
||||
seasonId = parentId!!,
|
||||
title = name ?: "Unknown title",
|
||||
index = indexNumber!!,
|
||||
releaseDate = releaseDate,
|
||||
rating = officialRating ?: "NR",
|
||||
runtime = formatRuntime(runTimeTicks),
|
||||
progress = userData!!.playedPercentage,
|
||||
watched = userData!!.played,
|
||||
format = container?.uppercase() ?: "VIDEO",
|
||||
synopsis = overview ?: "No synopsis available.",
|
||||
imageUrlPrefix = imageUrlPrefix,
|
||||
cast = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String {
|
||||
if (date == null) {
|
||||
return fallbackYear?.toString() ?: "—"
|
||||
}
|
||||
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
|
||||
return date.toLocalDate().format(formatter)
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "InMemoryAppContentRepo"
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import hu.bbara.purefin.core.data.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
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.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
|
||||
@Singleton
|
||||
class InMemoryMediaRepository @Inject constructor(
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
) : MediaCatalogReader, MediaProgressWriter {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private val moviesState = MutableStateFlow<Map<UUID, Movie>>(emptyMap())
|
||||
override val movies: StateFlow<Map<UUID, Movie>> = moviesState.asStateFlow()
|
||||
|
||||
private val seriesState = MutableStateFlow<Map<UUID, Series>>(emptyMap())
|
||||
override val series: StateFlow<Map<UUID, Series>> = seriesState.asStateFlow()
|
||||
|
||||
private val episodesState = MutableStateFlow<Map<UUID, Episode>>(emptyMap())
|
||||
override val episodes: StateFlow<Map<UUID, Episode>> = episodesState.asStateFlow()
|
||||
|
||||
fun upsertMovies(movies: List<Movie>) {
|
||||
moviesState.update { current -> current + movies.associateBy { it.id } }
|
||||
}
|
||||
|
||||
fun upsertSeries(series: List<Series>) {
|
||||
seriesState.update { current -> current + series.associateBy { it.id } }
|
||||
}
|
||||
|
||||
fun upsertEpisodes(episodes: List<Episode>) {
|
||||
episodesState.update { current -> current + episodes.associateBy { it.id } }
|
||||
}
|
||||
|
||||
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
|
||||
scope.launch {
|
||||
ensureSeriesContentLoaded(seriesId)
|
||||
}
|
||||
return seriesState.map { it[seriesId] }
|
||||
}
|
||||
|
||||
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
|
||||
if (durationMs <= 0) return
|
||||
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
|
||||
val watched = progressPercent >= 90.0
|
||||
|
||||
if (moviesState.value.containsKey(mediaId)) {
|
||||
moviesState.update { current ->
|
||||
val movie = current[mediaId] ?: return@update current
|
||||
current + (mediaId to movie.copy(progress = progressPercent, watched = watched))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (episodesState.value.containsKey(mediaId)) {
|
||||
episodesState.update { current ->
|
||||
val episode = current[mediaId] ?: return@update current
|
||||
current + (mediaId to episode.copy(progress = progressPercent, watched = watched))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureSeriesContentLoaded(seriesId: UUID) {
|
||||
seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
|
||||
|
||||
val series = seriesState.value[seriesId] ?: throw RuntimeException("Series not found")
|
||||
val serverUrl = userSessionRepository.serverUrl.first()
|
||||
|
||||
val emptySeasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
|
||||
val filledSeasons = emptySeasons.map { season ->
|
||||
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, season.id).map { it.toEpisode(serverUrl) }
|
||||
season.copy(episodes = episodes)
|
||||
}
|
||||
|
||||
val updatedSeries = series.copy(seasons = filledSeasons)
|
||||
seriesState.update { it + (updatedSeries.id to updatedSeries) }
|
||||
|
||||
val allEpisodes = filledSeasons.flatMap { it.episodes }
|
||||
episodesState.update { current -> current + allEpisodes.associateBy { it.id } }
|
||||
}
|
||||
|
||||
private fun BaseItemDto.toSeason(): Season {
|
||||
return Season(
|
||||
id = id,
|
||||
seriesId = seriesId!!,
|
||||
name = name ?: "Unknown",
|
||||
index = indexNumber ?: 0,
|
||||
unwatchedEpisodeCount = userData!!.unplayedItemCount!!,
|
||||
episodeCount = childCount!!,
|
||||
episodes = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun BaseItemDto.toEpisode(serverUrl: String): Episode {
|
||||
val releaseDate = formatReleaseDate(premiereDate, productionYear)
|
||||
val imageUrlPrefix = id?.let { itemId ->
|
||||
JellyfinImageHelper.toPrefixImageUrl(url = serverUrl, itemId = itemId)
|
||||
} ?: ""
|
||||
return Episode(
|
||||
id = id,
|
||||
seriesId = seriesId!!,
|
||||
seasonId = parentId!!,
|
||||
title = name ?: "Unknown title",
|
||||
index = indexNumber!!,
|
||||
releaseDate = releaseDate,
|
||||
rating = officialRating ?: "NR",
|
||||
runtime = formatRuntime(runTimeTicks),
|
||||
progress = userData!!.playedPercentage,
|
||||
watched = userData!!.played,
|
||||
format = container?.uppercase() ?: "VIDEO",
|
||||
synopsis = overview ?: "No synopsis available.",
|
||||
imageUrlPrefix = imageUrlPrefix,
|
||||
cast = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String {
|
||||
if (date == null) return fallbackYear?.toString() ?: "—"
|
||||
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
|
||||
return date.toLocalDate().format(formatter)
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import hu.bbara.purefin.core.data.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class JellyfinPlaybackProgressReporter @Inject constructor(
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
) : PlaybackProgressReporter {
|
||||
override suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long, reportContext: PlaybackReportContext) {
|
||||
jellyfinApiClient.reportPlaybackStart(itemId, positionTicks, reportContext)
|
||||
}
|
||||
|
||||
override suspend fun reportPlaybackProgress(
|
||||
itemId: UUID,
|
||||
positionTicks: Long,
|
||||
isPaused: Boolean,
|
||||
reportContext: PlaybackReportContext,
|
||||
) {
|
||||
jellyfinApiClient.reportPlaybackProgress(itemId, positionTicks, isPaused, reportContext)
|
||||
}
|
||||
|
||||
override suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long, reportContext: PlaybackReportContext) {
|
||||
jellyfinApiClient.reportPlaybackStopped(itemId, positionTicks, reportContext)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class MediaRepositoryModule {
|
||||
|
||||
@Binds
|
||||
abstract fun bindHomeRepository(impl: InMemoryAppContentRepository): HomeRepository
|
||||
|
||||
@Binds
|
||||
abstract fun bindMediaCatalogReader(impl: CompositeMediaRepository): MediaCatalogReader
|
||||
|
||||
@Binds
|
||||
abstract fun bindMediaProgressWriter(impl: CompositeMediaRepository): MediaProgressWriter
|
||||
|
||||
@Binds
|
||||
abstract fun bindOfflineCatalogReader(impl: OfflineMediaRepository): OfflineCatalogReader
|
||||
|
||||
@Binds
|
||||
abstract fun bindOfflineCatalogStore(impl: RoomOfflineCatalogStore): OfflineCatalogStore
|
||||
|
||||
@Binds
|
||||
abstract fun bindSmartDownloadStore(impl: RoomSmartDownloadStore): SmartDownloadStore
|
||||
|
||||
@Binds
|
||||
abstract fun bindPlayableMediaRepository(impl: DefaultPlayableMediaRepository): PlayableMediaRepository
|
||||
|
||||
@Binds
|
||||
abstract fun bindEpisodeSeriesLookup(impl: DefaultEpisodeSeriesLookup): EpisodeSeriesLookup
|
||||
|
||||
@Binds
|
||||
abstract fun bindPlaybackProgressReporter(impl: JellyfinPlaybackProgressReporter): PlaybackProgressReporter
|
||||
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import hu.bbara.purefin.core.data.room.offline.OfflineRoomMediaLocalDataSource
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
@Singleton
|
||||
class OfflineMediaRepository @Inject constructor(
|
||||
private val localDataSource: OfflineRoomMediaLocalDataSource,
|
||||
) : OfflineCatalogReader, MediaCatalogReader, MediaProgressWriter {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override val movies: StateFlow<Map<UUID, Movie>> = localDataSource.moviesFlow
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override val series: StateFlow<Map<UUID, Series>> = localDataSource.seriesFlow
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
|
||||
return localDataSource.observeSeriesWithContent(seriesId)
|
||||
}
|
||||
|
||||
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
|
||||
if (durationMs <= 0) return
|
||||
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
|
||||
val watched = progressPercent >= 90.0
|
||||
localDataSource.updateWatchProgress(mediaId, progressPercent, watched)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
enum class PlaybackMethod {
|
||||
DIRECT_PLAY,
|
||||
DIRECT_STREAM,
|
||||
TRANSCODE,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
enum class PlaybackProfileFamily {
|
||||
MOBILE,
|
||||
TV,
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||
import java.util.UUID
|
||||
|
||||
interface PlaybackProgressReporter {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
data class PlaybackReportContext(
|
||||
val playMethod: PlaybackMethod,
|
||||
val mediaSourceId: String?,
|
||||
val audioStreamIndex: Int?,
|
||||
val subtitleStreamIndex: Int?,
|
||||
val liveStreamId: String?,
|
||||
val playSessionId: String?,
|
||||
)
|
||||
@@ -1,51 +0,0 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import hu.bbara.purefin.core.data.room.offline.OfflineRoomMediaLocalDataSource
|
||||
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 javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class RoomOfflineCatalogStore @Inject constructor(
|
||||
private val localDataSource: OfflineRoomMediaLocalDataSource,
|
||||
) : OfflineCatalogStore {
|
||||
override suspend fun saveMovies(movies: List<Movie>) {
|
||||
localDataSource.saveMovies(movies)
|
||||
}
|
||||
|
||||
override suspend fun saveSeries(series: List<Series>) {
|
||||
localDataSource.saveSeries(series)
|
||||
}
|
||||
|
||||
override suspend fun saveSeason(season: Season) {
|
||||
localDataSource.saveSeason(season)
|
||||
}
|
||||
|
||||
override suspend fun saveEpisode(episode: Episode) {
|
||||
localDataSource.saveEpisode(episode)
|
||||
}
|
||||
|
||||
override suspend fun getSeriesBasic(seriesId: UUID): Series? {
|
||||
return localDataSource.getSeriesBasic(seriesId)
|
||||
}
|
||||
|
||||
override suspend fun getSeason(seasonId: UUID): Season? {
|
||||
return localDataSource.getSeason(seasonId)
|
||||
}
|
||||
|
||||
override suspend fun deleteMovie(movieId: UUID) {
|
||||
localDataSource.deleteMovie(movieId)
|
||||
}
|
||||
|
||||
override suspend fun deleteEpisodeAndCleanup(episodeId: UUID) {
|
||||
localDataSource.deleteEpisodeAndCleanup(episodeId)
|
||||
}
|
||||
|
||||
override suspend fun getEpisodesBySeries(seriesId: UUID): List<Episode> {
|
||||
return localDataSource.getEpisodesBySeries(seriesId)
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import hu.bbara.purefin.core.data.room.dao.SmartDownloadDao
|
||||
import hu.bbara.purefin.core.data.room.entity.SmartDownloadEntity
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Singleton
|
||||
class RoomSmartDownloadStore @Inject constructor(
|
||||
private val smartDownloadDao: SmartDownloadDao,
|
||||
) : SmartDownloadStore {
|
||||
override suspend fun enable(seriesId: UUID) {
|
||||
smartDownloadDao.insert(SmartDownloadEntity(seriesId))
|
||||
}
|
||||
|
||||
override suspend fun disable(seriesId: UUID) {
|
||||
smartDownloadDao.delete(seriesId)
|
||||
}
|
||||
|
||||
override fun observe(seriesId: UUID): Flow<Boolean> {
|
||||
return smartDownloadDao.observe(seriesId)
|
||||
}
|
||||
|
||||
override suspend fun getEnabledSeriesIds(): List<UUID> {
|
||||
return smartDownloadDao.getAll().map { it.seriesId }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
interface SessionBootstrapper {
|
||||
suspend fun initialize()
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.cache
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CachedMediaItem(
|
||||
val type: String,
|
||||
val id: String,
|
||||
val seriesId: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HomeCache(
|
||||
val suggestions: List<CachedMediaItem> = emptyList(),
|
||||
val continueWatching: List<CachedMediaItem> = emptyList(),
|
||||
val nextUp: List<CachedMediaItem> = emptyList(),
|
||||
val latestLibraryContent: Map<String, List<CachedMediaItem>> = emptyMap()
|
||||
)
|
||||
@@ -1,32 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.cache
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.dataStoreFile
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class HomeCacheModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideHomeCacheDataStore(
|
||||
@ApplicationContext context: Context
|
||||
): DataStore<HomeCache> {
|
||||
return DataStoreFactory.create(
|
||||
serializer = HomeCacheSerializer,
|
||||
produceFile = { context.dataStoreFile("home_cache.json") },
|
||||
corruptionHandler = ReplaceFileCorruptionHandler(
|
||||
produceNewData = { HomeCacheSerializer.defaultValue }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.cache
|
||||
|
||||
import androidx.datastore.core.CorruptionException
|
||||
import androidx.datastore.core.Serializer
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
object HomeCacheSerializer : Serializer<HomeCache> {
|
||||
override val defaultValue: HomeCache
|
||||
get() = HomeCache()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): HomeCache {
|
||||
try {
|
||||
return Json.decodeFromString<HomeCache>(
|
||||
input.readBytes().decodeToString()
|
||||
)
|
||||
} catch (serialization: SerializationException) {
|
||||
throw CorruptionException("proto", serialization)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun writeTo(t: HomeCache, output: OutputStream) {
|
||||
output.write(
|
||||
Json.encodeToString(t)
|
||||
.encodeToByteArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaCodecInfo.CodecProfileLevel
|
||||
import android.media.MediaFormat
|
||||
|
||||
internal data class AndroidCodecCatalog(
|
||||
val supportedVideoCodecs: Set<String>,
|
||||
val supportedAudioCodecs: Set<String>,
|
||||
val videoCodecProfiles: Map<String, Set<String>>,
|
||||
) {
|
||||
companion object {
|
||||
fun create(codecInfos: Array<MediaCodecInfo>): AndroidCodecCatalog {
|
||||
val supportedVideoCodecs = linkedSetOf<String>()
|
||||
val supportedAudioCodecs = linkedSetOf<String>()
|
||||
val videoCodecProfiles = linkedMapOf<String, MutableSet<String>>()
|
||||
|
||||
for (codecInfo in codecInfos) {
|
||||
if (codecInfo.isEncoder) continue
|
||||
|
||||
for (mimeType in codecInfo.supportedTypes) {
|
||||
val capabilities = try {
|
||||
codecInfo.getCapabilitiesForType(mimeType)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
continue
|
||||
}
|
||||
|
||||
androidVideoCodecName(mimeType)?.let { codec ->
|
||||
supportedVideoCodecs += codec
|
||||
val profiles = videoCodecProfiles.getOrPut(codec) { linkedSetOf() }
|
||||
capabilities.profileLevels
|
||||
.mapNotNull { androidVideoProfileName(codec, it.profile) }
|
||||
.forEach(profiles::add)
|
||||
}
|
||||
|
||||
androidAudioCodecName(mimeType)?.let(supportedAudioCodecs::add)
|
||||
}
|
||||
}
|
||||
|
||||
return AndroidCodecCatalog(
|
||||
supportedVideoCodecs = supportedVideoCodecs,
|
||||
supportedAudioCodecs = supportedAudioCodecs,
|
||||
videoCodecProfiles = videoCodecProfiles.mapValues { it.value.toSet() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun androidVideoCodecName(mimeType: String): String? = when (mimeType) {
|
||||
MediaFormat.MIMETYPE_VIDEO_MPEG2 -> "mpeg2video"
|
||||
MediaFormat.MIMETYPE_VIDEO_H263 -> "h263"
|
||||
MediaFormat.MIMETYPE_VIDEO_MPEG4 -> "mpeg4"
|
||||
MediaFormat.MIMETYPE_VIDEO_AVC -> "h264"
|
||||
MediaFormat.MIMETYPE_VIDEO_HEVC, MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION -> "hevc"
|
||||
MediaFormat.MIMETYPE_VIDEO_VP8 -> "vp8"
|
||||
MediaFormat.MIMETYPE_VIDEO_VP9 -> "vp9"
|
||||
MediaFormat.MIMETYPE_VIDEO_AV1 -> "av1"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun androidAudioCodecName(mimeType: String): String? = when (mimeType) {
|
||||
MediaFormat.MIMETYPE_AUDIO_AAC -> "aac"
|
||||
MediaFormat.MIMETYPE_AUDIO_AC3 -> "ac3"
|
||||
MediaFormat.MIMETYPE_AUDIO_AMR_WB, MediaFormat.MIMETYPE_AUDIO_AMR_NB -> "3gpp"
|
||||
MediaFormat.MIMETYPE_AUDIO_EAC3 -> "eac3"
|
||||
MediaFormat.MIMETYPE_AUDIO_FLAC -> "flac"
|
||||
MediaFormat.MIMETYPE_AUDIO_MPEG -> "mp3"
|
||||
MediaFormat.MIMETYPE_AUDIO_OPUS -> "opus"
|
||||
MediaFormat.MIMETYPE_AUDIO_RAW -> "raw"
|
||||
MediaFormat.MIMETYPE_AUDIO_VORBIS -> "vorbis"
|
||||
MediaFormat.MIMETYPE_AUDIO_QCELP,
|
||||
MediaFormat.MIMETYPE_AUDIO_MSGSM,
|
||||
MediaFormat.MIMETYPE_AUDIO_G711_MLAW,
|
||||
MediaFormat.MIMETYPE_AUDIO_G711_ALAW,
|
||||
-> null
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun androidVideoProfileName(codec: String, profile: Int): String? = when (codec) {
|
||||
"mpeg2video" -> mpeg2ProfileName(profile)
|
||||
"h263" -> h263ProfileName(profile)
|
||||
"mpeg4" -> mpeg4ProfileName(profile)
|
||||
"h264" -> avcProfileName(profile)
|
||||
"hevc" -> hevcProfileName(profile)
|
||||
"vp8" -> vp8ProfileName(profile)
|
||||
"vp9" -> vp9ProfileName(profile)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun mpeg2ProfileName(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.MPEG2ProfileSimple -> "simple profile"
|
||||
CodecProfileLevel.MPEG2ProfileMain -> "main profile"
|
||||
CodecProfileLevel.MPEG2Profile422 -> "422 profile"
|
||||
CodecProfileLevel.MPEG2ProfileSNR -> "snr profile"
|
||||
CodecProfileLevel.MPEG2ProfileSpatial -> "spatial profile"
|
||||
CodecProfileLevel.MPEG2ProfileHigh -> "high profile"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun h263ProfileName(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.H263ProfileBaseline -> "baseline"
|
||||
CodecProfileLevel.H263ProfileH320Coding -> "h320 coding"
|
||||
CodecProfileLevel.H263ProfileBackwardCompatible -> "backward compatible"
|
||||
CodecProfileLevel.H263ProfileISWV2 -> "isw v2"
|
||||
CodecProfileLevel.H263ProfileISWV3 -> "isw v3"
|
||||
CodecProfileLevel.H263ProfileHighCompression -> "high compression"
|
||||
CodecProfileLevel.H263ProfileInternet -> "internet"
|
||||
CodecProfileLevel.H263ProfileInterlace -> "interlace"
|
||||
CodecProfileLevel.H263ProfileHighLatency -> "high latency"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun mpeg4ProfileName(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.MPEG4ProfileAdvancedCoding -> "advanced coding profile"
|
||||
CodecProfileLevel.MPEG4ProfileAdvancedCore -> "advanced core profile"
|
||||
CodecProfileLevel.MPEG4ProfileAdvancedRealTime -> "advanced realtime profile"
|
||||
CodecProfileLevel.MPEG4ProfileAdvancedSimple -> "advanced simple profile"
|
||||
CodecProfileLevel.MPEG4ProfileBasicAnimated -> "basic animated profile"
|
||||
CodecProfileLevel.MPEG4ProfileCore -> "core profile"
|
||||
CodecProfileLevel.MPEG4ProfileCoreScalable -> "core scalable profile"
|
||||
CodecProfileLevel.MPEG4ProfileHybrid -> "hybrid profile"
|
||||
CodecProfileLevel.MPEG4ProfileNbit -> "nbit profile"
|
||||
CodecProfileLevel.MPEG4ProfileScalableTexture -> "scalable texture profile"
|
||||
CodecProfileLevel.MPEG4ProfileSimple -> "simple profile"
|
||||
CodecProfileLevel.MPEG4ProfileSimpleFBA -> "simple fba profile"
|
||||
CodecProfileLevel.MPEG4ProfileSimpleFace -> "simple face profile"
|
||||
CodecProfileLevel.MPEG4ProfileSimpleScalable -> "simple scalable profile"
|
||||
CodecProfileLevel.MPEG4ProfileMain -> "main profile"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun avcProfileName(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.AVCProfileBaseline -> "baseline"
|
||||
CodecProfileLevel.AVCProfileMain -> "main"
|
||||
CodecProfileLevel.AVCProfileExtended -> "extended"
|
||||
CodecProfileLevel.AVCProfileHigh -> "high"
|
||||
CodecProfileLevel.AVCProfileHigh10 -> "high 10"
|
||||
CodecProfileLevel.AVCProfileHigh422 -> "high 422"
|
||||
CodecProfileLevel.AVCProfileHigh444 -> "high 444"
|
||||
CodecProfileLevel.AVCProfileConstrainedBaseline -> "constrained baseline"
|
||||
CodecProfileLevel.AVCProfileConstrainedHigh -> "constrained high"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun hevcProfileName(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.HEVCProfileMain -> "Main"
|
||||
CodecProfileLevel.HEVCProfileMain10 -> "Main 10"
|
||||
CodecProfileLevel.HEVCProfileMain10HDR10 -> "Main 10 HDR 10"
|
||||
CodecProfileLevel.HEVCProfileMain10HDR10Plus -> "Main 10 HDR 10 Plus"
|
||||
CodecProfileLevel.HEVCProfileMainStill -> "Main Still"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun vp8ProfileName(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.VP8ProfileMain -> "main"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun vp9ProfileName(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.VP9Profile0 -> "Profile 0"
|
||||
CodecProfileLevel.VP9Profile1 -> "Profile 1"
|
||||
CodecProfileLevel.VP9Profile2,
|
||||
CodecProfileLevel.VP9Profile2HDR,
|
||||
-> "Profile 2"
|
||||
CodecProfileLevel.VP9Profile3,
|
||||
CodecProfileLevel.VP9Profile3HDR,
|
||||
-> "Profile 3"
|
||||
else -> null
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import android.media.MediaCodecInfo.CodecProfileLevel
|
||||
import android.media.MediaCodecList
|
||||
import android.media.MediaFormat
|
||||
import android.os.Build
|
||||
import androidx.media3.common.MimeTypes
|
||||
|
||||
data class ProfileResolution(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
)
|
||||
|
||||
interface DeviceProfileCapabilities {
|
||||
fun supportsAv1(): Boolean
|
||||
fun supportsAv1Main10(): Boolean
|
||||
fun supportsAv1DolbyVision(): Boolean
|
||||
fun supportsAv1Hdr10(): Boolean
|
||||
fun supportsAv1Hdr10Plus(): Boolean
|
||||
fun supportsAvc(): Boolean
|
||||
fun supportsAvcHigh10(): Boolean
|
||||
fun avcMainLevel(): Int
|
||||
fun avcHigh10Level(): Int
|
||||
fun supportsHevc(): Boolean
|
||||
fun supportsHevcMain10(): Boolean
|
||||
fun supportsHevcDolbyVision(): Boolean
|
||||
fun supportsHevcDolbyVisionEl(): Boolean
|
||||
fun supportsHevcHdr10(): Boolean
|
||||
fun supportsHevcHdr10Plus(): Boolean
|
||||
fun hevcMainLevel(): Int
|
||||
fun hevcMain10Level(): Int
|
||||
fun supportsVc1(): Boolean
|
||||
fun maxResolution(mimeType: String): ProfileResolution
|
||||
fun supportsVideoCodec(codec: String): Boolean
|
||||
fun supportsAudioCodec(codec: String): Boolean
|
||||
fun videoCodecProfiles(codec: String): Set<String>
|
||||
val hevcDoviHdr10PlusBug: Boolean
|
||||
}
|
||||
|
||||
internal class AndroidDeviceProfileCapabilities : DeviceProfileCapabilities {
|
||||
private val mediaCodecList by lazy { MediaCodecList(MediaCodecList.REGULAR_CODECS) }
|
||||
private val codecCatalog by lazy { AndroidCodecCatalog.create(mediaCodecList.codecInfos) }
|
||||
|
||||
private object DolbyVisionProfiles {
|
||||
val profile7: Int by lazy {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
CodecProfileLevel.DolbyVisionProfileDvheDtb
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object Av1ProfileLevels {
|
||||
val profileMain10: Int by lazy {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
CodecProfileLevel.AV1ProfileMain10
|
||||
} else {
|
||||
0x2
|
||||
}
|
||||
}
|
||||
val profileMain10Hdr10: Int by lazy {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
CodecProfileLevel.AV1ProfileMain10HDR10
|
||||
} else {
|
||||
0x1000
|
||||
}
|
||||
}
|
||||
val profileMain10Hdr10Plus: Int by lazy {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
CodecProfileLevel.AV1ProfileMain10HDR10Plus
|
||||
} else {
|
||||
0x2000
|
||||
}
|
||||
}
|
||||
val dolbyVisionProfile10: Int by lazy {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
CodecProfileLevel.DolbyVisionProfileDvav110
|
||||
} else {
|
||||
0x400
|
||||
}
|
||||
}
|
||||
val level5: Int by lazy {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
CodecProfileLevel.AV1Level5
|
||||
} else {
|
||||
0x1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val avcLevels = listOf(
|
||||
CodecProfileLevel.AVCLevel1b to 9,
|
||||
CodecProfileLevel.AVCLevel1 to 10,
|
||||
CodecProfileLevel.AVCLevel11 to 11,
|
||||
CodecProfileLevel.AVCLevel12 to 12,
|
||||
CodecProfileLevel.AVCLevel13 to 13,
|
||||
CodecProfileLevel.AVCLevel2 to 20,
|
||||
CodecProfileLevel.AVCLevel21 to 21,
|
||||
CodecProfileLevel.AVCLevel22 to 22,
|
||||
CodecProfileLevel.AVCLevel3 to 30,
|
||||
CodecProfileLevel.AVCLevel31 to 31,
|
||||
CodecProfileLevel.AVCLevel32 to 32,
|
||||
CodecProfileLevel.AVCLevel4 to 40,
|
||||
CodecProfileLevel.AVCLevel41 to 41,
|
||||
CodecProfileLevel.AVCLevel42 to 42,
|
||||
CodecProfileLevel.AVCLevel5 to 50,
|
||||
CodecProfileLevel.AVCLevel51 to 51,
|
||||
CodecProfileLevel.AVCLevel52 to 52,
|
||||
)
|
||||
|
||||
private val hevcLevels = listOf(
|
||||
CodecProfileLevel.HEVCMainTierLevel1 to 30,
|
||||
CodecProfileLevel.HEVCMainTierLevel2 to 60,
|
||||
CodecProfileLevel.HEVCMainTierLevel21 to 63,
|
||||
CodecProfileLevel.HEVCMainTierLevel3 to 90,
|
||||
CodecProfileLevel.HEVCMainTierLevel31 to 93,
|
||||
CodecProfileLevel.HEVCMainTierLevel4 to 120,
|
||||
CodecProfileLevel.HEVCMainTierLevel41 to 123,
|
||||
CodecProfileLevel.HEVCMainTierLevel5 to 150,
|
||||
CodecProfileLevel.HEVCMainTierLevel51 to 153,
|
||||
CodecProfileLevel.HEVCMainTierLevel52 to 156,
|
||||
CodecProfileLevel.HEVCMainTierLevel6 to 180,
|
||||
CodecProfileLevel.HEVCMainTierLevel61 to 183,
|
||||
CodecProfileLevel.HEVCMainTierLevel62 to 186,
|
||||
)
|
||||
|
||||
override fun supportsAv1(): Boolean = hasCodecForMime(MimeTypes.VIDEO_AV1)
|
||||
|
||||
override fun supportsAv1Main10(): Boolean = hasDecoder(
|
||||
MimeTypes.VIDEO_AV1,
|
||||
Av1ProfileLevels.profileMain10,
|
||||
Av1ProfileLevels.level5,
|
||||
)
|
||||
|
||||
override fun supportsAv1DolbyVision(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||
hasDecoder(
|
||||
MimeTypes.VIDEO_DOLBY_VISION,
|
||||
Av1ProfileLevels.dolbyVisionProfile10,
|
||||
CodecProfileLevel.DolbyVisionLevelHd24,
|
||||
)
|
||||
|
||||
override fun supportsAv1Hdr10(): Boolean = hasDecoder(
|
||||
MimeTypes.VIDEO_AV1,
|
||||
Av1ProfileLevels.profileMain10Hdr10,
|
||||
Av1ProfileLevels.level5,
|
||||
)
|
||||
|
||||
override fun supportsAv1Hdr10Plus(): Boolean = hasDecoder(
|
||||
MimeTypes.VIDEO_AV1,
|
||||
Av1ProfileLevels.profileMain10Hdr10Plus,
|
||||
Av1ProfileLevels.level5,
|
||||
)
|
||||
|
||||
override fun supportsAvc(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_AVC)
|
||||
|
||||
override fun supportsAvcHigh10(): Boolean = hasDecoder(
|
||||
MediaFormat.MIMETYPE_VIDEO_AVC,
|
||||
CodecProfileLevel.AVCProfileHigh10,
|
||||
CodecProfileLevel.AVCLevel4,
|
||||
)
|
||||
|
||||
override fun avcMainLevel(): Int = avcLevel(CodecProfileLevel.AVCProfileMain)
|
||||
|
||||
override fun avcHigh10Level(): Int = avcLevel(CodecProfileLevel.AVCProfileHigh10)
|
||||
|
||||
override fun supportsHevc(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_HEVC)
|
||||
|
||||
override fun supportsHevcMain10(): Boolean = hasDecoder(
|
||||
MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||
CodecProfileLevel.HEVCProfileMain10,
|
||||
CodecProfileLevel.HEVCMainTierLevel4,
|
||||
)
|
||||
|
||||
override fun supportsHevcDolbyVision(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||
hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION)
|
||||
|
||||
override fun supportsHevcDolbyVisionEl(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||
hasDecoder(
|
||||
MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION,
|
||||
DolbyVisionProfiles.profile7,
|
||||
CodecProfileLevel.DolbyVisionLevelHd24,
|
||||
) &&
|
||||
supportsMultiInstance(MediaFormat.MIMETYPE_VIDEO_HEVC)
|
||||
|
||||
override fun supportsHevcHdr10(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||
hasDecoder(
|
||||
MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||
CodecProfileLevel.HEVCProfileMain10HDR10,
|
||||
CodecProfileLevel.HEVCMainTierLevel4,
|
||||
)
|
||||
|
||||
override fun supportsHevcHdr10Plus(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
hasDecoder(
|
||||
MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||
CodecProfileLevel.HEVCProfileMain10HDR10Plus,
|
||||
CodecProfileLevel.HEVCMainTierLevel4,
|
||||
)
|
||||
|
||||
override fun hevcMainLevel(): Int = hevcLevel(CodecProfileLevel.HEVCProfileMain)
|
||||
|
||||
override fun hevcMain10Level(): Int = hevcLevel(CodecProfileLevel.HEVCProfileMain10)
|
||||
|
||||
override fun supportsVc1(): Boolean = hasCodecForMime(MimeTypes.VIDEO_VC1)
|
||||
|
||||
override fun maxResolution(mimeType: String): ProfileResolution {
|
||||
var maxWidth = 0
|
||||
var maxHeight = 0
|
||||
|
||||
for (info in mediaCodecList.codecInfos) {
|
||||
if (info.isEncoder) continue
|
||||
|
||||
try {
|
||||
val videoCapabilities = info.getCapabilitiesForType(mimeType).videoCapabilities ?: continue
|
||||
val supportedWidth = videoCapabilities.supportedWidths?.upper ?: continue
|
||||
val supportedHeight = videoCapabilities.supportedHeights?.upper ?: continue
|
||||
|
||||
maxWidth = maxOf(maxWidth, supportedWidth)
|
||||
maxHeight = maxOf(maxHeight, supportedHeight)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// Codec not supported.
|
||||
}
|
||||
}
|
||||
|
||||
return ProfileResolution(maxWidth, maxHeight)
|
||||
}
|
||||
|
||||
override fun supportsVideoCodec(codec: String): Boolean = codecCatalog.supportedVideoCodecs.contains(codec)
|
||||
|
||||
override fun supportsAudioCodec(codec: String): Boolean = codecCatalog.supportedAudioCodecs.contains(codec)
|
||||
|
||||
override fun videoCodecProfiles(codec: String): Set<String> = codecCatalog.videoCodecProfiles[codec].orEmpty()
|
||||
|
||||
override val hevcDoviHdr10PlusBug: Boolean = Build.MODEL in modelsWithDoviHdr10PlusBug
|
||||
|
||||
private fun avcLevel(profile: Int): Int {
|
||||
val decoderLevel = decoderLevel(MediaFormat.MIMETYPE_VIDEO_AVC, profile)
|
||||
return avcLevels.asReversed().find { decoderLevel >= it.first }?.second ?: 0
|
||||
}
|
||||
|
||||
private fun hevcLevel(profile: Int): Int {
|
||||
val decoderLevel = decoderLevel(MediaFormat.MIMETYPE_VIDEO_HEVC, profile)
|
||||
return hevcLevels.asReversed().find { decoderLevel >= it.first }?.second ?: 0
|
||||
}
|
||||
|
||||
private fun decoderLevel(mimeType: String, profile: Int): Int {
|
||||
var maxLevel = 0
|
||||
for (info in mediaCodecList.codecInfos) {
|
||||
if (info.isEncoder) continue
|
||||
|
||||
try {
|
||||
val capabilities = info.getCapabilitiesForType(mimeType)
|
||||
for (profileLevel in capabilities.profileLevels) {
|
||||
if (profileLevel.profile == profile) {
|
||||
maxLevel = maxOf(maxLevel, profileLevel.level)
|
||||
}
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// Codec not supported.
|
||||
}
|
||||
}
|
||||
return maxLevel
|
||||
}
|
||||
|
||||
private fun hasDecoder(mimeType: String, profile: Int, level: Int): Boolean {
|
||||
for (info in mediaCodecList.codecInfos) {
|
||||
if (info.isEncoder) continue
|
||||
|
||||
try {
|
||||
val capabilities = info.getCapabilitiesForType(mimeType)
|
||||
for (profileLevel in capabilities.profileLevels) {
|
||||
if (profileLevel.profile != profile) continue
|
||||
if (profileLevel.level >= level) return true
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// Codec not supported.
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun hasCodecForMime(mimeType: String): Boolean {
|
||||
for (info in mediaCodecList.codecInfos) {
|
||||
if (info.isEncoder) continue
|
||||
if (info.supportedTypes.any { it.equals(mimeType, ignoreCase = true) }) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun supportsMultiInstance(mimeType: String): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false
|
||||
|
||||
for (info in mediaCodecList.codecInfos) {
|
||||
if (info.isEncoder) continue
|
||||
|
||||
try {
|
||||
if (!info.supportedTypes.contains(mimeType)) continue
|
||||
if (info.getCapabilitiesForType(mimeType).maxSupportedInstances > 1) {
|
||||
return true
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// Codec not supported.
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private val modelsWithDoviHdr10PlusBug = setOf(
|
||||
"AFTKRT",
|
||||
"AFTKA",
|
||||
"AFTKM",
|
||||
)
|
||||
@@ -1,65 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import android.media.MediaCodecList
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Helper to debug available audio/video codecs on the device.
|
||||
*/
|
||||
object CodecDebugHelper {
|
||||
|
||||
private const val TAG = "CodecDebug"
|
||||
|
||||
/**
|
||||
* Logs all available decoders on this device.
|
||||
* Call this to understand what your device can actually decode.
|
||||
*/
|
||||
fun logAvailableDecoders() {
|
||||
try {
|
||||
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
|
||||
Log.d(TAG, "=== Available Audio Decoders ===")
|
||||
|
||||
codecList.codecInfos
|
||||
.filter { !it.isEncoder }
|
||||
.forEach { codecInfo ->
|
||||
codecInfo.supportedTypes.forEach { mimeType ->
|
||||
if (mimeType.startsWith("audio/")) {
|
||||
Log.d(TAG, "${codecInfo.name}: $mimeType")
|
||||
if (mimeType.contains("dts", ignoreCase = true) ||
|
||||
mimeType.contains("truehd", ignoreCase = true)) {
|
||||
Log.w(TAG, " ^^^ DTS/TrueHD decoder found! ^^^")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "=== Available Video Decoders ===")
|
||||
codecList.codecInfos
|
||||
.filter { !it.isEncoder }
|
||||
.forEach { codecInfo ->
|
||||
codecInfo.supportedTypes.forEach { mimeType ->
|
||||
if (mimeType.startsWith("video/")) {
|
||||
Log.d(TAG, "${codecInfo.name}: $mimeType")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to list codecs", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific MIME type has a decoder available.
|
||||
*/
|
||||
fun hasDecoderFor(mimeType: String): Boolean {
|
||||
return try {
|
||||
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
|
||||
codecList.codecInfos.any { codecInfo ->
|
||||
!codecInfo.isEncoder &&
|
||||
codecInfo.supportedTypes.any { it.equals(mimeType, ignoreCase = true) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import org.jellyfin.sdk.model.api.CodecProfile
|
||||
import org.jellyfin.sdk.model.api.CodecType
|
||||
import org.jellyfin.sdk.model.api.ContainerProfile
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.DirectPlayProfile
|
||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||
import org.jellyfin.sdk.model.api.ProfileCondition
|
||||
import org.jellyfin.sdk.model.api.ProfileConditionType
|
||||
import org.jellyfin.sdk.model.api.ProfileConditionValue
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import org.jellyfin.sdk.model.api.SubtitleProfile
|
||||
import org.jellyfin.sdk.model.api.TranscodingProfile
|
||||
|
||||
internal data class JellyfinAndroidMobileProfileConfig(
|
||||
val assDirectPlay: Boolean = false,
|
||||
)
|
||||
|
||||
internal object JellyfinAndroidMobileDeviceProfile {
|
||||
val fixedConfig = JellyfinAndroidMobileProfileConfig()
|
||||
|
||||
private const val profileName = "Purefin"
|
||||
private const val maxStreamingBitrate = 120_000_000
|
||||
private const val maxStaticBitrate = 100_000_000
|
||||
private const val maxMusicTranscodingBitrate = 384_000
|
||||
|
||||
private val supportedContainerFormats = arrayOf(
|
||||
"mp4", "fmp4", "webm", "mkv", "mp3", "ogg", "wav", "mpegts", "flv", "aac", "flac", "3gp",
|
||||
)
|
||||
|
||||
private val availableVideoCodecs = arrayOf(
|
||||
arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp9"),
|
||||
arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp9"),
|
||||
arrayOf("vp8", "vp9", "av1"),
|
||||
arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp8", "vp9"),
|
||||
emptyArray(),
|
||||
emptyArray(),
|
||||
emptyArray(),
|
||||
arrayOf("mpeg1video", "mpeg2video", "mpeg4", "h264", "hevc"),
|
||||
arrayOf("mpeg4", "h264"),
|
||||
emptyArray(),
|
||||
emptyArray(),
|
||||
arrayOf("h263", "mpeg4", "h264", "hevc"),
|
||||
)
|
||||
|
||||
private val pcmCodecs = arrayOf(
|
||||
"pcm_s8",
|
||||
"pcm_s16be",
|
||||
"pcm_s16le",
|
||||
"pcm_s24le",
|
||||
"pcm_s32le",
|
||||
"pcm_f32le",
|
||||
"pcm_alaw",
|
||||
"pcm_mulaw",
|
||||
)
|
||||
|
||||
private val availableAudioCodecs = arrayOf(
|
||||
arrayOf("mp1", "mp2", "mp3", "aac", "alac", "ac3", "opus"),
|
||||
arrayOf("mp3", "aac", "ac3", "eac3"),
|
||||
arrayOf("vorbis", "opus"),
|
||||
arrayOf(*pcmCodecs, "mp1", "mp2", "mp3", "aac", "vorbis", "opus", "flac", "alac", "ac3", "eac3", "dts", "mlp", "truehd"),
|
||||
arrayOf("mp3"),
|
||||
arrayOf("vorbis", "opus", "flac"),
|
||||
pcmCodecs,
|
||||
arrayOf(*pcmCodecs, "mp1", "mp2", "mp3", "aac", "ac3", "eac3", "dts", "mlp", "truehd"),
|
||||
arrayOf("mp3", "aac"),
|
||||
arrayOf("aac"),
|
||||
arrayOf("flac"),
|
||||
arrayOf("3gpp", "aac", "flac"),
|
||||
)
|
||||
|
||||
private val forcedAudioCodecs = setOf(*pcmCodecs, "alac", "aac", "ac3", "eac3", "dts", "mlp", "truehd")
|
||||
|
||||
private val exoEmbeddedSubtitles = arrayOf("dvbsub", "pgssub", "srt", "subrip", "ttml")
|
||||
private val exoExternalSubtitles = arrayOf("srt", "subrip", "ttml", "vtt", "webvtt")
|
||||
private val ssaSubtitles = arrayOf("ssa", "ass")
|
||||
|
||||
private val transcodingProfiles = listOf(
|
||||
TranscodingProfile(
|
||||
type = DlnaProfileType.VIDEO,
|
||||
container = "ts",
|
||||
videoCodec = "h264",
|
||||
audioCodec = "mp1,mp2,mp3,aac,ac3,eac3,dts,mlp,truehd",
|
||||
protocol = MediaStreamProtocol.HLS,
|
||||
conditions = emptyList(),
|
||||
),
|
||||
TranscodingProfile(
|
||||
type = DlnaProfileType.VIDEO,
|
||||
container = "mkv",
|
||||
videoCodec = "h264",
|
||||
audioCodec = availableAudioCodecs[supportedContainerFormats.indexOf("mkv")].joinToString(","),
|
||||
protocol = MediaStreamProtocol.HLS,
|
||||
conditions = emptyList(),
|
||||
),
|
||||
TranscodingProfile(
|
||||
type = DlnaProfileType.AUDIO,
|
||||
container = "mp3",
|
||||
videoCodec = "",
|
||||
audioCodec = "mp3",
|
||||
protocol = MediaStreamProtocol.HTTP,
|
||||
conditions = emptyList(),
|
||||
),
|
||||
)
|
||||
|
||||
fun create(
|
||||
capabilities: DeviceProfileCapabilities,
|
||||
config: JellyfinAndroidMobileProfileConfig = fixedConfig,
|
||||
): DeviceProfile {
|
||||
val containerProfiles = mutableListOf<ContainerProfile>()
|
||||
val directPlayProfiles = mutableListOf<DirectPlayProfile>()
|
||||
val codecProfiles = mutableListOf<CodecProfile>()
|
||||
|
||||
for (i in supportedContainerFormats.indices) {
|
||||
val container = supportedContainerFormats[i]
|
||||
val supportedVideoCodecs = availableVideoCodecs[i]
|
||||
.filter(capabilities::supportsVideoCodec)
|
||||
.toTypedArray()
|
||||
val supportedAudioCodecs = availableAudioCodecs[i]
|
||||
.filter { capabilities.supportsAudioCodec(it) || it in forcedAudioCodecs }
|
||||
.toTypedArray()
|
||||
|
||||
if (supportedVideoCodecs.isNotEmpty()) {
|
||||
containerProfiles += ContainerProfile(
|
||||
type = DlnaProfileType.VIDEO,
|
||||
container = container,
|
||||
conditions = emptyList(),
|
||||
)
|
||||
directPlayProfiles += DirectPlayProfile(
|
||||
type = DlnaProfileType.VIDEO,
|
||||
container = container,
|
||||
videoCodec = supportedVideoCodecs.joinToString(","),
|
||||
audioCodec = supportedAudioCodecs.joinToString(","),
|
||||
)
|
||||
supportedVideoCodecs.mapNotNullTo(codecProfiles) { videoCodec ->
|
||||
generateCodecProfile(container, videoCodec, capabilities.videoCodecProfiles(videoCodec))
|
||||
}
|
||||
}
|
||||
|
||||
if (supportedAudioCodecs.isNotEmpty()) {
|
||||
containerProfiles += ContainerProfile(
|
||||
type = DlnaProfileType.AUDIO,
|
||||
container = container,
|
||||
conditions = emptyList(),
|
||||
)
|
||||
directPlayProfiles += DirectPlayProfile(
|
||||
type = DlnaProfileType.AUDIO,
|
||||
container = container,
|
||||
audioCodec = supportedAudioCodecs.joinToString(","),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val subtitleProfiles = when {
|
||||
config.assDirectPlay -> subtitleProfiles(
|
||||
embedded = exoEmbeddedSubtitles + ssaSubtitles,
|
||||
external = exoExternalSubtitles + ssaSubtitles,
|
||||
)
|
||||
else -> subtitleProfiles(
|
||||
embedded = exoEmbeddedSubtitles,
|
||||
external = exoExternalSubtitles,
|
||||
)
|
||||
}
|
||||
|
||||
return DeviceProfile(
|
||||
name = profileName,
|
||||
directPlayProfiles = directPlayProfiles,
|
||||
transcodingProfiles = transcodingProfiles,
|
||||
containerProfiles = containerProfiles,
|
||||
codecProfiles = codecProfiles,
|
||||
subtitleProfiles = subtitleProfiles,
|
||||
maxStreamingBitrate = maxStreamingBitrate,
|
||||
maxStaticBitrate = maxStaticBitrate,
|
||||
musicStreamingTranscodingBitrate = maxMusicTranscodingBitrate,
|
||||
)
|
||||
}
|
||||
|
||||
private fun generateCodecProfile(
|
||||
container: String,
|
||||
videoCodec: String,
|
||||
profiles: Set<String>,
|
||||
): CodecProfile? {
|
||||
if (profiles.isEmpty()) return null
|
||||
|
||||
return CodecProfile(
|
||||
type = CodecType.VIDEO,
|
||||
container = container,
|
||||
codec = videoCodec,
|
||||
applyConditions = emptyList(),
|
||||
conditions = listOf(
|
||||
ProfileCondition(
|
||||
condition = ProfileConditionType.EQUALS_ANY,
|
||||
property = ProfileConditionValue.VIDEO_PROFILE,
|
||||
value = profiles.joinToString("|"),
|
||||
isRequired = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun subtitleProfiles(
|
||||
embedded: Array<String>,
|
||||
external: Array<String>,
|
||||
): List<SubtitleProfile> = buildList {
|
||||
embedded.forEach { format ->
|
||||
add(SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EMBED))
|
||||
}
|
||||
external.forEach { format ->
|
||||
add(SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EXTERNAL))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,544 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import androidx.media3.common.MimeTypes
|
||||
import org.jellyfin.sdk.model.ServerVersion
|
||||
import org.jellyfin.sdk.model.api.CodecType
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||
import org.jellyfin.sdk.model.api.EncodingContext
|
||||
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||
import org.jellyfin.sdk.model.api.ProfileConditionValue
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||
import org.jellyfin.sdk.model.deviceprofile.DeviceProfileBuilder
|
||||
import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile
|
||||
|
||||
internal data class JellyfinAndroidTvProfileConfig(
|
||||
val maxBitrate: Int = 100_000_000,
|
||||
val isAc3Enabled: Boolean = true,
|
||||
val downMixAudio: Boolean = false,
|
||||
val assDirectPlay: Boolean = true,
|
||||
val pgsDirectPlay: Boolean = true,
|
||||
)
|
||||
|
||||
internal object JellyfinAndroidTvDeviceProfile {
|
||||
val fixedConfig = JellyfinAndroidTvProfileConfig()
|
||||
private val extendedRangeTypesServerVersion = ServerVersion(10, 11, 0)
|
||||
|
||||
private val downmixSupportedAudioCodecs = arrayOf(
|
||||
Codec.Audio.AAC,
|
||||
Codec.Audio.MP2,
|
||||
Codec.Audio.MP3,
|
||||
)
|
||||
|
||||
private val supportedAudioCodecs = arrayOf(
|
||||
Codec.Audio.AAC,
|
||||
Codec.Audio.AAC_LATM,
|
||||
Codec.Audio.AC3,
|
||||
Codec.Audio.ALAC,
|
||||
Codec.Audio.DCA,
|
||||
Codec.Audio.DTS,
|
||||
Codec.Audio.EAC3,
|
||||
Codec.Audio.FLAC,
|
||||
Codec.Audio.MLP,
|
||||
Codec.Audio.MP2,
|
||||
Codec.Audio.MP3,
|
||||
Codec.Audio.OPUS,
|
||||
Codec.Audio.PCM_ALAW,
|
||||
Codec.Audio.PCM_MULAW,
|
||||
Codec.Audio.PCM_S16LE,
|
||||
Codec.Audio.PCM_S20LE,
|
||||
Codec.Audio.PCM_S24LE,
|
||||
Codec.Audio.TRUEHD,
|
||||
Codec.Audio.VORBIS,
|
||||
)
|
||||
|
||||
private val hlsMpegTsAudioCodecs = arrayOf(
|
||||
Codec.Audio.AAC,
|
||||
Codec.Audio.AC3,
|
||||
Codec.Audio.EAC3,
|
||||
Codec.Audio.MP3,
|
||||
)
|
||||
|
||||
private val hlsFmp4AudioCodecs = arrayOf(
|
||||
Codec.Audio.AAC,
|
||||
Codec.Audio.AC3,
|
||||
Codec.Audio.EAC3,
|
||||
Codec.Audio.MP3,
|
||||
Codec.Audio.ALAC,
|
||||
Codec.Audio.FLAC,
|
||||
Codec.Audio.OPUS,
|
||||
Codec.Audio.DTS,
|
||||
Codec.Audio.TRUEHD,
|
||||
)
|
||||
|
||||
fun create(
|
||||
capabilities: DeviceProfileCapabilities,
|
||||
serverVersion: ServerVersion,
|
||||
config: JellyfinAndroidTvProfileConfig = fixedConfig,
|
||||
): DeviceProfile = buildDeviceProfile {
|
||||
val allowedAudioCodecs = when {
|
||||
config.downMixAudio -> downmixSupportedAudioCodecs
|
||||
!config.isAc3Enabled -> supportedAudioCodecs
|
||||
.filterNot { it == Codec.Audio.AC3 || it == Codec.Audio.EAC3 }
|
||||
.toTypedArray()
|
||||
else -> supportedAudioCodecs
|
||||
}
|
||||
|
||||
val supportsHevc = capabilities.supportsHevc()
|
||||
val supportsHevcMain10 = capabilities.supportsHevcMain10()
|
||||
val hevcMainLevel = capabilities.hevcMainLevel()
|
||||
val hevcMain10Level = capabilities.hevcMain10Level()
|
||||
val supportsAvc = capabilities.supportsAvc()
|
||||
val supportsAvcHigh10 = capabilities.supportsAvcHigh10()
|
||||
val avcMainLevel = capabilities.avcMainLevel()
|
||||
val avcHigh10Level = capabilities.avcHigh10Level()
|
||||
val supportsAv1 = capabilities.supportsAv1()
|
||||
val supportsAv1Main10 = capabilities.supportsAv1Main10()
|
||||
val supportsVc1 = capabilities.supportsVc1()
|
||||
val maxResolutionAvc = capabilities.maxResolution(MimeTypes.VIDEO_H264)
|
||||
val maxResolutionHevc = capabilities.maxResolution(MimeTypes.VIDEO_H265)
|
||||
val maxResolutionAv1 = capabilities.maxResolution(MimeTypes.VIDEO_AV1)
|
||||
val maxResolutionVc1 = capabilities.maxResolution(MimeTypes.VIDEO_VC1)
|
||||
|
||||
val supportsAv1DolbyVision = capabilities.supportsAv1DolbyVision()
|
||||
val supportsAv1Hdr10 = capabilities.supportsAv1Hdr10()
|
||||
val supportsAv1Hdr10Plus = capabilities.supportsAv1Hdr10Plus()
|
||||
val supportsHevcDolbyVision = capabilities.supportsHevcDolbyVision()
|
||||
val supportsHevcDolbyVisionEl = capabilities.supportsHevcDolbyVisionEl()
|
||||
val supportsHevcHdr10 = capabilities.supportsHevcHdr10()
|
||||
val supportsHevcHdr10Plus = capabilities.supportsHevcHdr10Plus()
|
||||
val supportsExtendedRangeTypes = serverVersion >= extendedRangeTypesServerVersion
|
||||
|
||||
name = "AndroidTV-Default"
|
||||
maxStaticBitrate = config.maxBitrate
|
||||
maxStreamingBitrate = config.maxBitrate
|
||||
|
||||
val hlsVideoCodecs = listOfNotNull(
|
||||
if (supportsHevc) Codec.Video.HEVC else null,
|
||||
Codec.Video.H264,
|
||||
).toTypedArray()
|
||||
|
||||
transcodingProfile {
|
||||
type = DlnaProfileType.VIDEO
|
||||
context = EncodingContext.STREAMING
|
||||
container = Codec.Container.TS
|
||||
protocol = MediaStreamProtocol.HLS
|
||||
videoCodec(*hlsVideoCodecs)
|
||||
audioCodec(*hlsMpegTsAudioCodecs.filter(allowedAudioCodecs::contains).toTypedArray())
|
||||
copyTimestamps = false
|
||||
enableSubtitlesInManifest = true
|
||||
}
|
||||
|
||||
transcodingProfile {
|
||||
type = DlnaProfileType.VIDEO
|
||||
context = EncodingContext.STREAMING
|
||||
container = Codec.Container.MP4
|
||||
protocol = MediaStreamProtocol.HLS
|
||||
videoCodec(*hlsVideoCodecs)
|
||||
audioCodec(*hlsFmp4AudioCodecs.filter(allowedAudioCodecs::contains).toTypedArray())
|
||||
copyTimestamps = false
|
||||
enableSubtitlesInManifest = true
|
||||
}
|
||||
|
||||
transcodingProfile {
|
||||
type = DlnaProfileType.AUDIO
|
||||
context = EncodingContext.STREAMING
|
||||
container = Codec.Container.TS
|
||||
protocol = MediaStreamProtocol.HLS
|
||||
audioCodec(Codec.Audio.AAC)
|
||||
}
|
||||
|
||||
directPlayProfile {
|
||||
type = DlnaProfileType.VIDEO
|
||||
container(
|
||||
Codec.Container.ASF,
|
||||
Codec.Container.HLS,
|
||||
Codec.Container.M4V,
|
||||
Codec.Container.MKV,
|
||||
Codec.Container.MOV,
|
||||
Codec.Container.MP4,
|
||||
Codec.Container.OGM,
|
||||
Codec.Container.OGV,
|
||||
Codec.Container.TS,
|
||||
Codec.Container.VOB,
|
||||
Codec.Container.WEBM,
|
||||
Codec.Container.WMV,
|
||||
Codec.Container.XVID,
|
||||
)
|
||||
videoCodec(
|
||||
Codec.Video.AV1,
|
||||
Codec.Video.H264,
|
||||
Codec.Video.HEVC,
|
||||
Codec.Video.MPEG,
|
||||
Codec.Video.MPEG2VIDEO,
|
||||
Codec.Video.VC1,
|
||||
Codec.Video.VP8,
|
||||
Codec.Video.VP9,
|
||||
)
|
||||
audioCodec(*allowedAudioCodecs)
|
||||
}
|
||||
|
||||
directPlayProfile {
|
||||
type = DlnaProfileType.AUDIO
|
||||
audioCodec(*allowedAudioCodecs)
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.H264
|
||||
conditions {
|
||||
when {
|
||||
!supportsAvc -> ProfileConditionValue.VIDEO_PROFILE equals "none"
|
||||
else -> ProfileConditionValue.VIDEO_PROFILE inCollection listOfNotNull(
|
||||
"high",
|
||||
"main",
|
||||
"baseline",
|
||||
"constrained baseline",
|
||||
if (supportsAvcHigh10) "high 10" else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (supportsAvc) {
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.H264
|
||||
conditions {
|
||||
ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals avcMainLevel
|
||||
}
|
||||
applyConditions {
|
||||
ProfileConditionValue.VIDEO_PROFILE inCollection listOf(
|
||||
"high",
|
||||
"main",
|
||||
"baseline",
|
||||
"constrained baseline",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (supportsAvcHigh10) {
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.H264
|
||||
conditions {
|
||||
ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals avcHigh10Level
|
||||
}
|
||||
applyConditions {
|
||||
ProfileConditionValue.VIDEO_PROFILE equals "high 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.H264
|
||||
conditions {
|
||||
ProfileConditionValue.REF_FRAMES lowerThanOrEquals 12
|
||||
}
|
||||
applyConditions {
|
||||
ProfileConditionValue.WIDTH greaterThanOrEquals 1200
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.H264
|
||||
conditions {
|
||||
ProfileConditionValue.REF_FRAMES lowerThanOrEquals 4
|
||||
}
|
||||
applyConditions {
|
||||
ProfileConditionValue.WIDTH greaterThanOrEquals 1900
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.HEVC
|
||||
conditions {
|
||||
when {
|
||||
!supportsHevc -> ProfileConditionValue.VIDEO_PROFILE equals "none"
|
||||
else -> ProfileConditionValue.VIDEO_PROFILE inCollection listOfNotNull(
|
||||
"main",
|
||||
if (supportsHevcMain10) "main 10" else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (supportsHevc) {
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.HEVC
|
||||
conditions {
|
||||
ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals hevcMainLevel
|
||||
}
|
||||
applyConditions {
|
||||
ProfileConditionValue.VIDEO_PROFILE equals "main"
|
||||
}
|
||||
}
|
||||
}
|
||||
if (supportsHevcMain10) {
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.HEVC
|
||||
conditions {
|
||||
ProfileConditionValue.VIDEO_LEVEL lowerThanOrEquals hevcMain10Level
|
||||
}
|
||||
applyConditions {
|
||||
ProfileConditionValue.VIDEO_PROFILE equals "main 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.AV1
|
||||
conditions {
|
||||
when {
|
||||
!supportsAv1 -> ProfileConditionValue.VIDEO_PROFILE equals "none"
|
||||
!supportsAv1Main10 -> ProfileConditionValue.VIDEO_PROFILE notEquals "main 10"
|
||||
else -> ProfileConditionValue.VIDEO_PROFILE notEquals "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.VC1
|
||||
conditions {
|
||||
when {
|
||||
!supportsVc1 -> ProfileConditionValue.VIDEO_PROFILE equals "none"
|
||||
else -> ProfileConditionValue.VIDEO_PROFILE notEquals "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.H264
|
||||
conditions {
|
||||
ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAvc.width
|
||||
ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAvc.height
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.HEVC
|
||||
conditions {
|
||||
ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionHevc.width
|
||||
ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionHevc.height
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.AV1
|
||||
conditions {
|
||||
ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAv1.width
|
||||
ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAv1.height
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.VC1
|
||||
conditions {
|
||||
ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionVc1.width
|
||||
ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionVc1.height
|
||||
}
|
||||
}
|
||||
|
||||
val unsupportedRangeTypesAv1 = buildSet {
|
||||
addRangeTypeIfSupported(VideoRangeType.DOVI_INVALID, supportsExtendedRangeTypes)
|
||||
if (!supportsAv1DolbyVision) {
|
||||
add(VideoRangeType.DOVI)
|
||||
if (!supportsAv1Hdr10) add(VideoRangeType.DOVI_WITH_HDR10)
|
||||
if (!supportsAv1Hdr10Plus) addRangeTypeIfSupported(
|
||||
VideoRangeType.DOVI_WITH_HDR10_PLUS,
|
||||
supportsExtendedRangeTypes,
|
||||
)
|
||||
}
|
||||
if (!supportsAv1Hdr10Plus) {
|
||||
add(VideoRangeType.HDR10_PLUS)
|
||||
if (!supportsAv1Hdr10) add(VideoRangeType.HDR10)
|
||||
}
|
||||
}
|
||||
|
||||
val unsupportedRangeTypesHevc = buildSet {
|
||||
addRangeTypeIfSupported(VideoRangeType.DOVI_INVALID, supportsExtendedRangeTypes)
|
||||
if (!supportsHevcDolbyVisionEl) {
|
||||
addRangeTypeIfSupported(VideoRangeType.DOVI_WITH_EL, supportsExtendedRangeTypes)
|
||||
if (!supportsHevcHdr10Plus && !capabilities.hevcDoviHdr10PlusBug) {
|
||||
addRangeTypeIfSupported(
|
||||
VideoRangeType.DOVI_WITH_ELHDR10_PLUS,
|
||||
supportsExtendedRangeTypes,
|
||||
)
|
||||
}
|
||||
|
||||
if (!supportsHevcDolbyVision) {
|
||||
add(VideoRangeType.DOVI)
|
||||
if (!supportsHevcHdr10) add(VideoRangeType.DOVI_WITH_HDR10)
|
||||
if (!supportsHevcHdr10Plus && !capabilities.hevcDoviHdr10PlusBug) {
|
||||
addRangeTypeIfSupported(
|
||||
VideoRangeType.DOVI_WITH_HDR10_PLUS,
|
||||
supportsExtendedRangeTypes,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!supportsHevcHdr10Plus) {
|
||||
add(VideoRangeType.HDR10_PLUS)
|
||||
if (!supportsHevcHdr10) add(VideoRangeType.HDR10)
|
||||
}
|
||||
|
||||
if (capabilities.hevcDoviHdr10PlusBug) {
|
||||
addRangeTypeIfSupported(VideoRangeType.DOVI_WITH_HDR10_PLUS, supportsExtendedRangeTypes)
|
||||
addRangeTypeIfSupported(VideoRangeType.DOVI_WITH_ELHDR10_PLUS, supportsExtendedRangeTypes)
|
||||
}
|
||||
}
|
||||
|
||||
if (unsupportedRangeTypesAv1.isNotEmpty()) {
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.AV1
|
||||
conditions {
|
||||
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals unsupportedRangeTypesAv1.joinToString("|") { it.serialName }
|
||||
}
|
||||
applyConditions {
|
||||
ProfileConditionValue.VIDEO_RANGE_TYPE inCollection unsupportedRangeTypesAv1.map { it.serialName }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unsupportedRangeTypesHevc.isNotEmpty()) {
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.HEVC
|
||||
conditions {
|
||||
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals unsupportedRangeTypesHevc.joinToString("|") { it.serialName }
|
||||
}
|
||||
applyConditions {
|
||||
ProfileConditionValue.VIDEO_RANGE_TYPE inCollection unsupportedRangeTypesHevc.map { it.serialName }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO_AUDIO
|
||||
conditions {
|
||||
ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals if (config.downMixAudio) 2 else 8
|
||||
}
|
||||
}
|
||||
|
||||
subtitleProfile(Codec.Subtitle.VTT, embedded = true, hls = true, external = true)
|
||||
subtitleProfile(Codec.Subtitle.WEBVTT, embedded = true, hls = true, external = true)
|
||||
subtitleProfile(Codec.Subtitle.SRT, embedded = true, external = true)
|
||||
subtitleProfile(Codec.Subtitle.SUBRIP, embedded = true, external = true)
|
||||
subtitleProfile(Codec.Subtitle.TTML, embedded = true, external = true)
|
||||
subtitleProfile(Codec.Subtitle.DVBSUB, embedded = true, encode = true)
|
||||
subtitleProfile(Codec.Subtitle.DVDSUB, embedded = true, encode = true)
|
||||
subtitleProfile(Codec.Subtitle.IDX, embedded = true, encode = true)
|
||||
subtitleProfile(Codec.Subtitle.PGS, embedded = config.pgsDirectPlay, encode = true)
|
||||
subtitleProfile(Codec.Subtitle.PGSSUB, embedded = config.pgsDirectPlay, encode = true)
|
||||
subtitleProfile(
|
||||
Codec.Subtitle.ASS,
|
||||
encode = true,
|
||||
embedded = config.assDirectPlay,
|
||||
external = config.assDirectPlay,
|
||||
)
|
||||
subtitleProfile(
|
||||
Codec.Subtitle.SSA,
|
||||
encode = true,
|
||||
embedded = config.assDirectPlay,
|
||||
external = config.assDirectPlay,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableSet<VideoRangeType>.addRangeTypeIfSupported(
|
||||
type: VideoRangeType,
|
||||
supportedByServer: Boolean,
|
||||
) {
|
||||
if (supportedByServer) add(type)
|
||||
}
|
||||
|
||||
private fun DeviceProfileBuilder.subtitleProfile(
|
||||
format: String,
|
||||
embedded: Boolean = false,
|
||||
external: Boolean = false,
|
||||
hls: Boolean = false,
|
||||
encode: Boolean = false,
|
||||
) {
|
||||
if (embedded) subtitleProfile(format, SubtitleDeliveryMethod.EMBED)
|
||||
if (external) subtitleProfile(format, SubtitleDeliveryMethod.EXTERNAL)
|
||||
if (hls) subtitleProfile(format, SubtitleDeliveryMethod.HLS)
|
||||
if (encode) subtitleProfile(format, SubtitleDeliveryMethod.ENCODE)
|
||||
}
|
||||
|
||||
private object Codec {
|
||||
object Container {
|
||||
const val ASF = "asf"
|
||||
const val HLS = "hls"
|
||||
const val M4V = "m4v"
|
||||
const val MKV = "mkv"
|
||||
const val MOV = "mov"
|
||||
const val MP4 = "mp4"
|
||||
const val OGM = "ogm"
|
||||
const val OGV = "ogv"
|
||||
const val TS = "ts"
|
||||
const val VOB = "vob"
|
||||
const val WEBM = "webm"
|
||||
const val WMV = "wmv"
|
||||
const val XVID = "xvid"
|
||||
}
|
||||
|
||||
object Audio {
|
||||
const val AAC = "aac"
|
||||
const val AAC_LATM = "aac_latm"
|
||||
const val AC3 = "ac3"
|
||||
const val ALAC = "alac"
|
||||
const val DCA = "dca"
|
||||
const val DTS = "dts"
|
||||
const val EAC3 = "eac3"
|
||||
const val FLAC = "flac"
|
||||
const val MLP = "mlp"
|
||||
const val MP2 = "mp2"
|
||||
const val MP3 = "mp3"
|
||||
const val OPUS = "opus"
|
||||
const val PCM_ALAW = "pcm_alaw"
|
||||
const val PCM_MULAW = "pcm_mulaw"
|
||||
const val PCM_S16LE = "pcm_s16le"
|
||||
const val PCM_S20LE = "pcm_s20le"
|
||||
const val PCM_S24LE = "pcm_s24le"
|
||||
const val TRUEHD = "truehd"
|
||||
const val VORBIS = "vorbis"
|
||||
}
|
||||
|
||||
object Video {
|
||||
const val AV1 = "av1"
|
||||
const val H264 = "h264"
|
||||
const val HEVC = "hevc"
|
||||
const val MPEG = "mpeg"
|
||||
const val MPEG2VIDEO = "mpeg2video"
|
||||
const val VC1 = "vc1"
|
||||
const val VP8 = "vp8"
|
||||
const val VP9 = "vp9"
|
||||
}
|
||||
|
||||
object Subtitle {
|
||||
const val ASS = "ass"
|
||||
const val DVBSUB = "dvbsub"
|
||||
const val DVDSUB = "dvdsub"
|
||||
const val IDX = "idx"
|
||||
const val PGS = "pgs"
|
||||
const val PGSSUB = "pgssub"
|
||||
const val SRT = "srt"
|
||||
const val SSA = "ssa"
|
||||
const val SUBRIP = "subrip"
|
||||
const val TTML = "ttml"
|
||||
const val VTT = "vtt"
|
||||
const val WEBVTT = "webvtt"
|
||||
}
|
||||
}
|
||||
@@ -1,456 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.Response
|
||||
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||
import org.jellyfin.sdk.api.client.extensions.suggestionsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
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.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.ItemFields
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||
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.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 javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class JellyfinApiClient @Inject constructor(
|
||||
@ApplicationContext private val applicationContext: Context,
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val playbackProfilePolicy: PlaybackProfilePolicy,
|
||||
) {
|
||||
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 suspend fun getUserId(): UUID? = userSessionRepository.userId.first()
|
||||
|
||||
private suspend fun ensureConfigured(): Boolean {
|
||||
val serverUrl = userSessionRepository.serverUrl.first().trim()
|
||||
val accessToken = userSessionRepository.accessToken.first().trim()
|
||||
if (serverUrl.isBlank() || accessToken.isBlank()) {
|
||||
userSessionRepository.setLoggedIn(false)
|
||||
return false
|
||||
}
|
||||
api.update(baseUrl = serverUrl, accessToken = accessToken)
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun login(url: String, username: String, password: String): 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
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateApiClient() = withContext(Dispatchers.IO) {
|
||||
ensureConfigured()
|
||||
}
|
||||
|
||||
suspend fun getLibraries(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
val response = api.userViewsApi.getUserViews(
|
||||
userId = getUserId(),
|
||||
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
|
||||
includeHidden = false,
|
||||
)
|
||||
Log.d("getLibraries", response.content.toString())
|
||||
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()
|
||||
}
|
||||
val getItemsRequest = GetItemsRequest(
|
||||
userId = getUserId(),
|
||||
enableImages = true,
|
||||
parentId = libraryId,
|
||||
fields = itemFields,
|
||||
enableUserData = true,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
recursive = true,
|
||||
)
|
||||
val response = api.itemsApi.getItems(getItemsRequest)
|
||||
Log.d("getLibraryContent", response.content.toString())
|
||||
response.content.items
|
||||
}
|
||||
|
||||
suspend fun getSuggestions(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
val userId = getUserId()
|
||||
if (userId == null) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
val response = api.suggestionsApi.getSuggestions(
|
||||
userId = userId,
|
||||
mediaType = listOf(MediaType.VIDEO),
|
||||
type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
limit = 8,
|
||||
enableTotalRecordCount = true
|
||||
)
|
||||
Log.d("getSuggestions", response.content.toString())
|
||||
response.content.items
|
||||
}
|
||||
|
||||
suspend fun getContinueWatching(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
val userId = getUserId()
|
||||
if (userId == null) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
val getResumeItemsRequest = GetResumeItemsRequest(
|
||||
userId = userId,
|
||||
fields = itemFields,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
|
||||
enableUserData = true,
|
||||
startIndex = 0,
|
||||
)
|
||||
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
|
||||
Log.d("getContinueWatching", response.content.toString())
|
||||
response.content.items
|
||||
}
|
||||
|
||||
suspend fun getNextUpEpisodes(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) {
|
||||
throw IllegalStateException("Not configured")
|
||||
}
|
||||
val getNextUpRequest = GetNextUpRequest(
|
||||
userId = getUserId(),
|
||||
fields = itemFields,
|
||||
enableResumable = false,
|
||||
)
|
||||
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
|
||||
Log.d("getNextUpEpisodes", result.content.toString())
|
||||
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()
|
||||
}
|
||||
val response = api.userLibraryApi.getLatestMedia(
|
||||
userId = getUserId(),
|
||||
parentId = libraryId,
|
||||
fields = itemFields,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
|
||||
limit = 10
|
||||
)
|
||||
Log.d("getLatestFromLibrary", response.content.toString())
|
||||
response.content
|
||||
}
|
||||
|
||||
suspend fun getItemInfo(mediaId: UUID): BaseItemDto? = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) {
|
||||
return@withContext null
|
||||
}
|
||||
val result = api.userLibraryApi.getItem(
|
||||
itemId = mediaId,
|
||||
userId = getUserId()
|
||||
)
|
||||
Log.d("getItemInfo", result.content.toString())
|
||||
result.content
|
||||
}
|
||||
|
||||
suspend fun getSeasons(seriesId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
val result = api.tvShowsApi.getSeasons(
|
||||
userId = getUserId(),
|
||||
seriesId = seriesId,
|
||||
fields = itemFields,
|
||||
enableUserData = true
|
||||
)
|
||||
Log.d("getSeasons", result.content.toString())
|
||||
result.content.items
|
||||
}
|
||||
|
||||
suspend fun getEpisodesInSeason(seriesId: UUID, seasonId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
val result = api.tvShowsApi.getEpisodes(
|
||||
userId = getUserId(),
|
||||
seriesId = seriesId,
|
||||
seasonId = seasonId,
|
||||
fields = itemFields,
|
||||
enableUserData = true
|
||||
)
|
||||
Log.d("getEpisodesInSeason", result.content.toString())
|
||||
result.content.items
|
||||
}
|
||||
|
||||
suspend fun getNextEpisodes(episodeId: UUID, count: Int): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
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(
|
||||
userId = getUserId(),
|
||||
seriesId = seriesId,
|
||||
enableUserData = true,
|
||||
startItemId = episodeId,
|
||||
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
|
||||
}
|
||||
|
||||
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
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) {
|
||||
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(
|
||||
mediaId,
|
||||
PlaybackInfoDto(
|
||||
userId = getUserId(),
|
||||
deviceProfile = deviceProfile,
|
||||
enableDirectPlay = true,
|
||||
enableDirectStream = true,
|
||||
enableTranscoding = true,
|
||||
allowVideoStreamCopy = true,
|
||||
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
|
||||
}
|
||||
|
||||
suspend fun getMediaPlaybackUrl(mediaId: UUID, mediaSource: MediaSourceInfo): 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
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackStart(
|
||||
itemId: UUID,
|
||||
positionTicks: Long = 0L,
|
||||
reportContext: PlaybackReportContext,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) return@withContext
|
||||
api.playStateApi.reportPlaybackStart(
|
||||
PlaybackStartInfo(
|
||||
itemId = itemId,
|
||||
positionTicks = positionTicks,
|
||||
canSeek = true,
|
||||
isPaused = false,
|
||||
isMuted = false,
|
||||
mediaSourceId = reportContext.mediaSourceId,
|
||||
audioStreamIndex = reportContext.audioStreamIndex,
|
||||
subtitleStreamIndex = reportContext.subtitleStreamIndex,
|
||||
liveStreamId = reportContext.liveStreamId,
|
||||
playSessionId = reportContext.playSessionId,
|
||||
playMethod = reportContext.playMethod,
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackProgress(
|
||||
itemId: UUID,
|
||||
positionTicks: Long,
|
||||
isPaused: Boolean,
|
||||
reportContext: PlaybackReportContext,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) return@withContext
|
||||
api.playStateApi.reportPlaybackProgress(
|
||||
PlaybackProgressInfo(
|
||||
itemId = itemId,
|
||||
positionTicks = positionTicks,
|
||||
canSeek = true,
|
||||
isPaused = isPaused,
|
||||
isMuted = false,
|
||||
mediaSourceId = reportContext.mediaSourceId,
|
||||
audioStreamIndex = reportContext.audioStreamIndex,
|
||||
subtitleStreamIndex = reportContext.subtitleStreamIndex,
|
||||
liveStreamId = reportContext.liveStreamId,
|
||||
playSessionId = reportContext.playSessionId,
|
||||
playMethod = reportContext.playMethod,
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackStopped(
|
||||
itemId: UUID,
|
||||
positionTicks: Long,
|
||||
reportContext: PlaybackReportContext,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
if (!ensureConfigured()) return@withContext
|
||||
api.playStateApi.reportPlaybackStopped(
|
||||
PlaybackStopInfo(
|
||||
itemId = itemId,
|
||||
positionTicks = positionTicks,
|
||||
mediaSourceId = reportContext.mediaSourceId,
|
||||
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 companion object {
|
||||
private const val TAG = "JellyfinApiClient"
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class JellyfinAuthInterceptor @Inject constructor(
|
||||
userSessionRepository: UserSessionRepository
|
||||
) : Interceptor {
|
||||
|
||||
@Volatile
|
||||
private var cachedToken: String = ""
|
||||
|
||||
init {
|
||||
userSessionRepository.accessToken
|
||||
.onEach { cachedToken = it }
|
||||
.launchIn(CoroutineScope(SupervisorJob() + Dispatchers.IO))
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val token = cachedToken
|
||||
val request = chain.request().newBuilder()
|
||||
.addHeader("X-Emby-Token", token)
|
||||
.build()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
|
||||
fun playbackCustomCacheKey(
|
||||
mediaId: String,
|
||||
playbackUrl: String,
|
||||
playMethod: PlayMethod
|
||||
): String? {
|
||||
val normalizedUrl = playbackUrl.substringBefore('?').substringBefore('#').lowercase()
|
||||
val isAdaptiveManifest =
|
||||
normalizedUrl.endsWith(".m3u8") ||
|
||||
normalizedUrl.endsWith(".mpd") ||
|
||||
normalizedUrl.endsWith(".ism") ||
|
||||
normalizedUrl.contains(".ism/")
|
||||
|
||||
return if (playMethod == PlayMethod.DIRECT_PLAY && !isAdaptiveManifest) {
|
||||
mediaId
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
|
||||
data class PlaybackDecision(
|
||||
val url: String,
|
||||
val mediaSource: MediaSourceInfo,
|
||||
val reportContext: PlaybackReportContext
|
||||
)
|
||||
|
||||
data class PlaybackReportContext(
|
||||
val playMethod: PlayMethod,
|
||||
val mediaSourceId: String?,
|
||||
val audioStreamIndex: Int?,
|
||||
val subtitleStreamIndex: Int?,
|
||||
val liveStreamId: String?,
|
||||
val playSessionId: String?,
|
||||
)
|
||||
@@ -1,51 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import org.jellyfin.sdk.model.api.MediaProtocol
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
|
||||
internal object PlaybackDecisionResolver {
|
||||
fun resolve(
|
||||
mediaSources: List<MediaSourceInfo>,
|
||||
playSessionId: String?,
|
||||
serverUrl: String,
|
||||
directPlayUrl: (MediaSourceInfo) -> String,
|
||||
): PlaybackDecision? {
|
||||
val mediaSource = mediaSources.firstOrNull { it.protocol == MediaProtocol.FILE && !it.isRemote }
|
||||
?: return null
|
||||
|
||||
val playMethod = when {
|
||||
mediaSource.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
|
||||
mediaSource.supportsDirectStream && !mediaSource.transcodingUrl.isNullOrBlank() -> PlayMethod.DIRECT_STREAM
|
||||
mediaSource.supportsTranscoding && !mediaSource.transcodingUrl.isNullOrBlank() -> PlayMethod.TRANSCODE
|
||||
else -> return null
|
||||
}
|
||||
|
||||
val url = when (playMethod) {
|
||||
PlayMethod.DIRECT_PLAY -> directPlayUrl(mediaSource)
|
||||
PlayMethod.DIRECT_STREAM,
|
||||
PlayMethod.TRANSCODE,
|
||||
-> absolutePlaybackUrl(serverUrl, requireNotNull(mediaSource.transcodingUrl))
|
||||
}
|
||||
|
||||
return PlaybackDecision(
|
||||
url = url,
|
||||
mediaSource = mediaSource,
|
||||
reportContext = PlaybackReportContext(
|
||||
playMethod = playMethod,
|
||||
mediaSourceId = mediaSource.id,
|
||||
audioStreamIndex = mediaSource.defaultAudioStreamIndex,
|
||||
subtitleStreamIndex = mediaSource.defaultSubtitleStreamIndex,
|
||||
liveStreamId = mediaSource.liveStreamId,
|
||||
playSessionId = playSessionId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun absolutePlaybackUrl(serverUrl: String, playbackUrl: String): String = when {
|
||||
playbackUrl.startsWith("http://", ignoreCase = true) -> playbackUrl
|
||||
playbackUrl.startsWith("https://", ignoreCase = true) -> playbackUrl
|
||||
playbackUrl.startsWith("/") -> "${serverUrl.trimEnd('/')}$playbackUrl"
|
||||
else -> "${serverUrl.trimEnd('/')}/${playbackUrl.trimStart('/')}"
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object PlaybackProfileModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDeviceProfileCapabilities(): DeviceProfileCapabilities = AndroidDeviceProfileCapabilities()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePlaybackProfilePolicy(
|
||||
family: PlaybackProfileFamily,
|
||||
capabilities: DeviceProfileCapabilities,
|
||||
): PlaybackProfilePolicy = when (family) {
|
||||
PlaybackProfileFamily.MOBILE -> MobilePlaybackProfilePolicy(capabilities)
|
||||
PlaybackProfileFamily.TV -> TvPlaybackProfilePolicy(capabilities)
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import org.jellyfin.sdk.model.ServerVersion
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
|
||||
enum class PlaybackProfileFamily {
|
||||
MOBILE,
|
||||
TV,
|
||||
}
|
||||
|
||||
interface PlaybackProfilePolicy {
|
||||
fun create(serverVersion: ServerVersion): DeviceProfile
|
||||
}
|
||||
|
||||
internal object PlaybackProfileDefaults {
|
||||
val fallbackServerVersion = ServerVersion(10, 10, 0)
|
||||
}
|
||||
|
||||
internal class MobilePlaybackProfilePolicy(
|
||||
private val capabilities: DeviceProfileCapabilities,
|
||||
) : PlaybackProfilePolicy {
|
||||
override fun create(serverVersion: ServerVersion): DeviceProfile =
|
||||
JellyfinAndroidMobileDeviceProfile.create(capabilities = capabilities)
|
||||
}
|
||||
|
||||
internal class TvPlaybackProfilePolicy(
|
||||
private val capabilities: DeviceProfileCapabilities,
|
||||
) : PlaybackProfilePolicy {
|
||||
override fun create(serverVersion: ServerVersion): DeviceProfile =
|
||||
JellyfinAndroidTvDeviceProfile.create(
|
||||
capabilities = capabilities,
|
||||
serverVersion = serverVersion,
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.domain.usecase
|
||||
|
||||
import hu.bbara.purefin.core.data.HomeRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class RefreshHomeDataUseCase @Inject constructor(
|
||||
private val repository: HomeRepository
|
||||
) {
|
||||
suspend operator fun invoke() {
|
||||
repository.refreshHomeData()
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.image
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.core.data.client.JellyfinAuthInterceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ImageModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(authInterceptor: JellyfinAuthInterceptor): OkHttpClient {
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.image
|
||||
|
||||
import java.util.UUID
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
class JellyfinImageHelper {
|
||||
companion object {
|
||||
|
||||
fun toImageUrl(url: String, itemId: UUID, type: ImageType): String {
|
||||
if (url.isEmpty()) {
|
||||
return ""
|
||||
}
|
||||
return StringBuilder()
|
||||
.append(url)
|
||||
.append("/Items/")
|
||||
.append(itemId)
|
||||
.append("/Images/")
|
||||
.append(type.serialName)
|
||||
.toString()
|
||||
}
|
||||
|
||||
fun toPrefixImageUrl(url: String, itemId: UUID): String {
|
||||
if (url.isEmpty()) {
|
||||
return ""
|
||||
}
|
||||
return StringBuilder()
|
||||
.append(url)
|
||||
.append("/Items/")
|
||||
.append(itemId)
|
||||
.append("/Images/")
|
||||
.toString()
|
||||
}
|
||||
|
||||
fun finishImageUrl(prefixImageUrl: String?, imageType: ImageType): String {
|
||||
if (prefixImageUrl.isNullOrEmpty()) {
|
||||
return ""
|
||||
}
|
||||
return StringBuilder()
|
||||
.append(prefixImageUrl)
|
||||
.append(imageType.serialName)
|
||||
.toString()
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.room
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.core.data.room.dao.EpisodeDao
|
||||
import hu.bbara.purefin.core.data.room.dao.MovieDao
|
||||
import hu.bbara.purefin.core.data.room.dao.SeasonDao
|
||||
import hu.bbara.purefin.core.data.room.dao.SeriesDao
|
||||
import hu.bbara.purefin.core.data.room.dao.SmartDownloadDao
|
||||
import hu.bbara.purefin.core.data.room.offline.OfflineMediaDatabase
|
||||
import hu.bbara.purefin.core.data.room.offline.OfflineRoomMediaLocalDataSource
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object MediaDatabaseModule {
|
||||
|
||||
// Offline Database and DAOs
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOfflineDatabase(@ApplicationContext context: Context): OfflineMediaDatabase =
|
||||
Room.databaseBuilder(context, OfflineMediaDatabase::class.java, "offline_media_database")
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
fun provideOfflineMovieDao(db: OfflineMediaDatabase) = db.movieDao()
|
||||
|
||||
@Provides
|
||||
fun provideOfflineSeriesDao(db: OfflineMediaDatabase) = db.seriesDao()
|
||||
|
||||
@Provides
|
||||
fun provideOfflineSeasonDao(db: OfflineMediaDatabase) = db.seasonDao()
|
||||
|
||||
@Provides
|
||||
fun provideOfflineEpisodeDao(db: OfflineMediaDatabase) = db.episodeDao()
|
||||
|
||||
@Provides
|
||||
fun provideSmartDownloadDao(db: OfflineMediaDatabase): SmartDownloadDao = db.smartDownloadDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOfflineDataSource(
|
||||
database: OfflineMediaDatabase,
|
||||
movieDao: MovieDao,
|
||||
seriesDao: SeriesDao,
|
||||
seasonDao: SeasonDao,
|
||||
episodeDao: EpisodeDao
|
||||
): OfflineRoomMediaLocalDataSource = OfflineRoomMediaLocalDataSource(
|
||||
database, movieDao, seriesDao, seasonDao, episodeDao
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.room
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Relation
|
||||
import hu.bbara.purefin.core.data.room.entity.EpisodeEntity
|
||||
import hu.bbara.purefin.core.data.room.entity.LibraryEntity
|
||||
import hu.bbara.purefin.core.data.room.entity.MovieEntity
|
||||
import hu.bbara.purefin.core.data.room.entity.SeasonEntity
|
||||
import hu.bbara.purefin.core.data.room.entity.SeriesEntity
|
||||
|
||||
data class SeasonWithEpisodes(
|
||||
@Embedded val season: SeasonEntity,
|
||||
@Relation(
|
||||
parentColumn = "id",
|
||||
entityColumn = "seasonId"
|
||||
)
|
||||
val episodes: List<EpisodeEntity>
|
||||
)
|
||||
|
||||
data class SeriesWithSeasonsAndEpisodes(
|
||||
@Embedded val series: SeriesEntity,
|
||||
@Relation(
|
||||
entity = SeasonEntity::class,
|
||||
parentColumn = "id",
|
||||
entityColumn = "seriesId"
|
||||
)
|
||||
val seasons: List<SeasonWithEpisodes>
|
||||
)
|
||||
|
||||
data class LibraryWithContent(
|
||||
@Embedded val library: LibraryEntity,
|
||||
@Relation(
|
||||
parentColumn = "id",
|
||||
entityColumn = "libraryId"
|
||||
)
|
||||
val series: List<SeriesEntity>,
|
||||
@Relation(
|
||||
parentColumn = "id",
|
||||
entityColumn = "libraryId"
|
||||
)
|
||||
val movies: List<MovieEntity>
|
||||
)
|
||||
@@ -1,15 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.room
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Stores UUIDs as strings for Room in-memory database.
|
||||
*/
|
||||
class UuidConverters {
|
||||
@TypeConverter
|
||||
fun fromString(value: String?): UUID? = value?.let(UUID::fromString)
|
||||
|
||||
@TypeConverter
|
||||
fun uuidToString(uuid: UUID?): String? = uuid?.toString()
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.room.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import hu.bbara.purefin.core.data.room.entity.CastMemberEntity
|
||||
import java.util.UUID
|
||||
|
||||
@Dao
|
||||
interface CastMemberDao {
|
||||
@Upsert
|
||||
suspend fun upsertAll(cast: List<CastMemberEntity>)
|
||||
|
||||
@Query("SELECT * FROM cast_members WHERE movieId = :movieId")
|
||||
suspend fun getByMovieId(movieId: UUID): List<CastMemberEntity>
|
||||
|
||||
@Query("SELECT * FROM cast_members WHERE seriesId = :seriesId")
|
||||
suspend fun getBySeriesId(seriesId: UUID): List<CastMemberEntity>
|
||||
|
||||
@Query("SELECT * FROM cast_members WHERE episodeId = :episodeId")
|
||||
suspend fun getByEpisodeId(episodeId: UUID): List<CastMemberEntity>
|
||||
|
||||
@Query("DELETE FROM cast_members WHERE movieId = :movieId")
|
||||
suspend fun deleteByMovieId(movieId: UUID)
|
||||
|
||||
@Query("DELETE FROM cast_members WHERE seriesId = :seriesId")
|
||||
suspend fun deleteBySeriesId(seriesId: UUID)
|
||||
|
||||
@Query("DELETE FROM cast_members WHERE episodeId = :episodeId")
|
||||
suspend fun deleteByEpisodeId(episodeId: UUID)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.room.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import hu.bbara.purefin.core.data.room.entity.EpisodeEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.UUID
|
||||
|
||||
@Dao
|
||||
interface EpisodeDao {
|
||||
@Upsert
|
||||
suspend fun upsert(episode: EpisodeEntity)
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertAll(episodes: List<EpisodeEntity>)
|
||||
|
||||
@Query("SELECT * FROM episodes WHERE seriesId = :seriesId")
|
||||
suspend fun getBySeriesId(seriesId: UUID): List<EpisodeEntity>
|
||||
|
||||
@Query("SELECT * FROM episodes WHERE seasonId = :seasonId")
|
||||
suspend fun getBySeasonId(seasonId: UUID): List<EpisodeEntity>
|
||||
|
||||
@Query("SELECT * FROM episodes")
|
||||
fun observeAll(): Flow<List<EpisodeEntity>>
|
||||
|
||||
@Query("SELECT * FROM episodes WHERE id = :id")
|
||||
suspend fun getById(id: UUID): EpisodeEntity?
|
||||
|
||||
@Query("UPDATE episodes SET progress = :progress, watched = :watched WHERE id = :id")
|
||||
suspend fun updateProgress(id: UUID, progress: Double?, watched: Boolean)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM episodes WHERE seriesId = :seriesId AND watched = 0")
|
||||
suspend fun countUnwatchedBySeries(seriesId: UUID): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM episodes WHERE seasonId = :seasonId AND watched = 0")
|
||||
suspend fun countUnwatchedBySeason(seasonId: UUID): Int
|
||||
|
||||
@Query("DELETE FROM episodes WHERE seriesId = :seriesId")
|
||||
suspend fun deleteBySeriesId(seriesId: UUID)
|
||||
|
||||
@Query("DELETE FROM episodes WHERE seasonId = :seasonId")
|
||||
suspend fun deleteBySeasonId(seasonId: UUID)
|
||||
|
||||
@Query("DELETE FROM episodes WHERE id = :id")
|
||||
suspend fun deleteById(id: UUID)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM episodes WHERE seasonId = :seasonId")
|
||||
suspend fun countBySeasonId(seasonId: UUID): Int
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.room.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import hu.bbara.purefin.core.data.room.LibraryWithContent
|
||||
import hu.bbara.purefin.core.data.room.entity.LibraryEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface LibraryDao {
|
||||
@Upsert
|
||||
suspend fun upsert(library: LibraryEntity)
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertAll(libraries: List<LibraryEntity>)
|
||||
|
||||
@Query("SELECT * FROM library")
|
||||
fun observeAll(): Flow<List<LibraryEntity>>
|
||||
|
||||
@Query("SELECT * FROM library")
|
||||
fun observeAllWithContent(): Flow<List<LibraryWithContent>>
|
||||
|
||||
@Query("SELECT * FROM library")
|
||||
suspend fun getAll(): List<LibraryEntity>
|
||||
|
||||
@Query("DELETE FROM library")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.room.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import hu.bbara.purefin.core.data.room.entity.MovieEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.UUID
|
||||
|
||||
@Dao
|
||||
interface MovieDao {
|
||||
@Upsert
|
||||
suspend fun upsert(movie: MovieEntity)
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertAll(movies: List<MovieEntity>)
|
||||
|
||||
@Query("SELECT * FROM movies")
|
||||
suspend fun getAll(): List<MovieEntity>
|
||||
|
||||
@Query("SELECT * FROM movies")
|
||||
fun observeAll(): Flow<List<MovieEntity>>
|
||||
|
||||
@Query("SELECT * FROM movies WHERE id = :id")
|
||||
suspend fun getById(id: UUID): MovieEntity?
|
||||
|
||||
@Query("UPDATE movies SET progress = :progress, watched = :watched WHERE id = :id")
|
||||
suspend fun updateProgress(id: UUID, progress: Double?, watched: Boolean)
|
||||
|
||||
@Query("DELETE FROM movies WHERE id = :id")
|
||||
suspend fun deleteById(id: UUID)
|
||||
|
||||
@Query("DELETE FROM movies")
|
||||
suspend fun clear()
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.room.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import hu.bbara.purefin.core.data.room.entity.SeasonEntity
|
||||
import java.util.UUID
|
||||
|
||||
@Dao
|
||||
interface SeasonDao {
|
||||
@Upsert
|
||||
suspend fun upsert(season: SeasonEntity)
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertAll(seasons: List<SeasonEntity>)
|
||||
|
||||
@Query("SELECT * FROM seasons WHERE seriesId = :seriesId")
|
||||
suspend fun getBySeriesId(seriesId: UUID): List<SeasonEntity>
|
||||
|
||||
@Query("SELECT * FROM seasons WHERE id = :id")
|
||||
suspend fun getById(id: UUID): SeasonEntity?
|
||||
|
||||
@Query("UPDATE seasons SET unwatchedEpisodeCount = :count WHERE id = :id")
|
||||
suspend fun updateUnwatchedCount(id: UUID, count: Int)
|
||||
|
||||
@Query("DELETE FROM seasons WHERE seriesId = :seriesId")
|
||||
suspend fun deleteBySeriesId(seriesId: UUID)
|
||||
|
||||
@Query("DELETE FROM seasons WHERE id = :id")
|
||||
suspend fun deleteById(id: UUID)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM seasons WHERE seriesId = :seriesId")
|
||||
suspend fun countBySeriesId(seriesId: UUID): Int
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.room.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import hu.bbara.purefin.core.data.room.SeriesWithSeasonsAndEpisodes
|
||||
import hu.bbara.purefin.core.data.room.entity.SeriesEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.UUID
|
||||
|
||||
@Dao
|
||||
interface SeriesDao {
|
||||
@Upsert
|
||||
suspend fun upsert(series: SeriesEntity)
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertAll(series: List<SeriesEntity>)
|
||||
|
||||
@Query("SELECT * FROM series")
|
||||
suspend fun getAll(): List<SeriesEntity>
|
||||
|
||||
@Query("SELECT * FROM series")
|
||||
fun observeAll(): Flow<List<SeriesEntity>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM series WHERE id = :id")
|
||||
fun observeWithContent(id: UUID): Flow<SeriesWithSeasonsAndEpisodes?>
|
||||
|
||||
@Query("SELECT * FROM series WHERE id = :id")
|
||||
suspend fun getById(id: UUID): SeriesEntity?
|
||||
|
||||
@Query("UPDATE series SET unwatchedEpisodeCount = :count WHERE id = :id")
|
||||
suspend fun updateUnwatchedCount(id: UUID, count: Int)
|
||||
|
||||
@Query("DELETE FROM series")
|
||||
suspend fun clear()
|
||||
|
||||
@Query("DELETE FROM series WHERE id = :id")
|
||||
suspend fun deleteById(id: UUID)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.room.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import hu.bbara.purefin.core.data.room.entity.SmartDownloadEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.UUID
|
||||
|
||||
@Dao
|
||||
interface SmartDownloadDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(entity: SmartDownloadEntity)
|
||||
|
||||
@Query("DELETE FROM smart_downloads WHERE seriesId = :seriesId")
|
||||
suspend fun delete(seriesId: UUID)
|
||||
|
||||
@Query("SELECT * FROM smart_downloads")
|
||||
suspend fun getAll(): List<SmartDownloadEntity>
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM smart_downloads WHERE seriesId = :seriesId)")
|
||||
suspend fun exists(seriesId: UUID): Boolean
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM smart_downloads WHERE seriesId = :seriesId)")
|
||||
fun observe(seriesId: UUID): Flow<Boolean>
|
||||
|
||||
@Query("SELECT * FROM smart_downloads")
|
||||
fun observeAll(): Flow<List<SmartDownloadEntity>>
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.room.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.util.UUID
|
||||
|
||||
@Entity(
|
||||
tableName = "cast_members",
|
||||
indices = [Index("movieId"), Index("seriesId"), Index("episodeId")]
|
||||
)
|
||||
data class CastMemberEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val name: String,
|
||||
val role: String,
|
||||
val imageUrl: String?,
|
||||
val movieId: UUID? = null,
|
||||
val seriesId: UUID? = null,
|
||||
val episodeId: UUID? = null
|
||||
)
|
||||
@@ -1,35 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.room.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.util.UUID
|
||||
|
||||
@Entity(
|
||||
tableName = "episodes",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = SeriesEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["seriesId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [Index("seriesId"), Index("seasonId")]
|
||||
)
|
||||
data class EpisodeEntity(
|
||||
@PrimaryKey val id: UUID,
|
||||
val seriesId: UUID,
|
||||
val seasonId: UUID,
|
||||
val index: Int,
|
||||
val title: String,
|
||||
val synopsis: String,
|
||||
val releaseDate: String,
|
||||
val rating: String,
|
||||
val runtime: String,
|
||||
val progress: Double?,
|
||||
val watched: Boolean,
|
||||
val format: String,
|
||||
val imageUrlPrefix: String
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.room.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import java.util.UUID
|
||||
|
||||
@Entity(tableName = "library")
|
||||
data class LibraryEntity (
|
||||
@PrimaryKey
|
||||
val id: UUID,
|
||||
val name: String,
|
||||
val type: String,
|
||||
)
|
||||
@@ -1,26 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.room.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.util.UUID
|
||||
|
||||
@Entity(
|
||||
tableName = "movies",
|
||||
indices = [Index("libraryId")]
|
||||
)
|
||||
data class MovieEntity(
|
||||
@PrimaryKey val id: UUID,
|
||||
val libraryId: UUID,
|
||||
val title: String,
|
||||
val progress: Double?,
|
||||
val watched: Boolean,
|
||||
val year: String,
|
||||
val rating: String,
|
||||
val runtime: String,
|
||||
val format: String,
|
||||
val synopsis: String,
|
||||
val imageUrlPrefix: String,
|
||||
val audioTrack: String,
|
||||
val subtitles: String
|
||||
)
|
||||
@@ -1,28 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.room.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.util.UUID
|
||||
|
||||
@Entity(
|
||||
tableName = "seasons",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = SeriesEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["seriesId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [Index("seriesId")]
|
||||
)
|
||||
data class SeasonEntity(
|
||||
@PrimaryKey val id: UUID,
|
||||
val seriesId: UUID,
|
||||
val name: String,
|
||||
val index: Int,
|
||||
val unwatchedEpisodeCount: Int,
|
||||
val episodeCount: Int
|
||||
)
|
||||
@@ -1,21 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.room.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.util.UUID
|
||||
|
||||
@Entity(
|
||||
tableName = "series",
|
||||
indices = [Index("libraryId")]
|
||||
)
|
||||
data class SeriesEntity(
|
||||
@PrimaryKey val id: UUID,
|
||||
val libraryId: UUID,
|
||||
val name: String,
|
||||
val synopsis: String,
|
||||
val year: String,
|
||||
val imageUrlPrefix: String,
|
||||
val unwatchedEpisodeCount: Int,
|
||||
val seasonCount: Int
|
||||
)
|
||||
@@ -1,10 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.room.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import java.util.UUID
|
||||
|
||||
@Entity(tableName = "smart_downloads")
|
||||
data class SmartDownloadEntity(
|
||||
@PrimaryKey val seriesId: UUID
|
||||
)
|
||||
@@ -1,36 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.room.offline
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import hu.bbara.purefin.core.data.room.UuidConverters
|
||||
import hu.bbara.purefin.core.data.room.dao.EpisodeDao
|
||||
import hu.bbara.purefin.core.data.room.dao.MovieDao
|
||||
import hu.bbara.purefin.core.data.room.dao.SeasonDao
|
||||
import hu.bbara.purefin.core.data.room.dao.SeriesDao
|
||||
import hu.bbara.purefin.core.data.room.dao.SmartDownloadDao
|
||||
import hu.bbara.purefin.core.data.room.entity.EpisodeEntity
|
||||
import hu.bbara.purefin.core.data.room.entity.MovieEntity
|
||||
import hu.bbara.purefin.core.data.room.entity.SeasonEntity
|
||||
import hu.bbara.purefin.core.data.room.entity.SeriesEntity
|
||||
import hu.bbara.purefin.core.data.room.entity.SmartDownloadEntity
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
MovieEntity::class,
|
||||
SeriesEntity::class,
|
||||
SeasonEntity::class,
|
||||
EpisodeEntity::class,
|
||||
SmartDownloadEntity::class,
|
||||
],
|
||||
version = 9,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(UuidConverters::class)
|
||||
abstract class OfflineMediaDatabase : RoomDatabase() {
|
||||
abstract fun movieDao(): MovieDao
|
||||
abstract fun seriesDao(): SeriesDao
|
||||
abstract fun seasonDao(): SeasonDao
|
||||
abstract fun episodeDao(): EpisodeDao
|
||||
abstract fun smartDownloadDao(): SmartDownloadDao
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.room.offline
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import hu.bbara.purefin.core.data.room.dao.EpisodeDao
|
||||
import hu.bbara.purefin.core.data.room.dao.MovieDao
|
||||
import hu.bbara.purefin.core.data.room.dao.SeasonDao
|
||||
import hu.bbara.purefin.core.data.room.dao.SeriesDao
|
||||
import hu.bbara.purefin.core.data.room.entity.EpisodeEntity
|
||||
import hu.bbara.purefin.core.data.room.entity.MovieEntity
|
||||
import hu.bbara.purefin.core.data.room.entity.SeasonEntity
|
||||
import hu.bbara.purefin.core.data.room.entity.SeriesEntity
|
||||
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.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.util.UUID
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class OfflineRoomMediaLocalDataSource(
|
||||
private val database: OfflineMediaDatabase,
|
||||
private val movieDao: MovieDao,
|
||||
private val seriesDao: SeriesDao,
|
||||
private val seasonDao: SeasonDao,
|
||||
private val episodeDao: EpisodeDao,
|
||||
) {
|
||||
|
||||
val moviesFlow: Flow<Map<UUID, Movie>> = movieDao.observeAll()
|
||||
.map { entities -> entities.associate { it.id to it.toDomain() } }
|
||||
|
||||
val seriesFlow: Flow<Map<UUID, Series>> = seriesDao.observeAll()
|
||||
.map { entities ->
|
||||
entities.associate { it.id to it.toDomain(seasons = emptyList()) }
|
||||
}
|
||||
|
||||
val episodesFlow: Flow<Map<UUID, Episode>> = episodeDao.observeAll()
|
||||
.map { entities -> entities.associate { it.id to it.toDomain() } }
|
||||
|
||||
// Full content Flow for series detail screen (scoped to one series)
|
||||
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> =
|
||||
seriesDao.observeWithContent(seriesId).map { relation ->
|
||||
relation?.let {
|
||||
it.series.toDomain(
|
||||
seasons = it.seasons.map { swe ->
|
||||
swe.season.toDomain(
|
||||
episodes = swe.episodes.map { ep -> ep.toDomain() }
|
||||
)
|
||||
} )
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveMovies(movies: List<Movie>) {
|
||||
database.withTransaction {
|
||||
movieDao.upsertAll(movies.map { it.toEntity() })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveSeries(seriesList: List<Series>) {
|
||||
database.withTransaction {
|
||||
seriesDao.upsertAll(seriesList.map { it.toEntity() })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveSeriesContent(series: Series) {
|
||||
database.withTransaction {
|
||||
// First ensure the series exists before adding seasons/episodes/cast
|
||||
seriesDao.upsert(series.toEntity())
|
||||
|
||||
episodeDao.deleteBySeriesId(series.id)
|
||||
seasonDao.deleteBySeriesId(series.id)
|
||||
|
||||
series.seasons.forEach { season ->
|
||||
seasonDao.upsert(season.toEntity())
|
||||
season.episodes.forEach { episode ->
|
||||
episodeDao.upsert(episode.toEntity())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveSeason(season: Season) {
|
||||
database.withTransaction {
|
||||
seriesDao.getById(season.seriesId)
|
||||
?: throw RuntimeException("Cannot add season without series. Season: $season")
|
||||
seasonDao.upsert(season.toEntity())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveEpisode(episode: Episode) {
|
||||
database.withTransaction {
|
||||
seriesDao.getById(episode.seriesId)
|
||||
?: throw RuntimeException("Cannot add episode without series. Episode: $episode")
|
||||
|
||||
episodeDao.upsert(episode.toEntity())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteEpisodeAndCleanup(episodeId: UUID) {
|
||||
database.withTransaction {
|
||||
val episode = episodeDao.getById(episodeId) ?: return@withTransaction
|
||||
episodeDao.deleteById(episodeId)
|
||||
|
||||
val remainingEpisodesInSeason = episodeDao.countBySeasonId(episode.seasonId)
|
||||
if (remainingEpisodesInSeason == 0) {
|
||||
seasonDao.deleteById(episode.seasonId)
|
||||
|
||||
val remainingSeasonsInSeries = seasonDao.countBySeriesId(episode.seriesId)
|
||||
if (remainingSeasonsInSeries == 0) {
|
||||
seriesDao.deleteById(episode.seriesId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMovies(): List<Movie> {
|
||||
val movies = movieDao.getAll()
|
||||
return movies.map { entity ->
|
||||
entity.toDomain()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMovie(id: UUID): Movie? {
|
||||
val entity = movieDao.getById(id) ?: return null
|
||||
return entity.toDomain()
|
||||
}
|
||||
|
||||
suspend fun getSeries(): List<Series> {
|
||||
return seriesDao.getAll().mapNotNull { entity -> getSeriesInternal(entity.id, includeContent = false) }
|
||||
}
|
||||
|
||||
suspend fun getSeriesBasic(id: UUID): Series? = getSeriesInternal(id, includeContent = false)
|
||||
|
||||
suspend fun getSeriesWithContent(id: UUID): Series? = getSeriesInternal(id, includeContent = true)
|
||||
|
||||
private suspend fun getSeriesInternal(id: UUID, includeContent: Boolean): Series? {
|
||||
val entity = seriesDao.getById(id) ?: return null
|
||||
val seasons = if (includeContent) {
|
||||
seasonDao.getBySeriesId(id).map { seasonEntity ->
|
||||
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
|
||||
episodeEntity.toDomain()
|
||||
}
|
||||
seasonEntity.toDomain(episodes)
|
||||
}
|
||||
} else emptyList()
|
||||
return entity.toDomain(seasons)
|
||||
}
|
||||
|
||||
suspend fun getSeason(seriesId: UUID, seasonId: UUID): Season? {
|
||||
val seasonEntity = seasonDao.getById(seasonId) ?: return null
|
||||
val episodes = episodeDao.getBySeasonId(seasonId).map { episodeEntity ->
|
||||
episodeEntity.toDomain()
|
||||
}
|
||||
return seasonEntity.toDomain(episodes)
|
||||
}
|
||||
|
||||
suspend fun getSeason(seasonId: UUID): Season? {
|
||||
val seasonEntity = seasonDao.getById(seasonId) ?: return null
|
||||
val episodes = episodeDao.getBySeasonId(seasonId).map { episodeEntity ->
|
||||
episodeEntity.toDomain()
|
||||
}
|
||||
return seasonEntity.toDomain(episodes)
|
||||
}
|
||||
|
||||
suspend fun getSeasons(seriesId: UUID): List<Season> {
|
||||
return seasonDao.getBySeriesId(seriesId).map { seasonEntity ->
|
||||
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
|
||||
episodeEntity.toDomain()
|
||||
}
|
||||
seasonEntity.toDomain(episodes)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID): Episode? {
|
||||
val episodeEntity = episodeDao.getById(episodeId) ?: return null
|
||||
return episodeEntity.toDomain()
|
||||
}
|
||||
|
||||
suspend fun getEpisodeById(episodeId: UUID): Episode? {
|
||||
val episodeEntity = episodeDao.getById(episodeId) ?: return null
|
||||
return episodeEntity.toDomain()
|
||||
}
|
||||
|
||||
suspend fun updateWatchProgress(mediaId: UUID, progress: Double?, watched: Boolean) {
|
||||
movieDao.getById(mediaId)?.let {
|
||||
movieDao.updateProgress(mediaId, progress, watched)
|
||||
return
|
||||
}
|
||||
|
||||
episodeDao.getById(mediaId)?.let { episode ->
|
||||
database.withTransaction {
|
||||
episodeDao.updateProgress(mediaId, progress, watched)
|
||||
val seasonUnwatched = episodeDao.countUnwatchedBySeason(episode.seasonId)
|
||||
seasonDao.updateUnwatchedCount(episode.seasonId, seasonUnwatched)
|
||||
val seriesUnwatched = episodeDao.countUnwatchedBySeries(episode.seriesId)
|
||||
seriesDao.updateUnwatchedCount(episode.seriesId, seriesUnwatched)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getEpisodesBySeries(seriesId: UUID): List<Episode> {
|
||||
return episodeDao.getBySeriesId(seriesId).map { episodeEntity ->
|
||||
episodeEntity.toDomain()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteMovie(movieId: UUID) {
|
||||
movieDao.deleteById(movieId)
|
||||
}
|
||||
|
||||
private fun Movie.toEntity() = MovieEntity(
|
||||
id = id,
|
||||
libraryId = libraryId,
|
||||
title = title,
|
||||
progress = progress,
|
||||
watched = watched,
|
||||
year = year,
|
||||
rating = rating,
|
||||
runtime = runtime,
|
||||
format = format,
|
||||
synopsis = synopsis,
|
||||
imageUrlPrefix = imageUrlPrefix,
|
||||
audioTrack = audioTrack,
|
||||
subtitles = subtitles
|
||||
)
|
||||
|
||||
private fun Series.toEntity() = SeriesEntity(
|
||||
id = id,
|
||||
libraryId = libraryId,
|
||||
name = name,
|
||||
synopsis = synopsis,
|
||||
year = year,
|
||||
imageUrlPrefix = imageUrlPrefix,
|
||||
unwatchedEpisodeCount = unwatchedEpisodeCount,
|
||||
seasonCount = seasonCount
|
||||
)
|
||||
|
||||
private fun Season.toEntity() = SeasonEntity(
|
||||
id = id,
|
||||
seriesId = seriesId,
|
||||
name = name,
|
||||
index = index,
|
||||
unwatchedEpisodeCount = unwatchedEpisodeCount,
|
||||
episodeCount = episodeCount
|
||||
)
|
||||
|
||||
private fun Episode.toEntity() = EpisodeEntity(
|
||||
id = id,
|
||||
seriesId = seriesId,
|
||||
seasonId = seasonId,
|
||||
index = index,
|
||||
title = title,
|
||||
synopsis = synopsis,
|
||||
releaseDate = releaseDate,
|
||||
rating = rating,
|
||||
runtime = runtime,
|
||||
progress = progress,
|
||||
watched = watched,
|
||||
format = format,
|
||||
imageUrlPrefix = imageUrlPrefix
|
||||
)
|
||||
|
||||
private fun MovieEntity.toDomain() = Movie(
|
||||
id = id,
|
||||
libraryId = libraryId,
|
||||
title = title,
|
||||
progress = progress,
|
||||
watched = watched,
|
||||
year = year,
|
||||
rating = rating,
|
||||
runtime = runtime,
|
||||
format = format,
|
||||
synopsis = synopsis,
|
||||
imageUrlPrefix = imageUrlPrefix,
|
||||
audioTrack = audioTrack,
|
||||
subtitles = subtitles,
|
||||
cast = emptyList()
|
||||
)
|
||||
|
||||
private fun SeriesEntity.toDomain(seasons: List<Season>) = Series(
|
||||
id = id,
|
||||
libraryId = libraryId,
|
||||
name = name,
|
||||
synopsis = synopsis,
|
||||
year = year,
|
||||
imageUrlPrefix = imageUrlPrefix,
|
||||
unwatchedEpisodeCount = unwatchedEpisodeCount,
|
||||
seasonCount = seasonCount,
|
||||
seasons = seasons,
|
||||
cast = emptyList()
|
||||
)
|
||||
|
||||
private fun SeasonEntity.toDomain(episodes: List<Episode>) = Season(
|
||||
id = id,
|
||||
seriesId = seriesId,
|
||||
name = name,
|
||||
index = index,
|
||||
unwatchedEpisodeCount = unwatchedEpisodeCount,
|
||||
episodeCount = episodeCount,
|
||||
episodes = episodes
|
||||
)
|
||||
|
||||
private fun EpisodeEntity.toDomain() = Episode(
|
||||
id = id,
|
||||
seriesId = seriesId,
|
||||
seasonId = seasonId,
|
||||
index = index,
|
||||
title = title,
|
||||
synopsis = synopsis,
|
||||
releaseDate = releaseDate,
|
||||
rating = rating,
|
||||
runtime = runtime,
|
||||
progress = progress,
|
||||
watched = watched,
|
||||
format = format,
|
||||
imageUrlPrefix = imageUrlPrefix,
|
||||
cast = emptyList()
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.session
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class UserSession(
|
||||
val accessToken: String,
|
||||
val url: String,
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
val userId: UUID?,
|
||||
val loggedIn: Boolean,
|
||||
val isOfflineMode: Boolean = false
|
||||
)
|
||||
@@ -1,40 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.session
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.dataStoreFile
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class UserSessionModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUserProfileDataStore(
|
||||
@ApplicationContext context: Context
|
||||
): DataStore<UserSession> {
|
||||
return DataStoreFactory.create(
|
||||
serializer = UserSessionSerializer,
|
||||
produceFile = { context.dataStoreFile("user_session.json") },
|
||||
corruptionHandler = ReplaceFileCorruptionHandler(
|
||||
produceNewData = { UserSessionSerializer.defaultValue }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUserSessionRepository(
|
||||
userSessionDataStore: DataStore<UserSession>
|
||||
): UserSessionRepository {
|
||||
return UserSessionRepository(userSessionDataStore)
|
||||
}
|
||||
}
|
||||
@@ -1,54 +1,21 @@
|
||||
package hu.bbara.purefin.core.data.session
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
class UserSessionRepository @Inject constructor(
|
||||
private val userSessionDataStore: DataStore<UserSession>
|
||||
) {
|
||||
val session: Flow<UserSession> = userSessionDataStore.data
|
||||
interface UserSessionRepository {
|
||||
val serverUrl: Flow<String>
|
||||
suspend fun setServerUrl(serverUrl: String)
|
||||
|
||||
val serverUrl: Flow<String> = session
|
||||
.map { it.url }
|
||||
val accessToken: Flow<String>
|
||||
suspend fun setAccessToken(accessToken: String)
|
||||
|
||||
suspend fun setServerUrl(serverUrl: String) {
|
||||
userSessionDataStore.updateData {
|
||||
it.copy(url = serverUrl)
|
||||
}
|
||||
}
|
||||
val userId: Flow<UUID?>
|
||||
suspend fun setUserId(userId: UUID?)
|
||||
suspend fun getUserId(): UUID?
|
||||
|
||||
val accessToken: Flow<String> = session
|
||||
.map { it.accessToken }
|
||||
val isLoggedIn: Flow<Boolean>
|
||||
suspend fun setLoggedIn(isLoggedIn: Boolean)
|
||||
|
||||
suspend fun setAccessToken(accessToken: String) {
|
||||
userSessionDataStore.updateData {
|
||||
it.copy(accessToken = accessToken)
|
||||
}
|
||||
}
|
||||
|
||||
val userId: Flow<UUID?> = session
|
||||
.map { it.userId }
|
||||
|
||||
suspend fun setUserId(userId: UUID?) {
|
||||
userSessionDataStore.updateData {
|
||||
it.copy(userId = userId)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getUserId(): UUID? = userId.first()
|
||||
|
||||
val isLoggedIn: Flow<Boolean> = session.map { it.loggedIn }.distinctUntilChanged()
|
||||
|
||||
suspend fun setLoggedIn(isLoggedIn: Boolean) {
|
||||
userSessionDataStore.updateData {
|
||||
it.copy(loggedIn = isLoggedIn)
|
||||
}
|
||||
}
|
||||
|
||||
val isOfflineMode: Flow<Boolean> = session.map { it.isOfflineMode }.distinctUntilChanged()
|
||||
val isOfflineMode: Flow<Boolean>
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.session
|
||||
|
||||
import androidx.datastore.core.CorruptionException
|
||||
import androidx.datastore.core.Serializer
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
object UserSessionSerializer : Serializer<UserSession> {
|
||||
override val defaultValue: UserSession
|
||||
get() = UserSession(accessToken = "", url = "", loggedIn = false, userId = null, isOfflineMode = false)
|
||||
|
||||
override suspend fun readFrom(input: InputStream): UserSession {
|
||||
try {
|
||||
return Json.decodeFromString<UserSession>(
|
||||
input.readBytes().decodeToString()
|
||||
)
|
||||
} catch (serialization: SerializationException) {
|
||||
throw CorruptionException("proto", serialization)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun writeTo(t: UserSession, output: OutputStream) {
|
||||
output.write(
|
||||
Json.encodeToString(t)
|
||||
.encodeToByteArray()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultEpisodeSeriesLookupTest {
|
||||
|
||||
@Test
|
||||
fun `returns series id for known episode`() = runBlocking {
|
||||
val episodeId = UUID.randomUUID()
|
||||
val seriesId = UUID.randomUUID()
|
||||
val lookup = DefaultEpisodeSeriesLookup(
|
||||
mediaCatalogReader = FakeMediaCatalogReader(
|
||||
episodes = mapOf(
|
||||
episodeId to episode(
|
||||
id = episodeId,
|
||||
seriesId = seriesId,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = lookup.preferenceKeyFor(episodeId)
|
||||
|
||||
assertEquals(seriesId.toString(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to media id when episode is missing`() = runBlocking {
|
||||
val mediaId = UUID.randomUUID()
|
||||
val lookup = DefaultEpisodeSeriesLookup(
|
||||
mediaCatalogReader = FakeMediaCatalogReader()
|
||||
)
|
||||
|
||||
val result = lookup.preferenceKeyFor(mediaId)
|
||||
|
||||
assertEquals(mediaId.toString(), result)
|
||||
}
|
||||
|
||||
private class FakeMediaCatalogReader(
|
||||
movies: Map<UUID, Movie> = emptyMap(),
|
||||
series: Map<UUID, Series> = emptyMap(),
|
||||
episodes: Map<UUID, Episode> = emptyMap(),
|
||||
) : MediaCatalogReader {
|
||||
override val movies: StateFlow<Map<UUID, Movie>> = MutableStateFlow(movies)
|
||||
override val series: StateFlow<Map<UUID, Series>> = MutableStateFlow(series)
|
||||
override val episodes: StateFlow<Map<UUID, Episode>> = MutableStateFlow(episodes)
|
||||
|
||||
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> = flowOf(series.value[seriesId])
|
||||
}
|
||||
|
||||
private fun episode(id: UUID, seriesId: UUID) = Episode(
|
||||
id = id,
|
||||
seriesId = seriesId,
|
||||
seasonId = UUID.randomUUID(),
|
||||
index = 1,
|
||||
title = "Episode",
|
||||
synopsis = "Synopsis",
|
||||
releaseDate = "2026",
|
||||
rating = "PG",
|
||||
runtime = "42m",
|
||||
progress = null,
|
||||
watched = false,
|
||||
format = "VIDEO",
|
||||
imageUrlPrefix = "",
|
||||
cast = emptyList(),
|
||||
)
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import androidx.media3.common.MimeTypes
|
||||
import org.jellyfin.sdk.model.ServerVersion
|
||||
import org.jellyfin.sdk.model.api.CodecType
|
||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||
import org.jellyfin.sdk.model.api.ProfileCondition
|
||||
import org.jellyfin.sdk.model.api.ProfileConditionType
|
||||
import org.jellyfin.sdk.model.api.ProfileConditionValue
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class JellyfinAndroidTvDeviceProfileTest {
|
||||
@Test
|
||||
fun `official defaults build expected profile structure`() {
|
||||
val profile = JellyfinAndroidTvDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(),
|
||||
serverVersion = ServerVersion(10, 11, 0),
|
||||
)
|
||||
|
||||
assertEquals("AndroidTV-Default", profile.name)
|
||||
assertEquals(100_000_000, profile.maxStreamingBitrate)
|
||||
assertEquals(100_000_000, profile.maxStaticBitrate)
|
||||
assertEquals(3, profile.transcodingProfiles.size)
|
||||
|
||||
val videoProfiles = profile.transcodingProfiles.filter { it.type == DlnaProfileType.VIDEO }
|
||||
assertEquals(setOf("mp4", "ts"), videoProfiles.map { it.container }.toSet())
|
||||
assertTrue(videoProfiles.all { it.protocol == MediaStreamProtocol.HLS })
|
||||
assertTrue(videoProfiles.all { it.enableSubtitlesInManifest })
|
||||
|
||||
val audioProfile = profile.transcodingProfiles.single { it.type == DlnaProfileType.AUDIO }
|
||||
assertEquals("ts", audioProfile.container)
|
||||
assertEquals("aac", audioProfile.audioCodec)
|
||||
|
||||
val videoDirectPlay = profile.directPlayProfiles.single { it.type == DlnaProfileType.VIDEO }
|
||||
assertTrue(videoDirectPlay.videoCodec.orEmpty().split(",").contains("vc1"))
|
||||
assertTrue(videoDirectPlay.audioCodec.orEmpty().split(",").contains("ac3"))
|
||||
|
||||
assertTrue(
|
||||
profile.subtitleProfiles.any {
|
||||
it.format == "pgs" && it.method == SubtitleDeliveryMethod.EMBED
|
||||
}
|
||||
)
|
||||
assertTrue(
|
||||
profile.subtitleProfiles.any {
|
||||
it.format == "webvtt" && it.method == SubtitleDeliveryMethod.HLS
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ac3 disabled removes ac3 and eac3 from advertised codecs`() {
|
||||
val profile = JellyfinAndroidTvDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(),
|
||||
serverVersion = ServerVersion(10, 11, 0),
|
||||
config = JellyfinAndroidTvDeviceProfile.fixedConfig.copy(isAc3Enabled = false),
|
||||
)
|
||||
|
||||
val directAudioCodecs = profile.directPlayProfiles
|
||||
.single { it.type == DlnaProfileType.AUDIO }
|
||||
.audioCodec
|
||||
.orEmpty()
|
||||
.split(",")
|
||||
|
||||
val transcodingAudioCodecs = profile.transcodingProfiles
|
||||
.filter { it.type == DlnaProfileType.VIDEO }
|
||||
.flatMap { it.audioCodec.orEmpty().split(",") }
|
||||
|
||||
assertFalse(directAudioCodecs.contains("ac3"))
|
||||
assertFalse(directAudioCodecs.contains("eac3"))
|
||||
assertFalse(transcodingAudioCodecs.contains("ac3"))
|
||||
assertFalse(transcodingAudioCodecs.contains("eac3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `downmix mode limits audio codecs and channel profile`() {
|
||||
val profile = JellyfinAndroidTvDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(),
|
||||
serverVersion = ServerVersion(10, 11, 0),
|
||||
config = JellyfinAndroidTvDeviceProfile.fixedConfig.copy(downMixAudio = true),
|
||||
)
|
||||
|
||||
val directAudioCodecs = profile.directPlayProfiles
|
||||
.single { it.type == DlnaProfileType.AUDIO }
|
||||
.audioCodec
|
||||
.orEmpty()
|
||||
.split(",")
|
||||
|
||||
assertEquals(listOf("aac", "mp2", "mp3"), directAudioCodecs)
|
||||
|
||||
val audioChannelProfile = profile.codecProfiles.single { it.type == CodecType.VIDEO_AUDIO }
|
||||
assertEquals("2", audioChannelProfile.conditions.single().value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `older servers omit extended dolby vision range types`() {
|
||||
val capabilities = FakeDeviceProfileCapabilities(
|
||||
supportsAv1DolbyVision = false,
|
||||
supportsAv1Hdr10 = false,
|
||||
supportsAv1Hdr10Plus = false,
|
||||
supportsHevcDolbyVision = false,
|
||||
supportsHevcDolbyVisionEl = false,
|
||||
supportsHevcHdr10 = false,
|
||||
supportsHevcHdr10Plus = false,
|
||||
)
|
||||
|
||||
val olderServerProfile = JellyfinAndroidTvDeviceProfile.create(
|
||||
capabilities = capabilities,
|
||||
serverVersion = ServerVersion(10, 10, 0),
|
||||
)
|
||||
val newerServerProfile = JellyfinAndroidTvDeviceProfile.create(
|
||||
capabilities = capabilities,
|
||||
serverVersion = ServerVersion(10, 11, 0),
|
||||
)
|
||||
|
||||
val olderRangeTypes = codecRangeTypes(olderServerProfile.codecProfiles)
|
||||
val newerRangeTypes = codecRangeTypes(newerServerProfile.codecProfiles)
|
||||
|
||||
assertFalse(olderRangeTypes.contains("DOVIWithEL"))
|
||||
assertFalse(olderRangeTypes.contains("DOVIWithHDR10Plus"))
|
||||
assertFalse(olderRangeTypes.contains("DOVIWithELHDR10Plus"))
|
||||
assertFalse(olderRangeTypes.contains("DOVIInvalid"))
|
||||
|
||||
assertTrue(newerRangeTypes.contains("DOVIWithEL"))
|
||||
assertTrue(newerRangeTypes.contains("DOVIWithHDR10Plus"))
|
||||
assertTrue(newerRangeTypes.contains("DOVIWithELHDR10Plus"))
|
||||
assertTrue(newerRangeTypes.contains("DOVIInvalid"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `h264 high 10 is advertised as high 10`() {
|
||||
val profile = JellyfinAndroidTvDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(supportsAvcHigh10 = true),
|
||||
serverVersion = ServerVersion(10, 11, 0),
|
||||
)
|
||||
|
||||
val h264Values = profile.codecProfiles
|
||||
.filter { it.codec == "h264" }
|
||||
.flatMap { it.conditions + it.applyConditions }
|
||||
.mapNotNull(ProfileCondition::value)
|
||||
|
||||
assertTrue(h264Values.any { "high 10" in it })
|
||||
assertFalse(h264Values.any { "main 10" in it })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vc1 support condition changes with codec support`() {
|
||||
val unsupportedProfile = JellyfinAndroidTvDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(supportsVc1 = false),
|
||||
serverVersion = ServerVersion(10, 11, 0),
|
||||
)
|
||||
val supportedProfile = JellyfinAndroidTvDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(supportsVc1 = true),
|
||||
serverVersion = ServerVersion(10, 11, 0),
|
||||
)
|
||||
|
||||
val unsupportedCondition = vc1SupportCondition(unsupportedProfile.codecProfiles)
|
||||
val supportedCondition = vc1SupportCondition(supportedProfile.codecProfiles)
|
||||
|
||||
assertEquals(ProfileConditionType.EQUALS, unsupportedCondition.condition)
|
||||
assertEquals("none", unsupportedCondition.value)
|
||||
assertEquals(ProfileConditionType.NOT_EQUALS, supportedCondition.condition)
|
||||
assertEquals("none", supportedCondition.value)
|
||||
}
|
||||
}
|
||||
|
||||
class JellyfinAndroidMobileDeviceProfileTest {
|
||||
@Test
|
||||
fun `official mobile defaults build expected profile structure`() {
|
||||
val profile = JellyfinAndroidMobileDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(),
|
||||
)
|
||||
|
||||
assertEquals("Purefin", profile.name)
|
||||
assertEquals(120_000_000, profile.maxStreamingBitrate)
|
||||
assertEquals(100_000_000, profile.maxStaticBitrate)
|
||||
assertEquals(384_000, profile.musicStreamingTranscodingBitrate)
|
||||
|
||||
val videoProfiles = profile.transcodingProfiles.filter { it.type == DlnaProfileType.VIDEO }
|
||||
assertEquals(setOf("mkv", "ts"), videoProfiles.map { it.container }.toSet())
|
||||
assertTrue(videoProfiles.all { it.protocol == MediaStreamProtocol.HLS })
|
||||
|
||||
val audioProfile = profile.transcodingProfiles.single { it.type == DlnaProfileType.AUDIO }
|
||||
assertEquals("mp3", audioProfile.container)
|
||||
assertEquals(MediaStreamProtocol.HTTP, audioProfile.protocol)
|
||||
assertEquals("mp3", audioProfile.audioCodec)
|
||||
|
||||
assertTrue(
|
||||
profile.directPlayProfiles.any {
|
||||
it.type == DlnaProfileType.VIDEO && it.container == "mp4"
|
||||
}
|
||||
)
|
||||
assertTrue(
|
||||
profile.subtitleProfiles.any {
|
||||
it.format == "pgssub" && it.method == SubtitleDeliveryMethod.EMBED
|
||||
}
|
||||
)
|
||||
assertFalse(
|
||||
profile.subtitleProfiles.any {
|
||||
it.format == "ass"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mobile profile advertises forced audio codecs even without codec support`() {
|
||||
val profile = JellyfinAndroidMobileDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(
|
||||
supportedAudioCodecs = emptySet(),
|
||||
supportedVideoCodecs = setOf("h264"),
|
||||
videoProfiles = mapOf("h264" to setOf("high")),
|
||||
),
|
||||
)
|
||||
|
||||
val mkvAudioProfile = profile.directPlayProfiles.single {
|
||||
it.type == DlnaProfileType.AUDIO && it.container == "mkv"
|
||||
}
|
||||
|
||||
val codecs = mkvAudioProfile.audioCodec.orEmpty().split(",")
|
||||
assertTrue(codecs.contains("aac"))
|
||||
assertTrue(codecs.contains("ac3"))
|
||||
assertTrue(codecs.contains("truehd"))
|
||||
assertTrue(codecs.contains("pcm_s16le"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mobile ass direct play opt in adds ass and ssa subtitles`() {
|
||||
val profile = JellyfinAndroidMobileDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(),
|
||||
config = JellyfinAndroidMobileDeviceProfile.fixedConfig.copy(assDirectPlay = true),
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
profile.subtitleProfiles.any {
|
||||
it.format == "ass" && it.method == SubtitleDeliveryMethod.EMBED
|
||||
}
|
||||
)
|
||||
assertTrue(
|
||||
profile.subtitleProfiles.any {
|
||||
it.format == "ssa" && it.method == SubtitleDeliveryMethod.EXTERNAL
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mobile profile omits tv hdr range conditions`() {
|
||||
val profile = JellyfinAndroidMobileDeviceProfile.create(
|
||||
capabilities = FakeDeviceProfileCapabilities(),
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
profile.codecProfiles.any { codecProfile ->
|
||||
(codecProfile.conditions + codecProfile.applyConditions).any {
|
||||
it.property == ProfileConditionValue.VIDEO_RANGE_TYPE
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun codecRangeTypes(codecProfiles: List<org.jellyfin.sdk.model.api.CodecProfile>): Set<String> =
|
||||
codecProfiles
|
||||
.filter { it.propertyValuesContain(ProfileConditionValue.VIDEO_RANGE_TYPE) }
|
||||
.flatMap { (it.conditions + it.applyConditions).mapNotNull(ProfileCondition::value) }
|
||||
.flatMap { it.split("|") }
|
||||
.toSet()
|
||||
|
||||
private fun vc1SupportCondition(codecProfiles: List<org.jellyfin.sdk.model.api.CodecProfile>): ProfileCondition =
|
||||
codecProfiles
|
||||
.single {
|
||||
it.codec == "vc1" && it.conditions.any { condition ->
|
||||
condition.property == ProfileConditionValue.VIDEO_PROFILE
|
||||
}
|
||||
}
|
||||
.conditions
|
||||
.single()
|
||||
|
||||
private fun org.jellyfin.sdk.model.api.CodecProfile.propertyValuesContain(value: ProfileConditionValue): Boolean =
|
||||
(conditions + applyConditions).any { it.property == value }
|
||||
|
||||
private data class FakeDeviceProfileCapabilities(
|
||||
val supportsAv1: Boolean = true,
|
||||
val supportsAv1Main10: Boolean = true,
|
||||
val supportsAv1DolbyVision: Boolean = true,
|
||||
val supportsAv1Hdr10: Boolean = true,
|
||||
val supportsAv1Hdr10Plus: Boolean = true,
|
||||
val supportsAvc: Boolean = true,
|
||||
val supportsAvcHigh10: Boolean = false,
|
||||
val avcMainLevel: Int = 41,
|
||||
val avcHigh10Level: Int = 41,
|
||||
val supportsHevc: Boolean = true,
|
||||
val supportsHevcMain10: Boolean = true,
|
||||
val supportsHevcDolbyVision: Boolean = true,
|
||||
val supportsHevcDolbyVisionEl: Boolean = true,
|
||||
val supportsHevcHdr10: Boolean = true,
|
||||
val supportsHevcHdr10Plus: Boolean = true,
|
||||
val hevcMainLevel: Int = 123,
|
||||
val hevcMain10Level: Int = 123,
|
||||
val supportsVc1: Boolean = true,
|
||||
val maxResolutionAvc: ProfileResolution = ProfileResolution(3840, 2160),
|
||||
val maxResolutionHevc: ProfileResolution = ProfileResolution(3840, 2160),
|
||||
val maxResolutionAv1: ProfileResolution = ProfileResolution(3840, 2160),
|
||||
val maxResolutionVc1: ProfileResolution = ProfileResolution(1920, 1080),
|
||||
val supportedVideoCodecs: Set<String> = setOf(
|
||||
"h263",
|
||||
"mpeg2video",
|
||||
"mpeg4",
|
||||
"h264",
|
||||
"hevc",
|
||||
"vp8",
|
||||
"vp9",
|
||||
"av1",
|
||||
),
|
||||
val supportedAudioCodecs: Set<String> = setOf(
|
||||
"aac",
|
||||
"ac3",
|
||||
"eac3",
|
||||
"flac",
|
||||
"mp3",
|
||||
"opus",
|
||||
"vorbis",
|
||||
"3gpp",
|
||||
),
|
||||
val videoProfiles: Map<String, Set<String>> = mapOf(
|
||||
"h263" to setOf("baseline"),
|
||||
"mpeg2video" to setOf("main profile"),
|
||||
"mpeg4" to setOf("simple profile"),
|
||||
"h264" to setOf("high", "main", "baseline"),
|
||||
"hevc" to setOf("Main", "Main 10"),
|
||||
"vp8" to setOf("main"),
|
||||
"vp9" to setOf("Profile 0"),
|
||||
),
|
||||
override val hevcDoviHdr10PlusBug: Boolean = false,
|
||||
) : DeviceProfileCapabilities {
|
||||
override fun supportsAv1(): Boolean = supportsAv1
|
||||
override fun supportsAv1Main10(): Boolean = supportsAv1Main10
|
||||
override fun supportsAv1DolbyVision(): Boolean = supportsAv1DolbyVision
|
||||
override fun supportsAv1Hdr10(): Boolean = supportsAv1Hdr10
|
||||
override fun supportsAv1Hdr10Plus(): Boolean = supportsAv1Hdr10Plus
|
||||
override fun supportsAvc(): Boolean = supportsAvc
|
||||
override fun supportsAvcHigh10(): Boolean = supportsAvcHigh10
|
||||
override fun avcMainLevel(): Int = avcMainLevel
|
||||
override fun avcHigh10Level(): Int = avcHigh10Level
|
||||
override fun supportsHevc(): Boolean = supportsHevc
|
||||
override fun supportsHevcMain10(): Boolean = supportsHevcMain10
|
||||
override fun supportsHevcDolbyVision(): Boolean = supportsHevcDolbyVision
|
||||
override fun supportsHevcDolbyVisionEl(): Boolean = supportsHevcDolbyVisionEl
|
||||
override fun supportsHevcHdr10(): Boolean = supportsHevcHdr10
|
||||
override fun supportsHevcHdr10Plus(): Boolean = supportsHevcHdr10Plus
|
||||
override fun hevcMainLevel(): Int = hevcMainLevel
|
||||
override fun hevcMain10Level(): Int = hevcMain10Level
|
||||
override fun supportsVc1(): Boolean = supportsVc1
|
||||
|
||||
override fun maxResolution(mimeType: String): ProfileResolution = when (mimeType) {
|
||||
MimeTypes.VIDEO_H264 -> maxResolutionAvc
|
||||
MimeTypes.VIDEO_H265 -> maxResolutionHevc
|
||||
MimeTypes.VIDEO_AV1 -> maxResolutionAv1
|
||||
MimeTypes.VIDEO_VC1 -> maxResolutionVc1
|
||||
else -> ProfileResolution(0, 0)
|
||||
}
|
||||
|
||||
override fun supportsVideoCodec(codec: String): Boolean = supportedVideoCodecs.contains(codec)
|
||||
|
||||
override fun supportsAudioCodec(codec: String): Boolean = supportedAudioCodecs.contains(codec)
|
||||
|
||||
override fun videoCodecProfiles(codec: String): Set<String> = videoProfiles[codec].orEmpty()
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import org.jellyfin.sdk.model.api.MediaProtocol
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaSourceType
|
||||
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
class PlaybackDecisionResolverTest {
|
||||
@Test
|
||||
fun `resolve prefers local file-backed source for direct play`() {
|
||||
val remoteSource = mediaSource(
|
||||
id = "remote",
|
||||
protocol = MediaProtocol.HTTP,
|
||||
isRemote = true,
|
||||
supportsDirectPlay = true,
|
||||
)
|
||||
val localSource = mediaSource(
|
||||
id = "local",
|
||||
supportsDirectPlay = true,
|
||||
defaultAudioStreamIndex = 2,
|
||||
defaultSubtitleStreamIndex = 4,
|
||||
liveStreamId = "live-1",
|
||||
)
|
||||
|
||||
val decision = requireNotNull(
|
||||
PlaybackDecisionResolver.resolve(
|
||||
mediaSources = listOf(remoteSource, localSource),
|
||||
playSessionId = "session-1",
|
||||
serverUrl = "https://example.com/jellyfin",
|
||||
) { mediaSource ->
|
||||
"direct://${mediaSource.id}"
|
||||
}
|
||||
)
|
||||
|
||||
assertSame(localSource, decision.mediaSource)
|
||||
assertEquals("direct://local", decision.url)
|
||||
assertEquals(PlayMethod.DIRECT_PLAY, decision.reportContext.playMethod)
|
||||
assertEquals("local", decision.reportContext.mediaSourceId)
|
||||
assertEquals(2, decision.reportContext.audioStreamIndex)
|
||||
assertEquals(4, decision.reportContext.subtitleStreamIndex)
|
||||
assertEquals("live-1", decision.reportContext.liveStreamId)
|
||||
assertEquals("session-1", decision.reportContext.playSessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve uses transcoding url for direct stream`() {
|
||||
val source = mediaSource(
|
||||
id = "direct-stream",
|
||||
supportsDirectStream = true,
|
||||
transcodingUrl = "/Videos/1/master.m3u8",
|
||||
)
|
||||
|
||||
val decision = requireNotNull(
|
||||
PlaybackDecisionResolver.resolve(
|
||||
mediaSources = listOf(source),
|
||||
playSessionId = "session-2",
|
||||
serverUrl = "https://example.com/jellyfin",
|
||||
) { error("direct play should not be used") }
|
||||
)
|
||||
|
||||
assertEquals(PlayMethod.DIRECT_STREAM, decision.reportContext.playMethod)
|
||||
assertEquals("https://example.com/jellyfin/Videos/1/master.m3u8", decision.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve uses transcoding when direct stream is unavailable`() {
|
||||
val source = mediaSource(
|
||||
id = "transcode",
|
||||
supportsTranscoding = true,
|
||||
transcodingUrl = "Videos/2/master.m3u8",
|
||||
)
|
||||
|
||||
val decision = requireNotNull(
|
||||
PlaybackDecisionResolver.resolve(
|
||||
mediaSources = listOf(source),
|
||||
playSessionId = "session-3",
|
||||
serverUrl = "https://example.com/jellyfin",
|
||||
) { error("direct play should not be used") }
|
||||
)
|
||||
|
||||
assertEquals(PlayMethod.TRANSCODE, decision.reportContext.playMethod)
|
||||
assertEquals("https://example.com/jellyfin/Videos/2/master.m3u8", decision.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve returns null when no usable source exists`() {
|
||||
val source = mediaSource(id = "unusable")
|
||||
|
||||
val decision = PlaybackDecisionResolver.resolve(
|
||||
mediaSources = listOf(source),
|
||||
playSessionId = "session-4",
|
||||
serverUrl = "https://example.com/jellyfin",
|
||||
) { error("direct play should not be used") }
|
||||
|
||||
assertNull(decision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `absolute playback url preserves absolute urls`() {
|
||||
val url = PlaybackDecisionResolver.absolutePlaybackUrl(
|
||||
serverUrl = "https://example.com/jellyfin",
|
||||
playbackUrl = "https://cdn.example.com/master.m3u8",
|
||||
)
|
||||
|
||||
assertEquals("https://cdn.example.com/master.m3u8", url)
|
||||
}
|
||||
|
||||
private fun mediaSource(
|
||||
id: String,
|
||||
protocol: MediaProtocol = MediaProtocol.FILE,
|
||||
isRemote: Boolean = false,
|
||||
supportsDirectPlay: Boolean = false,
|
||||
supportsDirectStream: Boolean = false,
|
||||
supportsTranscoding: Boolean = false,
|
||||
transcodingUrl: String? = null,
|
||||
defaultAudioStreamIndex: Int? = null,
|
||||
defaultSubtitleStreamIndex: Int? = null,
|
||||
liveStreamId: String? = null,
|
||||
): MediaSourceInfo {
|
||||
return MediaSourceInfo(
|
||||
protocol = protocol,
|
||||
id = id,
|
||||
type = MediaSourceType.DEFAULT,
|
||||
container = "mkv",
|
||||
isRemote = isRemote,
|
||||
readAtNativeFramerate = true,
|
||||
ignoreDts = false,
|
||||
ignoreIndex = false,
|
||||
genPtsInput = false,
|
||||
supportsTranscoding = supportsTranscoding,
|
||||
supportsDirectStream = supportsDirectStream,
|
||||
supportsDirectPlay = supportsDirectPlay,
|
||||
isInfiniteStream = false,
|
||||
requiresOpening = false,
|
||||
requiresClosing = false,
|
||||
liveStreamId = liveStreamId,
|
||||
requiresLooping = false,
|
||||
supportsProbing = true,
|
||||
transcodingUrl = transcodingUrl,
|
||||
defaultAudioStreamIndex = defaultAudioStreamIndex,
|
||||
defaultSubtitleStreamIndex = defaultSubtitleStreamIndex,
|
||||
transcodingSubProtocol = MediaStreamProtocol.HLS,
|
||||
hasSegments = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package hu.bbara.purefin.core.data.client
|
||||
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class PlaybackCacheKeysTest {
|
||||
|
||||
@Test
|
||||
fun `progressive direct play gets stable cache key`() {
|
||||
assertEquals(
|
||||
"movie-1",
|
||||
playbackCustomCacheKey(
|
||||
mediaId = "movie-1",
|
||||
playbackUrl = "https://example.com/Videos/movie-1.mp4",
|
||||
playMethod = PlayMethod.DIRECT_PLAY
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adaptive direct stream skips stable cache key`() {
|
||||
assertNull(
|
||||
playbackCustomCacheKey(
|
||||
mediaId = "episode-1",
|
||||
playbackUrl = "https://example.com/Videos/episode-1/master.m3u8",
|
||||
playMethod = PlayMethod.DIRECT_STREAM
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manifest-looking direct play still skips stable cache key`() {
|
||||
assertNull(
|
||||
playbackCustomCacheKey(
|
||||
mediaId = "episode-2",
|
||||
playbackUrl = "https://example.com/Videos/episode-2/master.m3u8",
|
||||
playMethod = PlayMethod.DIRECT_PLAY
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
40
core/download/build.gradle.kts
Normal file
40
core/download/build.gradle.kts
Normal file
@@ -0,0 +1,40 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "hu.bbara.purefin.core.download"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 29
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
implementation(project(":core:data"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.hilt)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.medi3.exoplayer)
|
||||
implementation(libs.media3.datasource.okhttp)
|
||||
implementation(libs.okhttp)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package hu.bbara.purefin.core.download
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class DownloadControllerModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindMediaDownloadController(
|
||||
impl: MediaDownloadManager
|
||||
): MediaDownloadController
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package hu.bbara.purefin.core.download
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.CacheEvictor
|
||||
import androidx.media3.datasource.cache.NoOpCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
import javax.inject.Singleton
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
internal object DownloadModuleFactories {
|
||||
fun createDownloadCacheEvictor(): CacheEvictor {
|
||||
return NoOpCacheEvictor()
|
||||
}
|
||||
|
||||
fun newPhonePlaybackDataSourceFactory(
|
||||
upstreamDataSourceFactory: DataSource.Factory
|
||||
): CacheDataSource.Factory {
|
||||
return CacheDataSource.Factory()
|
||||
.setUpstreamDataSourceFactory(upstreamDataSourceFactory)
|
||||
.setCacheWriteDataSinkFactory(null)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DownloadModule {
|
||||
|
||||
private const val DOWNLOAD_CHANNEL_ID = "purefin_downloads"
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDownloadCache(@ApplicationContext context: Context): SimpleCache {
|
||||
val downloadDir = File(context.getExternalFilesDir(null), "downloads")
|
||||
return SimpleCache(
|
||||
downloadDir,
|
||||
DownloadModuleFactories.createDownloadCacheEvictor(),
|
||||
StandaloneDatabaseProvider(context)
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDownloadNotificationHelper(@ApplicationContext context: Context): DownloadNotificationHelper {
|
||||
return DownloadNotificationHelper(context, DOWNLOAD_CHANNEL_ID)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDownloadManager(
|
||||
@ApplicationContext context: Context,
|
||||
cache: SimpleCache,
|
||||
okHttpDataSourceFactory: OkHttpDataSource.Factory
|
||||
): DownloadManager {
|
||||
return DownloadManager(
|
||||
context,
|
||||
StandaloneDatabaseProvider(context),
|
||||
cache,
|
||||
okHttpDataSourceFactory,
|
||||
Executors.newFixedThreadPool(2)
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePlaybackDataSourceFactory(
|
||||
cache: SimpleCache,
|
||||
okHttpDataSourceFactory: OkHttpDataSource.Factory
|
||||
): DataSource.Factory {
|
||||
return DownloadModuleFactories
|
||||
.newPhonePlaybackDataSourceFactory(okHttpDataSourceFactory)
|
||||
.setCache(cache)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package hu.bbara.purefin.core.data.download
|
||||
package hu.bbara.purefin.core.download
|
||||
|
||||
sealed class DownloadState {
|
||||
data object NotDownloaded : DownloadState()
|
||||
@@ -1,4 +1,4 @@
|
||||
package hu.bbara.purefin.core.data.download
|
||||
package hu.bbara.purefin.core.download
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -0,0 +1,294 @@
|
||||
package hu.bbara.purefin.core.download
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.offline.Download
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadRequest
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import hu.bbara.purefin.core.data.DownloadMediaSourceResolver
|
||||
import hu.bbara.purefin.core.data.OfflineCatalogStore
|
||||
import hu.bbara.purefin.core.data.SmartDownloadStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Singleton
|
||||
class MediaDownloadManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val downloadManager: DownloadManager,
|
||||
private val downloadMediaSourceResolver: DownloadMediaSourceResolver,
|
||||
private val offlineCatalogStore: OfflineCatalogStore,
|
||||
private val smartDownloadStore: SmartDownloadStore,
|
||||
) : MediaDownloadController {
|
||||
|
||||
private val stateFlows = ConcurrentHashMap<String, MutableStateFlow<DownloadState>>()
|
||||
|
||||
init {
|
||||
downloadManager.resumeDownloads()
|
||||
|
||||
downloadManager.addListener(object : DownloadManager.Listener {
|
||||
override fun onDownloadChanged(
|
||||
manager: DownloadManager,
|
||||
download: Download,
|
||||
finalException: Exception?
|
||||
) {
|
||||
val contentId = download.request.id
|
||||
val state = download.toDownloadState()
|
||||
Log.d(TAG, "Download changed: $contentId -> $state (${download.percentDownloaded}%)")
|
||||
if (finalException != null) {
|
||||
Log.e(TAG, "Download exception for $contentId", finalException)
|
||||
}
|
||||
getOrCreateStateFlow(contentId).value = state
|
||||
}
|
||||
|
||||
override fun onDownloadRemoved(manager: DownloadManager, download: Download) {
|
||||
val contentId = download.request.id
|
||||
Log.d(TAG, "Download removed: $contentId")
|
||||
getOrCreateStateFlow(contentId).value = DownloadState.NotDownloaded
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the download index every 500 ms and emits a map of contentId → progress (0–100)
|
||||
* for every download that is currently queued or in progress.
|
||||
* Uses the download index directly so it always reflects the true download state,
|
||||
* regardless of listener callback timing.
|
||||
*/
|
||||
override fun observeActiveDownloads(): Flow<Map<String, Float>> = flow {
|
||||
while (true) {
|
||||
try {
|
||||
val result = buildMap<String, Float> {
|
||||
val cursor = downloadManager.downloadIndex.getDownloads(
|
||||
Download.STATE_QUEUED,
|
||||
Download.STATE_DOWNLOADING,
|
||||
Download.STATE_RESTARTING
|
||||
)
|
||||
cursor.use {
|
||||
while (it.moveToNext()) {
|
||||
val d = it.download
|
||||
put(d.request.id, d.percentDownloaded)
|
||||
}
|
||||
}
|
||||
}
|
||||
emit(result)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to poll active downloads", e)
|
||||
emit(emptyMap())
|
||||
}
|
||||
delay(500)
|
||||
}
|
||||
}.flowOn(Dispatchers.IO).distinctUntilChanged()
|
||||
|
||||
override fun observeDownloadState(contentId: String): StateFlow<DownloadState> {
|
||||
val flow = getOrCreateStateFlow(contentId)
|
||||
// Initialize from current download index
|
||||
val download = downloadManager.downloadIndex.getDownload(contentId)
|
||||
flow.value = download?.toDownloadState() ?: DownloadState.NotDownloaded
|
||||
return flow
|
||||
}
|
||||
|
||||
fun isDownloaded(contentId: String): Boolean {
|
||||
return downloadManager.downloadIndex.getDownload(contentId)?.state == Download.STATE_COMPLETED
|
||||
}
|
||||
|
||||
override suspend fun downloadMovie(movieId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val source = downloadMediaSourceResolver.resolveMovieDownload(movieId) ?: run {
|
||||
Log.e(TAG, "No downloadable movie source for $movieId")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
offlineCatalogStore.saveMovies(listOf(source.movie))
|
||||
|
||||
Log.d(TAG, "Starting download for '${source.movie.title}' from: ${source.playbackUrl}")
|
||||
val request = buildDownloadRequest(
|
||||
mediaId = movieId,
|
||||
playbackUrl = source.playbackUrl,
|
||||
title = source.movie.title,
|
||||
customCacheKey = source.customCacheKey,
|
||||
)
|
||||
PurefinDownloadService.sendAddDownload(context, request)
|
||||
Log.d(TAG, "Download request sent for $movieId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start download for $movieId", e)
|
||||
getOrCreateStateFlow(movieId.toString()).value = DownloadState.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cancelDownload(movieId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
PurefinDownloadService.sendRemoveDownload(context, movieId.toString())
|
||||
try {
|
||||
offlineCatalogStore.deleteMovie(movieId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove movie from offline DB", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun downloadEpisode(episodeId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val source = downloadMediaSourceResolver.resolveEpisodeDownload(episodeId) ?: run {
|
||||
Log.e(TAG, "No downloadable episode source for $episodeId")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
if (offlineCatalogStore.getSeriesBasic(source.series.id) == null) {
|
||||
offlineCatalogStore.saveSeries(listOf(source.series))
|
||||
}
|
||||
|
||||
if (offlineCatalogStore.getSeason(source.season.id) == null) {
|
||||
offlineCatalogStore.saveSeason(source.season)
|
||||
}
|
||||
|
||||
offlineCatalogStore.saveEpisode(source.episode)
|
||||
|
||||
Log.d(TAG, "Starting download for episode '${source.episode.title}' from: ${source.playbackUrl}")
|
||||
val request = buildDownloadRequest(
|
||||
mediaId = episodeId,
|
||||
playbackUrl = source.playbackUrl,
|
||||
title = source.episode.title,
|
||||
customCacheKey = source.customCacheKey,
|
||||
)
|
||||
PurefinDownloadService.sendAddDownload(context, request)
|
||||
Log.d(TAG, "Download request sent for episode $episodeId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start download for episode $episodeId", e)
|
||||
getOrCreateStateFlow(episodeId.toString()).value = DownloadState.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun downloadEpisodes(episodeIds: List<UUID>) {
|
||||
coroutineScope {
|
||||
for (episodeId in episodeIds) {
|
||||
launch { downloadEpisode(episodeId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cancelEpisodeDownload(episodeId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
PurefinDownloadService.sendRemoveDownload(context, episodeId.toString())
|
||||
try {
|
||||
offlineCatalogStore.deleteEpisodeAndCleanup(episodeId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove episode from offline DB", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Smart Download ──────────────────────────────────────────────────
|
||||
|
||||
override suspend fun enableSmartDownload(seriesId: UUID) {
|
||||
smartDownloadStore.enable(seriesId)
|
||||
syncSmartDownloadsForSeries(seriesId)
|
||||
}
|
||||
|
||||
suspend fun disableSmartDownload(seriesId: UUID) {
|
||||
smartDownloadStore.disable(seriesId)
|
||||
}
|
||||
|
||||
fun isSmartDownloadEnabled(seriesId: UUID): Flow<Boolean> = smartDownloadStore.observe(seriesId)
|
||||
|
||||
override suspend fun syncSmartDownloads() {
|
||||
withContext(Dispatchers.IO) {
|
||||
val enabledSeriesIds = smartDownloadStore.getEnabledSeriesIds()
|
||||
for (seriesId in enabledSeriesIds) {
|
||||
try {
|
||||
syncSmartDownloadsForSeries(seriesId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Smart download sync failed for series $seriesId", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncSmartDownloadsForSeries(seriesId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
// 1. Get currently downloaded episodes for this series
|
||||
val downloadedEpisodes = offlineCatalogStore.getEpisodesBySeries(seriesId)
|
||||
|
||||
// 2. Check watched status from server and delete watched downloads
|
||||
val unwatchedDownloaded = mutableListOf<UUID>()
|
||||
for (episode in downloadedEpisodes) {
|
||||
if (downloadMediaSourceResolver.isEpisodeWatched(episode.id)) {
|
||||
Log.d(TAG, "Smart download: removing watched episode ${episode.title}")
|
||||
cancelEpisodeDownload(episode.id)
|
||||
} else {
|
||||
unwatchedDownloaded.add(episode.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Find unwatched episodes not already downloaded
|
||||
val needed = SMART_DOWNLOAD_COUNT - unwatchedDownloaded.size
|
||||
if (needed <= 0) return@withContext
|
||||
|
||||
val toDownload = downloadMediaSourceResolver.getUnwatchedEpisodeIds(
|
||||
seriesId = seriesId,
|
||||
excludedEpisodeIds = unwatchedDownloaded.toSet(),
|
||||
limit = needed,
|
||||
)
|
||||
|
||||
if (toDownload.isNotEmpty()) {
|
||||
Log.d(TAG, "Smart download: queuing ${toDownload.size} episodes for series $seriesId")
|
||||
downloadEpisodes(toDownload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreateStateFlow(contentId: String): MutableStateFlow<DownloadState> {
|
||||
return stateFlows.getOrPut(contentId) { MutableStateFlow(DownloadState.NotDownloaded) }
|
||||
}
|
||||
|
||||
private fun buildDownloadRequest(
|
||||
mediaId: UUID,
|
||||
playbackUrl: String,
|
||||
title: String,
|
||||
customCacheKey: String?,
|
||||
): DownloadRequest {
|
||||
val builder = DownloadRequest.Builder(mediaId.toString(), playbackUrl.toUri())
|
||||
.setData(title.toByteArray(Charsets.UTF_8))
|
||||
|
||||
customCacheKey?.let(builder::setCustomCacheKey)
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun Download.toDownloadState(): DownloadState = when (state) {
|
||||
Download.STATE_COMPLETED -> DownloadState.Downloaded
|
||||
Download.STATE_DOWNLOADING -> DownloadState.Downloading(percentDownloaded)
|
||||
Download.STATE_QUEUED, Download.STATE_RESTARTING -> DownloadState.Downloading(0f)
|
||||
Download.STATE_FAILED -> DownloadState.Failed
|
||||
Download.STATE_REMOVING -> DownloadState.NotDownloaded
|
||||
Download.STATE_STOPPED -> DownloadState.NotDownloaded
|
||||
else -> DownloadState.NotDownloaded
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MediaDownloadManager"
|
||||
private const val SMART_DOWNLOAD_COUNT = 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package hu.bbara.purefin.core.download
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.offline.Download
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
|
||||
import androidx.media3.exoplayer.offline.DownloadRequest
|
||||
import androidx.media3.exoplayer.offline.DownloadService
|
||||
import androidx.media3.exoplayer.scheduler.Scheduler
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class PurefinDownloadService : DownloadService(
|
||||
FOREGROUND_NOTIFICATION_ID,
|
||||
DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL,
|
||||
DOWNLOAD_CHANNEL_ID,
|
||||
R.string.download_channel_name,
|
||||
R.string.download_channel_description
|
||||
) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface DownloadServiceEntryPoint {
|
||||
fun downloadManager(): DownloadManager
|
||||
fun downloadNotificationHelper(): DownloadNotificationHelper
|
||||
}
|
||||
|
||||
private val entryPoint: DownloadServiceEntryPoint by lazy {
|
||||
EntryPointAccessors.fromApplication(applicationContext, DownloadServiceEntryPoint::class.java)
|
||||
}
|
||||
|
||||
private var lastBytesDownloaded: Long = 0L
|
||||
private var lastUpdateTimeMs: Long = 0L
|
||||
|
||||
override fun getDownloadManager(): DownloadManager = entryPoint.downloadManager()
|
||||
|
||||
override fun getForegroundNotification(
|
||||
downloads: MutableList<Download>,
|
||||
notMetRequirements: Int
|
||||
): Notification {
|
||||
val activeDownloads = downloads.filter { it.state == Download.STATE_DOWNLOADING }
|
||||
|
||||
if (activeDownloads.isEmpty()) {
|
||||
return entryPoint.downloadNotificationHelper().buildProgressNotification(
|
||||
this,
|
||||
android.R.drawable.stat_sys_download,
|
||||
null,
|
||||
null,
|
||||
downloads,
|
||||
notMetRequirements
|
||||
)
|
||||
}
|
||||
|
||||
val totalBytes = activeDownloads.sumOf { it.bytesDownloaded }
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
val speedText = if (lastUpdateTimeMs > 0L) {
|
||||
val elapsed = (now - lastUpdateTimeMs).coerceAtLeast(1)
|
||||
val bytesPerSec = (totalBytes - lastBytesDownloaded) * 1000L / elapsed
|
||||
formatSpeed(bytesPerSec)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
lastBytesDownloaded = totalBytes
|
||||
lastUpdateTimeMs = now
|
||||
|
||||
val percent = if (activeDownloads.size == 1) {
|
||||
activeDownloads[0].percentDownloaded
|
||||
} else {
|
||||
activeDownloads.map { it.percentDownloaded }.average().toFloat()
|
||||
}
|
||||
|
||||
val title = if (activeDownloads.size == 1) {
|
||||
activeDownloads[0].request.data
|
||||
?.toString(Charsets.UTF_8)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: "Downloading"
|
||||
} else {
|
||||
"Downloading ${activeDownloads.size} files"
|
||||
}
|
||||
|
||||
val contentText = buildString {
|
||||
append("${percent.toInt()}%")
|
||||
if (speedText.isNotEmpty()) {
|
||||
append(" · $speedText")
|
||||
}
|
||||
}
|
||||
|
||||
return NotificationCompat.Builder(this, DOWNLOAD_CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setContentTitle(title)
|
||||
.setContentText(contentText)
|
||||
.setProgress(100, percent.toInt(), false)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getScheduler(): Scheduler? = null
|
||||
|
||||
private fun formatSpeed(bytesPerSec: Long): String {
|
||||
if (bytesPerSec <= 0) return ""
|
||||
return when {
|
||||
bytesPerSec >= 1_000_000 -> String.format("%.1f MB/s", bytesPerSec / 1_000_000.0)
|
||||
bytesPerSec >= 1_000 -> String.format("%.0f KB/s", bytesPerSec / 1_000.0)
|
||||
else -> "$bytesPerSec B/s"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FOREGROUND_NOTIFICATION_ID = 1
|
||||
private const val DOWNLOAD_CHANNEL_ID = "purefin_downloads"
|
||||
|
||||
fun sendAddDownload(context: Context, request: DownloadRequest) {
|
||||
sendAddDownload(
|
||||
context,
|
||||
PurefinDownloadService::class.java,
|
||||
request,
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
fun sendRemoveDownload(context: Context, contentId: String) {
|
||||
sendRemoveDownload(
|
||||
context,
|
||||
PurefinDownloadService::class.java,
|
||||
contentId,
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
5
core/download/src/main/res/values/strings.xml
Normal file
5
core/download/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="download_channel_name">Downloads</string>
|
||||
<string name="download_channel_description">Purefin offline downloads</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,43 @@
|
||||
package hu.bbara.purefin.core.download
|
||||
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.NoOpCacheEvictor
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DownloadModuleTest {
|
||||
|
||||
@Test
|
||||
fun `download cache keeps no-op evictor`() {
|
||||
assertTrue(DownloadModuleFactories.createDownloadCacheEvictor() is NoOpCacheEvictor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `phone playback data source reads download cache and writes upstream only`() {
|
||||
val upstreamFactory = FakeDataSourceFactory()
|
||||
val factory = DownloadModuleFactories.newPhonePlaybackDataSourceFactory(upstreamFactory)
|
||||
|
||||
assertTrue(readPrivateBoolean(factory, "cacheIsReadOnly"))
|
||||
assertNull(readPrivateField(factory, "cacheWriteDataSinkFactory"))
|
||||
assertSame(upstreamFactory, readPrivateField(factory, "upstreamDataSourceFactory"))
|
||||
}
|
||||
|
||||
private fun readPrivateBoolean(instance: CacheDataSource.Factory, fieldName: String): Boolean {
|
||||
return readPrivateField(instance, fieldName) as Boolean
|
||||
}
|
||||
|
||||
private fun readPrivateField(instance: CacheDataSource.Factory, fieldName: String): Any? {
|
||||
val field = instance.javaClass.getDeclaredField(fieldName)
|
||||
field.isAccessible = true
|
||||
return field.get(instance)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeDataSourceFactory : DataSource.Factory {
|
||||
override fun createDataSource(): DataSource {
|
||||
throw UnsupportedOperationException("Unused in unit tests")
|
||||
}
|
||||
}
|
||||
16
core/image/build.gradle.kts
Normal file
16
core/image/build.gradle.kts
Normal file
@@ -0,0 +1,16 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
jvmToolchain(11)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package hu.bbara.purefin.core.image
|
||||
|
||||
enum class ArtworkKind(
|
||||
internal val pathSegment: String,
|
||||
) {
|
||||
PRIMARY("Primary"),
|
||||
BACKDROP("Backdrop"),
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package hu.bbara.purefin.core.image
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
object ImageUrlBuilder {
|
||||
fun toImageUrl(url: String, itemId: UUID, artworkKind: ArtworkKind): String {
|
||||
if (url.isEmpty()) {
|
||||
return ""
|
||||
}
|
||||
return StringBuilder()
|
||||
.append(url)
|
||||
.append("/Items/")
|
||||
.append(itemId)
|
||||
.append("/Images/")
|
||||
.append(artworkKind.pathSegment)
|
||||
.toString()
|
||||
}
|
||||
|
||||
fun toPrefixImageUrl(url: String, itemId: UUID): String {
|
||||
if (url.isEmpty()) {
|
||||
return ""
|
||||
}
|
||||
return StringBuilder()
|
||||
.append(url)
|
||||
.append("/Items/")
|
||||
.append(itemId)
|
||||
.append("/Images/")
|
||||
.toString()
|
||||
}
|
||||
|
||||
fun finishImageUrl(prefixImageUrl: String?, artworkKind: ArtworkKind): String {
|
||||
if (prefixImageUrl.isNullOrEmpty()) {
|
||||
return ""
|
||||
}
|
||||
return StringBuilder()
|
||||
.append(prefixImageUrl)
|
||||
.append(artworkKind.pathSegment)
|
||||
.toString()
|
||||
}
|
||||
}
|
||||
38
core/navigation/build.gradle.kts
Normal file
38
core/navigation/build.gradle.kts
Normal file
@@ -0,0 +1,38 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "hu.bbara.purefin.core.navigation"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 29
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.hilt)
|
||||
ksp(libs.hilt.compiler)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package hu.bbara.purefin.core.navigation
|
||||
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.Serializable
|
||||
import hu.bbara.purefin.core.navigation.UuidSerializer
|
||||
|
||||
@Serializable
|
||||
data class EpisodeDto(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val id: UUID,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val seasonId: UUID,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val seriesId: UUID,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package hu.bbara.purefin.core.navigation
|
||||
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.Serializable
|
||||
import hu.bbara.purefin.core.navigation.UuidSerializer
|
||||
|
||||
@Serializable
|
||||
data class LibraryDto(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val id: UUID,
|
||||
val name: String,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package hu.bbara.purefin.core.navigation
|
||||
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.Serializable
|
||||
import hu.bbara.purefin.core.navigation.UuidSerializer
|
||||
|
||||
@Serializable
|
||||
data class MovieDto(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val id: UUID,
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
package hu.bbara.purefin.core.navigation
|
||||
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
sealed interface NavigationCommand {
|
||||
data class Navigate(val route: Route) : NavigationCommand
|
||||
data class ReplaceAll(val route: Route) : NavigationCommand
|
||||
data object Pop : NavigationCommand
|
||||
}
|
||||
|
||||
interface NavigationManager {
|
||||
val commands: SharedFlow<NavigationCommand>
|
||||
fun navigate(route: Route)
|
||||
fun replaceAll(route: Route)
|
||||
fun pop()
|
||||
}
|
||||
|
||||
class DefaultNavigationManager : NavigationManager {
|
||||
private val commandsFlow = MutableSharedFlow<NavigationCommand>(
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
override val commands: SharedFlow<NavigationCommand> = commandsFlow.asSharedFlow()
|
||||
|
||||
override fun navigate(route: Route) {
|
||||
commandsFlow.tryEmit(NavigationCommand.Navigate(route))
|
||||
}
|
||||
|
||||
override fun replaceAll(route: Route) {
|
||||
commandsFlow.tryEmit(NavigationCommand.ReplaceAll(route))
|
||||
}
|
||||
|
||||
override fun pop() {
|
||||
commandsFlow.tryEmit(NavigationCommand.Pop)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
package hu.bbara.purefin.core.navigation
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class NetworkModule {
|
||||
|
||||
@Binds
|
||||
object NavigationManagerModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
abstract fun bindNetworkMonitor(impl: ConnectivityNetworkMonitor): NetworkMonitor
|
||||
fun provideNavigationManager(): NavigationManager = DefaultNavigationManager()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package hu.bbara.purefin.core.navigation
|
||||
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed interface Route : NavKey {
|
||||
@Serializable
|
||||
data object Home : Route
|
||||
|
||||
@Serializable
|
||||
data class MovieRoute(val item: MovieDto) : Route
|
||||
|
||||
@Serializable
|
||||
data class SeriesRoute(val item: SeriesDto) : Route
|
||||
|
||||
@Serializable
|
||||
data class EpisodeRoute(val item: EpisodeDto) : Route
|
||||
|
||||
@Serializable
|
||||
data class LibraryRoute(val library: LibraryDto) : Route
|
||||
|
||||
@Serializable
|
||||
data object LoginRoute : Route
|
||||
|
||||
@Serializable
|
||||
data class PlayerRoute(val mediaId: String) : Route
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package hu.bbara.purefin.core.navigation
|
||||
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.Serializable
|
||||
import hu.bbara.purefin.core.navigation.UuidSerializer
|
||||
|
||||
@Serializable
|
||||
data class SeriesDto(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val id: UUID,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
package hu.bbara.purefin.core.navigation
|
||||
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
|
||||
object UuidSerializer : KSerializer<UUID> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
|
||||
|
||||
override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString())
|
||||
|
||||
override fun serialize(encoder: Encoder, value: UUID) {
|
||||
encoder.encodeString(value.toString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package hu.bbara.purefin.core.navigation
|
||||
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RouteSerializationTest {
|
||||
|
||||
private val json = Json
|
||||
|
||||
@Test
|
||||
fun `episode route round trips with nested dto ids`() {
|
||||
val route: Route = Route.EpisodeRoute(
|
||||
item = EpisodeDto(
|
||||
id = UUID.randomUUID(),
|
||||
seasonId = UUID.randomUUID(),
|
||||
seriesId = UUID.randomUUID(),
|
||||
)
|
||||
)
|
||||
|
||||
assertRoundTrip(route)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `library route round trips`() {
|
||||
val route: Route = Route.LibraryRoute(
|
||||
library = LibraryDto(
|
||||
id = UUID.randomUUID(),
|
||||
name = "Shows",
|
||||
)
|
||||
)
|
||||
|
||||
assertRoundTrip(route)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `movie and series routes round trip`() {
|
||||
assertRoundTrip(
|
||||
Route.MovieRoute(
|
||||
item = MovieDto(id = UUID.randomUUID())
|
||||
)
|
||||
)
|
||||
assertRoundTrip(
|
||||
Route.SeriesRoute(
|
||||
item = SeriesDto(id = UUID.randomUUID())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `singleton routes round trip`() {
|
||||
assertRoundTrip(Route.Home)
|
||||
assertRoundTrip(Route.LoginRoute)
|
||||
assertRoundTrip(Route.PlayerRoute(mediaId = UUID.randomUUID().toString()))
|
||||
}
|
||||
|
||||
private fun assertRoundTrip(route: Route) {
|
||||
val encoded = json.encodeToString(route)
|
||||
val decoded = json.decodeFromString<Route>(encoded)
|
||||
|
||||
assertEquals(route, decoded)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ kotlin {
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
implementation(project(":core:data"))
|
||||
implementation(project(":core:image"))
|
||||
implementation(libs.hilt)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
|
||||
@@ -9,7 +9,7 @@ import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.data.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.player.model.QueueItemUi
|
||||
import hu.bbara.purefin.core.player.model.TrackOption
|
||||
import hu.bbara.purefin.core.player.model.TrackType
|
||||
|
||||
@@ -4,7 +4,7 @@ import android.util.Log
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import hu.bbara.purefin.core.data.MediaProgressWriter
|
||||
import hu.bbara.purefin.core.data.PlaybackProgressReporter
|
||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.data.PlaybackReportContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
Reference in New Issue
Block a user