feat: introduce core data models and interfaces for playback and downloads

This commit is contained in:
2026-04-15 20:02:21 +02:00
parent b67c8076f1
commit ad930e899f
180 changed files with 1363 additions and 960 deletions

View File

@@ -0,0 +1,41 @@
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.data.catalog"
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(project(":core:image"))
implementation(project(":data:jellyfin"))
implementation(project(":data:offline"))
implementation(libs.jellyfin.core)
implementation(libs.hilt)
ksp(libs.hilt.compiler)
implementation(libs.datastore)
testImplementation(libs.junit)
}

View File

@@ -0,0 +1,59 @@
package hu.bbara.purefin.data.catalog
import hu.bbara.purefin.core.data.MediaCatalogReader
import hu.bbara.purefin.core.data.MediaProgressWriter
import hu.bbara.purefin.core.data.NetworkMonitor
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)
}
}

View File

@@ -0,0 +1,16 @@
package hu.bbara.purefin.data.catalog
import hu.bbara.purefin.core.data.EpisodeSeriesLookup
import hu.bbara.purefin.core.data.MediaCatalogReader
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()
}
}

View File

@@ -0,0 +1,421 @@
package hu.bbara.purefin.data.catalog
import android.util.Log
import androidx.datastore.core.DataStore
import hu.bbara.purefin.core.data.HomeRepository
import hu.bbara.purefin.core.data.NetworkMonitor
import hu.bbara.purefin.core.image.ArtworkKind
import hu.bbara.purefin.data.offline.cache.CachedMediaItem
import hu.bbara.purefin.data.offline.cache.HomeCache
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.core.image.ImageUrlBuilder
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
@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 = ImageUrlBuilder.toImageUrl(url = serverUrl, itemId = id, artworkKind = ArtworkKind.PRIMARY),
type = LibraryKind.MOVIES,
movies = emptyList(),
)
CollectionType.TVSHOWS -> Library(
id = id,
name = name!!,
posterUrl = ImageUrlBuilder.toImageUrl(url = serverUrl, itemId = id, artworkKind = ArtworkKind.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 = ImageUrlBuilder.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 = ImageUrlBuilder.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 ->
ImageUrlBuilder.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"
}
}

View File

@@ -0,0 +1,155 @@
package hu.bbara.purefin.data.catalog
import hu.bbara.purefin.core.data.MediaCatalogReader
import hu.bbara.purefin.core.data.MediaProgressWriter
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.core.image.ImageUrlBuilder
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 ->
ImageUrlBuilder.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"
}
}

View File

@@ -0,0 +1,31 @@
package hu.bbara.purefin.data.catalog
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.EpisodeSeriesLookup
import hu.bbara.purefin.core.data.HomeRepository
import hu.bbara.purefin.core.data.MediaCatalogReader
import hu.bbara.purefin.core.data.MediaProgressWriter
import hu.bbara.purefin.core.data.OfflineCatalogReader
@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 bindEpisodeSeriesLookup(impl: DefaultEpisodeSeriesLookup): EpisodeSeriesLookup
}

View File

@@ -0,0 +1,46 @@
package hu.bbara.purefin.data.catalog
import hu.bbara.purefin.core.data.MediaCatalogReader
import hu.bbara.purefin.core.data.MediaProgressWriter
import hu.bbara.purefin.core.data.OfflineCatalogReader
import hu.bbara.purefin.data.offline.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)
}
}

View File

@@ -0,0 +1,78 @@
package hu.bbara.purefin.data.catalog
import hu.bbara.purefin.core.data.MediaCatalogReader
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(),
)
}

View File

@@ -0,0 +1,45 @@
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.data.jellyfin"
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(project(":core:image"))
implementation(project(":core:navigation"))
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)
testImplementation(libs.junit)
}

View File

@@ -0,0 +1,49 @@
package hu.bbara.purefin.data.jellyfin
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 hu.bbara.purefin.core.data.NetworkMonitor
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)
}
}

View File

@@ -0,0 +1,134 @@
package hu.bbara.purefin.data.jellyfin
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.PlayableMediaRepository
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.data.jellyfin.client.PlaybackDecision
import hu.bbara.purefin.core.data.PlaybackReportContext
import hu.bbara.purefin.data.jellyfin.client.playbackCustomCacheKey
import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.image.ImageUrlBuilder
import 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 hu.bbara.purefin.core.image.ArtworkKind
import org.jellyfin.sdk.model.api.BaseItemDto
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 = ImageUrlBuilder.toImageUrl(serverUrl, mediaId, ArtworkKind.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 = ImageUrlBuilder.toImageUrl(serverUrl, id, ArtworkKind.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"
}
}

View File

@@ -0,0 +1,41 @@
package hu.bbara.purefin.data.jellyfin
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.AuthenticationRepository
import hu.bbara.purefin.core.data.DownloadMediaSourceResolver
import hu.bbara.purefin.core.data.NetworkMonitor
import hu.bbara.purefin.core.data.PlayableMediaRepository
import hu.bbara.purefin.core.data.PlaybackProgressReporter
import hu.bbara.purefin.core.data.SessionBootstrapper
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class JellyfinBindingsModule {
@Binds
@Singleton
abstract fun bindSessionBootstrapper(impl: JellyfinApiClient): SessionBootstrapper
@Binds
@Singleton
abstract fun bindAuthenticationRepository(impl: JellyfinApiClient): AuthenticationRepository
@Binds
@Singleton
abstract fun bindDownloadMediaSourceResolver(impl: JellyfinApiClient): DownloadMediaSourceResolver
@Binds
abstract fun bindPlayableMediaRepository(impl: DefaultPlayableMediaRepository): PlayableMediaRepository
@Binds
abstract fun bindPlaybackProgressReporter(impl: JellyfinPlaybackProgressReporter): PlaybackProgressReporter
@Binds
@Singleton
abstract fun bindNetworkMonitor(impl: ConnectivityNetworkMonitor): NetworkMonitor
}

View File

@@ -0,0 +1,22 @@
package hu.bbara.purefin.data.jellyfin
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.data.jellyfin.client.JellyfinAuthInterceptor
import okhttp3.OkHttpClient
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object JellyfinNetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(authInterceptor: JellyfinAuthInterceptor): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.build()
}
}

View File

@@ -0,0 +1,30 @@
package hu.bbara.purefin.data.jellyfin
import hu.bbara.purefin.core.data.PlaybackProgressReporter
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.core.data.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)
}
}

View File

@@ -0,0 +1,169 @@
package hu.bbara.purefin.data.jellyfin.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
}

View File

@@ -0,0 +1,315 @@
package hu.bbara.purefin.data.jellyfin.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",
)

View File

@@ -0,0 +1,65 @@
package hu.bbara.purefin.data.jellyfin.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
}
}
}

View File

@@ -0,0 +1,213 @@
package hu.bbara.purefin.data.jellyfin.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))
}
}
}

View File

@@ -0,0 +1,544 @@
package hu.bbara.purefin.data.jellyfin.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"
}
}

View File

@@ -0,0 +1,625 @@
package hu.bbara.purefin.data.jellyfin.client
import android.content.Context
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.core.data.AuthenticationRepository
import hu.bbara.purefin.core.data.DownloadMediaSourceResolver
import hu.bbara.purefin.core.data.EpisodeDownloadSource
import hu.bbara.purefin.core.data.MovieDownloadSource
import hu.bbara.purefin.core.data.PlaybackMethod
import hu.bbara.purefin.core.data.PlaybackProfileFamily
import hu.bbara.purefin.core.data.PlaybackReportContext
import hu.bbara.purefin.core.data.SessionBootstrapper
import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
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.PlayMethod
import org.jellyfin.sdk.model.api.RepeatMode
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class JellyfinApiClient @Inject constructor(
@ApplicationContext private val applicationContext: Context,
private val userSessionRepository: UserSessionRepository,
private val playbackProfilePolicy: PlaybackProfilePolicy,
) : SessionBootstrapper, AuthenticationRepository, DownloadMediaSourceResolver {
private val jellyfin = createJellyfin {
context = applicationContext
clientInfo = ClientInfo(name = "Purefin", version = "0.0.1")
}
private val api = jellyfin.createApi()
private val serverVersionCache = ConcurrentHashMap<String, ServerVersion>()
private 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
}
override 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
}
}
override suspend fun initialize() {
withContext(Dispatchers.IO) {
ensureConfigured()
}
}
suspend fun updateApiClient() = initialize()
suspend fun getLibraries(): List<BaseItemDto> = withContext(Dispatchers.IO) {
if (!ensureConfigured()) {
return@withContext emptyList()
}
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
}
override suspend fun resolveMovieDownload(movieId: UUID): MovieDownloadSource? = withContext(Dispatchers.IO) {
val serverUrl = userSessionRepository.serverUrl.first().trim()
val source = getMediaSources(movieId).firstOrNull() ?: return@withContext null
val playbackUrl = getMediaPlaybackUrl(movieId, source) ?: return@withContext null
val itemInfo = getItemInfo(movieId) ?: return@withContext null
val movie = itemInfo.toMovie(serverUrl)
MovieDownloadSource(
movie = movie,
playbackUrl = playbackUrl,
customCacheKey = source.customCacheKey(movieId, playbackUrl),
)
}
override suspend fun resolveEpisodeDownload(episodeId: UUID): EpisodeDownloadSource? = withContext(Dispatchers.IO) {
val serverUrl = userSessionRepository.serverUrl.first().trim()
val source = getMediaSources(episodeId).firstOrNull() ?: return@withContext null
val playbackUrl = getMediaPlaybackUrl(episodeId, source) ?: return@withContext null
val episodeDto = getItemInfo(episodeId) ?: return@withContext null
val episode = episodeDto.toEpisode(serverUrl)
val series = getItemInfo(episode.seriesId)?.toSeries(serverUrl) ?: return@withContext null
val season = getItemInfo(episode.seasonId)?.toSeason(series.id) ?: return@withContext null
EpisodeDownloadSource(
episode = episode,
series = series,
season = season,
playbackUrl = playbackUrl,
customCacheKey = source.customCacheKey(episodeId, playbackUrl),
)
}
override suspend fun isEpisodeWatched(episodeId: UUID): Boolean {
return getItemInfo(episodeId)?.userData?.played == true
}
override suspend fun getUnwatchedEpisodeIds(
seriesId: UUID,
excludedEpisodeIds: Set<UUID>,
limit: Int,
): List<UUID> = withContext(Dispatchers.IO) {
if (limit <= 0) {
return@withContext emptyList()
}
val seasons = getSeasons(seriesId)
val episodes = buildList {
seasons.forEach { season ->
addAll(getEpisodesInSeason(seriesId, season.id))
}
}
episodes
.filter { episode ->
episode.userData?.played != true && episode.id !in excludedEpisodeIds
}
.take(limit)
.map { it.id }
}
suspend fun reportPlaybackStart(
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.toJellyfinPlayMethod(),
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.toJellyfinPlayMethod(),
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 fun BaseItemDto.toMovie(serverUrl: String): Movie {
return Movie(
id = id,
libraryId = parentId ?: UUID.randomUUID(),
title = name ?: "Unknown title",
progress = userData?.playedPercentage,
watched = userData?.played ?: false,
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
format = container?.uppercase() ?: "VIDEO",
synopsis = overview ?: "No synopsis available",
imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(serverUrl, id),
audioTrack = "ENG",
subtitles = "ENG",
cast = emptyList(),
)
}
private fun BaseItemDto.toEpisode(serverUrl: String): Episode {
return Episode(
id = id,
seriesId = seriesId ?: UUID.randomUUID(),
seasonId = parentId ?: UUID.randomUUID(),
title = name ?: "Unknown title",
index = indexNumber ?: 0,
synopsis = overview ?: "No synopsis available.",
releaseDate = productionYear?.toString() ?: "",
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
progress = userData?.playedPercentage,
watched = userData?.played ?: false,
format = container?.uppercase() ?: "VIDEO",
imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(serverUrl, id),
cast = emptyList(),
)
}
private fun BaseItemDto.toSeries(serverUrl: String): Series {
return Series(
id = id,
libraryId = parentId ?: UUID.randomUUID(),
name = name ?: "Unknown",
synopsis = overview ?: "No synopsis available",
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(serverUrl, id),
unwatchedEpisodeCount = userData?.unplayedItemCount ?: 0,
seasonCount = childCount ?: 0,
seasons = emptyList(),
cast = emptyList(),
)
}
private fun BaseItemDto.toSeason(seriesId: UUID): Season {
return Season(
id = id,
seriesId = this.seriesId ?: seriesId,
name = name ?: "Unknown",
index = indexNumber ?: 0,
unwatchedEpisodeCount = userData?.unplayedItemCount ?: 0,
episodeCount = childCount ?: 0,
episodes = emptyList(),
)
}
private fun MediaSourceInfo.customCacheKey(mediaId: UUID, playbackUrl: String): String? {
val shouldTranscode = supportsTranscoding == true &&
(supportsDirectPlay == false || transcodingUrl != null)
if (shouldTranscode) {
return null
}
return playbackCustomCacheKey(
mediaId = mediaId.toString(),
playbackUrl = playbackUrl,
playMethod = PlaybackMethod.DIRECT_PLAY,
)
}
private fun formatRuntime(ticks: Long?): String {
if (ticks == null || ticks <= 0) return ""
val totalSeconds = ticks / 10_000_000
val hours = TimeUnit.SECONDS.toHours(totalSeconds)
val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
}
private fun PlaybackMethod.toJellyfinPlayMethod(): PlayMethod = when (this) {
PlaybackMethod.DIRECT_PLAY -> PlayMethod.DIRECT_PLAY
PlaybackMethod.DIRECT_STREAM -> PlayMethod.DIRECT_STREAM
PlaybackMethod.TRANSCODE -> PlayMethod.TRANSCODE
}
private companion object {
private const val TAG = "JellyfinApiClient"
}
}

View File

@@ -0,0 +1,35 @@
package hu.bbara.purefin.data.jellyfin.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)
}
}

View File

@@ -0,0 +1,22 @@
package hu.bbara.purefin.data.jellyfin.client
import hu.bbara.purefin.core.data.PlaybackMethod
fun playbackCustomCacheKey(
mediaId: String,
playbackUrl: String,
playMethod: PlaybackMethod
): String? {
val normalizedUrl = playbackUrl.substringBefore('?').substringBefore('#').lowercase()
val isAdaptiveManifest =
normalizedUrl.endsWith(".m3u8") ||
normalizedUrl.endsWith(".mpd") ||
normalizedUrl.endsWith(".ism") ||
normalizedUrl.contains(".ism/")
return if (playMethod == PlaybackMethod.DIRECT_PLAY && !isAdaptiveManifest) {
mediaId
} else {
null
}
}

View File

@@ -0,0 +1,10 @@
package hu.bbara.purefin.data.jellyfin.client
import hu.bbara.purefin.core.data.PlaybackReportContext
import org.jellyfin.sdk.model.api.MediaSourceInfo
data class PlaybackDecision(
val url: String,
val mediaSource: MediaSourceInfo,
val reportContext: PlaybackReportContext
)

View File

@@ -0,0 +1,53 @@
package hu.bbara.purefin.data.jellyfin.client
import hu.bbara.purefin.core.data.PlaybackMethod
import hu.bbara.purefin.core.data.PlaybackReportContext
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 -> PlaybackMethod.DIRECT_PLAY
mediaSource.supportsDirectStream && !mediaSource.transcodingUrl.isNullOrBlank() -> PlaybackMethod.DIRECT_STREAM
mediaSource.supportsTranscoding && !mediaSource.transcodingUrl.isNullOrBlank() -> PlaybackMethod.TRANSCODE
else -> return null
}
val url = when (playMethod) {
PlaybackMethod.DIRECT_PLAY -> directPlayUrl(mediaSource)
PlaybackMethod.DIRECT_STREAM,
PlaybackMethod.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('/')}"
}
}

View File

@@ -0,0 +1,27 @@
package hu.bbara.purefin.data.jellyfin.client
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.PlaybackProfileFamily
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)
}
}

View File

@@ -0,0 +1,30 @@
package hu.bbara.purefin.data.jellyfin.client
import hu.bbara.purefin.core.data.PlaybackProfileFamily
import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.api.DeviceProfile
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,
)
}

View File

@@ -0,0 +1,15 @@
package hu.bbara.purefin.data.jellyfin.session
import kotlinx.serialization.Serializable
import hu.bbara.purefin.core.navigation.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
)

View File

@@ -0,0 +1,41 @@
package hu.bbara.purefin.data.jellyfin.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 hu.bbara.purefin.core.data.session.UserSessionRepository
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 DataStoreUserSessionRepository(userSessionDataStore)
}
}

View File

@@ -0,0 +1,55 @@
package hu.bbara.purefin.data.jellyfin.session
import androidx.datastore.core.DataStore
import hu.bbara.purefin.core.data.session.UserSessionRepository
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
class DataStoreUserSessionRepository @Inject constructor(
private val userSessionDataStore: DataStore<UserSession>
) : UserSessionRepository {
val session: Flow<UserSession> = userSessionDataStore.data
override val serverUrl: Flow<String> = session
.map { it.url }
override suspend fun setServerUrl(serverUrl: String) {
userSessionDataStore.updateData {
it.copy(url = serverUrl)
}
}
override val accessToken: Flow<String> = session
.map { it.accessToken }
override suspend fun setAccessToken(accessToken: String) {
userSessionDataStore.updateData {
it.copy(accessToken = accessToken)
}
}
override val userId: Flow<UUID?> = session
.map { it.userId }
override suspend fun setUserId(userId: UUID?) {
userSessionDataStore.updateData {
it.copy(userId = userId)
}
}
override suspend fun getUserId(): UUID? = userId.first()
override val isLoggedIn: Flow<Boolean> = session.map { it.loggedIn }.distinctUntilChanged()
override suspend fun setLoggedIn(isLoggedIn: Boolean) {
userSessionDataStore.updateData {
it.copy(loggedIn = isLoggedIn)
}
}
override val isOfflineMode: Flow<Boolean> = session.map { it.isOfflineMode }.distinctUntilChanged()
}

View File

@@ -0,0 +1,32 @@
package hu.bbara.purefin.data.jellyfin.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(UserSession.serializer(), t)
.encodeToByteArray()
)
}
}

View File

@@ -0,0 +1,371 @@
package hu.bbara.purefin.data.jellyfin.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()
}

View File

@@ -0,0 +1,43 @@
package hu.bbara.purefin.data.jellyfin.client
import hu.bbara.purefin.core.data.PlaybackMethod
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 = PlaybackMethod.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 = PlaybackMethod.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 = PlaybackMethod.DIRECT_PLAY
)
)
}
}

View File

@@ -0,0 +1,151 @@
package hu.bbara.purefin.data.jellyfin.client
import hu.bbara.purefin.core.data.PlaybackMethod
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.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(PlaybackMethod.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(PlaybackMethod.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(PlaybackMethod.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,
)
}
}

View File

@@ -0,0 +1,41 @@
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.data.offline"
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.serialization.json)
implementation(libs.hilt)
ksp(libs.hilt.compiler)
implementation(libs.datastore)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
testImplementation(libs.junit)
}

View File

@@ -0,0 +1,19 @@
package hu.bbara.purefin.data.offline
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.OfflineCatalogStore
import hu.bbara.purefin.core.data.SmartDownloadStore
@Module
@InstallIn(SingletonComponent::class)
abstract class OfflineBindingsModule {
@Binds
abstract fun bindOfflineCatalogStore(impl: RoomOfflineCatalogStore): OfflineCatalogStore
@Binds
abstract fun bindSmartDownloadStore(impl: RoomSmartDownloadStore): SmartDownloadStore
}

View File

@@ -0,0 +1,52 @@
package hu.bbara.purefin.data.offline
import hu.bbara.purefin.core.data.OfflineCatalogStore
import hu.bbara.purefin.data.offline.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)
}
}

View File

@@ -0,0 +1,30 @@
package hu.bbara.purefin.data.offline
import hu.bbara.purefin.core.data.SmartDownloadStore
import hu.bbara.purefin.data.offline.room.dao.SmartDownloadDao
import hu.bbara.purefin.data.offline.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 }
}
}

View File

@@ -0,0 +1,18 @@
package hu.bbara.purefin.data.offline.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()
)

View File

@@ -0,0 +1,32 @@
package hu.bbara.purefin.data.offline.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 }
)
)
}
}

View File

@@ -0,0 +1,30 @@
package hu.bbara.purefin.data.offline.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(HomeCache.serializer(), t)
.encodeToByteArray()
)
}
}

View File

@@ -0,0 +1,57 @@
package hu.bbara.purefin.data.offline.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.data.offline.room.dao.EpisodeDao
import hu.bbara.purefin.data.offline.room.dao.MovieDao
import hu.bbara.purefin.data.offline.room.dao.SeasonDao
import hu.bbara.purefin.data.offline.room.dao.SeriesDao
import hu.bbara.purefin.data.offline.room.dao.SmartDownloadDao
import hu.bbara.purefin.data.offline.room.offline.OfflineMediaDatabase
import hu.bbara.purefin.data.offline.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
)
}

View File

@@ -0,0 +1,42 @@
package hu.bbara.purefin.data.offline.room
import androidx.room.Embedded
import androidx.room.Relation
import hu.bbara.purefin.data.offline.room.entity.EpisodeEntity
import hu.bbara.purefin.data.offline.room.entity.LibraryEntity
import hu.bbara.purefin.data.offline.room.entity.MovieEntity
import hu.bbara.purefin.data.offline.room.entity.SeasonEntity
import hu.bbara.purefin.data.offline.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>
)

View File

@@ -0,0 +1,15 @@
package hu.bbara.purefin.data.offline.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()
}

View File

@@ -0,0 +1,31 @@
package hu.bbara.purefin.data.offline.room.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import hu.bbara.purefin.data.offline.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)
}

View File

@@ -0,0 +1,50 @@
package hu.bbara.purefin.data.offline.room.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import hu.bbara.purefin.data.offline.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
}

View File

@@ -0,0 +1,29 @@
package hu.bbara.purefin.data.offline.room.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import hu.bbara.purefin.data.offline.room.LibraryWithContent
import hu.bbara.purefin.data.offline.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()
}

View File

@@ -0,0 +1,35 @@
package hu.bbara.purefin.data.offline.room.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import hu.bbara.purefin.data.offline.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()
}

View File

@@ -0,0 +1,34 @@
package hu.bbara.purefin.data.offline.room.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import hu.bbara.purefin.data.offline.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
}

View File

@@ -0,0 +1,41 @@
package hu.bbara.purefin.data.offline.room.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import hu.bbara.purefin.data.offline.room.SeriesWithSeasonsAndEpisodes
import hu.bbara.purefin.data.offline.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)
}

View File

@@ -0,0 +1,30 @@
package hu.bbara.purefin.data.offline.room.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import hu.bbara.purefin.data.offline.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>>
}

View File

@@ -0,0 +1,20 @@
package hu.bbara.purefin.data.offline.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
)

View File

@@ -0,0 +1,35 @@
package hu.bbara.purefin.data.offline.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
)

View File

@@ -0,0 +1,13 @@
package hu.bbara.purefin.data.offline.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,
)

View File

@@ -0,0 +1,26 @@
package hu.bbara.purefin.data.offline.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
)

View File

@@ -0,0 +1,28 @@
package hu.bbara.purefin.data.offline.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
)

View File

@@ -0,0 +1,21 @@
package hu.bbara.purefin.data.offline.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
)

View File

@@ -0,0 +1,10 @@
package hu.bbara.purefin.data.offline.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
)

View File

@@ -0,0 +1,36 @@
package hu.bbara.purefin.data.offline.room.offline
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import hu.bbara.purefin.data.offline.room.UuidConverters
import hu.bbara.purefin.data.offline.room.dao.EpisodeDao
import hu.bbara.purefin.data.offline.room.dao.MovieDao
import hu.bbara.purefin.data.offline.room.dao.SeasonDao
import hu.bbara.purefin.data.offline.room.dao.SeriesDao
import hu.bbara.purefin.data.offline.room.dao.SmartDownloadDao
import hu.bbara.purefin.data.offline.room.entity.EpisodeEntity
import hu.bbara.purefin.data.offline.room.entity.MovieEntity
import hu.bbara.purefin.data.offline.room.entity.SeasonEntity
import hu.bbara.purefin.data.offline.room.entity.SeriesEntity
import hu.bbara.purefin.data.offline.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
}

View File

@@ -0,0 +1,320 @@
package hu.bbara.purefin.data.offline.room.offline
import androidx.room.withTransaction
import hu.bbara.purefin.data.offline.room.dao.EpisodeDao
import hu.bbara.purefin.data.offline.room.dao.MovieDao
import hu.bbara.purefin.data.offline.room.dao.SeasonDao
import hu.bbara.purefin.data.offline.room.dao.SeriesDao
import hu.bbara.purefin.data.offline.room.entity.EpisodeEntity
import hu.bbara.purefin.data.offline.room.entity.MovieEntity
import hu.bbara.purefin.data.offline.room.entity.SeasonEntity
import hu.bbara.purefin.data.offline.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()
)
}