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

@@ -3,13 +3,12 @@ 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.feature.shared"
namespace = "hu.bbara.purefin.feature.browse"
compileSdk = 36
defaultConfig {
@@ -31,12 +30,12 @@ kotlin {
dependencies {
implementation(project(":core:model"))
implementation(project(":core:data"))
implementation(project(":core:image"))
implementation(project(":core:navigation"))
implementation(project(":core:download"))
implementation(libs.androidx.navigation3.runtime)
implementation(libs.hilt)
ksp(libs.hilt.compiler)
implementation(libs.jellyfin.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.navigation3.runtime)
testImplementation(libs.junit)
}

View File

@@ -1,22 +1,21 @@
package hu.bbara.purefin.feature.shared.home
package hu.bbara.purefin.feature.browse.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.HomeRepository
import hu.bbara.purefin.core.data.MediaCatalogReader
import hu.bbara.purefin.core.data.domain.usecase.RefreshHomeDataUseCase
import hu.bbara.purefin.core.data.download.MediaDownloadController
import hu.bbara.purefin.core.download.MediaDownloadController
import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.model.LibraryKind
import hu.bbara.purefin.core.model.Media
import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.feature.shared.navigation.EpisodeDto
import hu.bbara.purefin.feature.shared.navigation.LibraryDto
import hu.bbara.purefin.feature.shared.navigation.MovieDto
import hu.bbara.purefin.feature.shared.navigation.NavigationManager
import hu.bbara.purefin.feature.shared.navigation.Route
import hu.bbara.purefin.feature.shared.navigation.SeriesDto
import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.core.navigation.LibraryDto
import hu.bbara.purefin.core.navigation.MovieDto
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.core.navigation.SeriesDto
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -34,7 +33,6 @@ class AppViewModel @Inject constructor(
private val mediaCatalogReader: MediaCatalogReader,
private val userSessionRepository: UserSessionRepository,
private val navigationManager: NavigationManager,
private val refreshHomeDataUseCase: RefreshHomeDataUseCase,
private val mediaDownloadManager: MediaDownloadController,
) : ViewModel() {
@@ -207,7 +205,7 @@ class AppViewModel @Inject constructor(
fun onResumed() {
viewModelScope.launch {
try {
refreshHomeDataUseCase()
homeRepository.refreshHomeData()
} catch (e: Exception) {
// Refresh is best-effort; don't crash on failure
}
@@ -223,7 +221,7 @@ class AppViewModel @Inject constructor(
viewModelScope.launch {
_isRefreshing.value = true
try {
refreshHomeDataUseCase()
homeRepository.refreshHomeData()
} catch (e: Exception) {
// Refresh is best-effort; don't crash on failure
} finally {

View File

@@ -1,13 +1,13 @@
package hu.bbara.purefin.feature.shared.home
package hu.bbara.purefin.feature.browse.home
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
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.Series
import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.core.model.LibraryKind
import org.jellyfin.sdk.model.api.ImageType
import hu.bbara.purefin.core.image.ArtworkKind
sealed interface SuggestedItem {
@@ -38,9 +38,9 @@ data class SuggestedEpisode (
override val id: UUID = episode.id,
override val title: String = episode.title,
override val description: String = episode.synopsis,
override val imageUrl: String = JellyfinImageHelper.finishImageUrl(
override val imageUrl: String = ImageUrlBuilder.finishImageUrl(
prefixImageUrl = episode.imageUrlPrefix,
imageType = ImageType.PRIMARY
artworkKind = ArtworkKind.PRIMARY
)
) : SuggestedItem
@@ -61,9 +61,9 @@ data class SuggestedSeries (
override val id: UUID = series.id,
override val title: String = series.name,
override val description: String = series.synopsis,
override val imageUrl: String = JellyfinImageHelper.finishImageUrl(
override val imageUrl: String = ImageUrlBuilder.finishImageUrl(
prefixImageUrl = series.imageUrlPrefix,
imageType = ImageType.PRIMARY
artworkKind = ArtworkKind.PRIMARY
)
) : SuggestedItem
@@ -82,9 +82,9 @@ data class SuggestedMovie (
override val id: UUID = movie.id,
override val title: String = movie.title,
override val description: String = movie.synopsis,
override val imageUrl: String = JellyfinImageHelper.finishImageUrl(
override val imageUrl: String = ImageUrlBuilder.finishImageUrl(
prefixImageUrl = movie.imageUrlPrefix,
imageType = ImageType.PRIMARY
artworkKind = ArtworkKind.PRIMARY
)
) : SuggestedItem
@@ -118,13 +118,13 @@ data class ContinueWatchingItem(
else -> throw UnsupportedOperationException("Unsupported item type: $type")
}
override val imageUrl: String = when (type) {
MediaKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
MediaKind.MOVIE -> ImageUrlBuilder.finishImageUrl(
prefixImageUrl = movie!!.imageUrlPrefix,
imageType = ImageType.PRIMARY
artworkKind = ArtworkKind.PRIMARY
)
MediaKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
MediaKind.EPISODE -> ImageUrlBuilder.finishImageUrl(
prefixImageUrl = episode!!.imageUrlPrefix,
imageType = ImageType.PRIMARY
artworkKind = ArtworkKind.PRIMARY
)
else -> throw IllegalArgumentException("Invalid type: $type")
}
@@ -147,9 +147,9 @@ data class NextUpItem(
override val type: MediaKind
get() = MediaKind.EPISODE
override val imageUrl: String
get() = JellyfinImageHelper.finishImageUrl(
get() = ImageUrlBuilder.finishImageUrl(
prefixImageUrl = episode.imageUrlPrefix,
imageType = ImageType.PRIMARY
artworkKind = ArtworkKind.PRIMARY
)
override val primaryText: String = episode.title
override val secondaryText: String = episode.releaseDate
@@ -184,17 +184,17 @@ data class PosterItem(
else -> throw IllegalArgumentException("Invalid type: $type")
}
override val imageUrl: String = when (type) {
MediaKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
MediaKind.MOVIE -> ImageUrlBuilder.finishImageUrl(
prefixImageUrl = movie!!.imageUrlPrefix,
imageType = ImageType.PRIMARY
artworkKind = ArtworkKind.PRIMARY
)
MediaKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
MediaKind.EPISODE -> ImageUrlBuilder.finishImageUrl(
prefixImageUrl = episode!!.imageUrlPrefix,
imageType = ImageType.PRIMARY
artworkKind = ArtworkKind.PRIMARY
)
MediaKind.SERIES -> JellyfinImageHelper.finishImageUrl(
MediaKind.SERIES -> ImageUrlBuilder.finishImageUrl(
prefixImageUrl = series!!.imageUrlPrefix,
imageType = ImageType.PRIMARY
artworkKind = ArtworkKind.PRIMARY
)
else -> throw IllegalArgumentException("Invalid type: $type")
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.feature.shared.library
package hu.bbara.purefin.feature.browse.library
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -6,11 +6,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.HomeRepository
import hu.bbara.purefin.core.model.LibraryKind
import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.feature.shared.navigation.MovieDto
import hu.bbara.purefin.feature.shared.navigation.NavigationManager
import hu.bbara.purefin.feature.shared.navigation.Route
import hu.bbara.purefin.feature.shared.navigation.SeriesDto
import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.core.navigation.MovieDto
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.core.navigation.SeriesDto
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted

View File

@@ -0,0 +1,40 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
android {
namespace = "hu.bbara.purefin.feature.content"
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:navigation"))
implementation(project(":core:download"))
implementation(libs.androidx.navigation3.runtime)
implementation(libs.hilt)
ksp(libs.hilt.compiler)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
testImplementation(libs.junit)
}

View File

@@ -1,15 +1,15 @@
package hu.bbara.purefin.feature.shared.content.episode
package hu.bbara.purefin.feature.content.episode
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaCatalogReader
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.download.MediaDownloadController
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.download.MediaDownloadController
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.feature.shared.navigation.NavigationManager
import hu.bbara.purefin.feature.shared.navigation.Route
import hu.bbara.purefin.feature.shared.navigation.SeriesDto
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.core.navigation.SeriesDto
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted

View File

@@ -1,14 +1,14 @@
package hu.bbara.purefin.feature.shared.content.movie
package hu.bbara.purefin.feature.content.movie
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaCatalogReader
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.download.MediaDownloadController
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.download.MediaDownloadController
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.navigation.NavigationManager
import hu.bbara.purefin.feature.shared.navigation.Route
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted

View File

@@ -1,16 +1,16 @@
package hu.bbara.purefin.feature.shared.content.series
package hu.bbara.purefin.feature.content.series
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaCatalogReader
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.download.MediaDownloadController
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.download.MediaDownloadController
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.shared.navigation.EpisodeDto
import hu.bbara.purefin.feature.shared.navigation.NavigationManager
import hu.bbara.purefin.feature.shared.navigation.Route
import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
import java.util.UUID
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow

View File

@@ -1,19 +0,0 @@
package hu.bbara.purefin.feature.download
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.download.MediaDownloadController
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class DownloadControllerModule {
@Binds
@Singleton
abstract fun bindMediaDownloadController(
impl: MediaDownloadManager
): MediaDownloadController
}

View File

@@ -1,89 +0,0 @@
package hu.bbara.purefin.feature.download
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.CacheEvictor
import androidx.media3.datasource.cache.NoOpCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.offline.DownloadManager
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.io.File
import java.util.concurrent.Executors
import javax.inject.Singleton
@OptIn(UnstableApi::class)
internal object DownloadModuleFactories {
fun createDownloadCacheEvictor(): CacheEvictor {
return NoOpCacheEvictor()
}
fun newPhonePlaybackDataSourceFactory(
upstreamDataSourceFactory: DataSource.Factory
): CacheDataSource.Factory {
return CacheDataSource.Factory()
.setUpstreamDataSourceFactory(upstreamDataSourceFactory)
.setCacheWriteDataSinkFactory(null)
}
}
@OptIn(UnstableApi::class)
@Module
@InstallIn(SingletonComponent::class)
object DownloadModule {
private const val DOWNLOAD_CHANNEL_ID = "purefin_downloads"
@Provides
@Singleton
fun provideDownloadCache(@ApplicationContext context: Context): SimpleCache {
val downloadDir = File(context.getExternalFilesDir(null), "downloads")
return SimpleCache(
downloadDir,
DownloadModuleFactories.createDownloadCacheEvictor(),
StandaloneDatabaseProvider(context)
)
}
@Provides
@Singleton
fun provideDownloadNotificationHelper(@ApplicationContext context: Context): DownloadNotificationHelper {
return DownloadNotificationHelper(context, DOWNLOAD_CHANNEL_ID)
}
@Provides
@Singleton
fun provideDownloadManager(
@ApplicationContext context: Context,
cache: SimpleCache,
okHttpDataSourceFactory: OkHttpDataSource.Factory
): DownloadManager {
return DownloadManager(
context,
StandaloneDatabaseProvider(context),
cache,
okHttpDataSourceFactory,
Executors.newFixedThreadPool(2)
)
}
@Provides
@Singleton
fun providePlaybackDataSourceFactory(
cache: SimpleCache,
okHttpDataSourceFactory: OkHttpDataSource.Factory
): DataSource.Factory {
return DownloadModuleFactories
.newPhonePlaybackDataSourceFactory(okHttpDataSourceFactory)
.setCache(cache)
}
}

View File

@@ -1,447 +0,0 @@
package hu.bbara.purefin.feature.download
import android.content.Context
import android.util.Log
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.offline.Download
import androidx.media3.exoplayer.offline.DownloadManager
import androidx.media3.exoplayer.offline.DownloadRequest
import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.core.data.OfflineCatalogStore
import hu.bbara.purefin.core.data.SmartDownloadStore
import hu.bbara.purefin.core.data.client.playbackCustomCacheKey
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.download.MediaDownloadController
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@OptIn(UnstableApi::class)
@Singleton
class MediaDownloadManager @Inject constructor(
@ApplicationContext private val context: Context,
private val downloadManager: DownloadManager,
private val jellyfinApiClient: JellyfinApiClient,
private val offlineCatalogStore: OfflineCatalogStore,
private val smartDownloadStore: SmartDownloadStore,
private val userSessionRepository: UserSessionRepository,
) : MediaDownloadController {
private val stateFlows = ConcurrentHashMap<String, MutableStateFlow<DownloadState>>()
init {
downloadManager.resumeDownloads()
downloadManager.addListener(object : DownloadManager.Listener {
override fun onDownloadChanged(
manager: DownloadManager,
download: Download,
finalException: Exception?
) {
val contentId = download.request.id
val state = download.toDownloadState()
Log.d(TAG, "Download changed: $contentId -> $state (${download.percentDownloaded}%)")
if (finalException != null) {
Log.e(TAG, "Download exception for $contentId", finalException)
}
getOrCreateStateFlow(contentId).value = state
}
override fun onDownloadRemoved(manager: DownloadManager, download: Download) {
val contentId = download.request.id
Log.d(TAG, "Download removed: $contentId")
getOrCreateStateFlow(contentId).value = DownloadState.NotDownloaded
}
})
}
/**
* Polls the download index every 500 ms and emits a map of contentId → progress (0100)
* for every download that is currently queued or in progress.
* Uses the download index directly so it always reflects the true download state,
* regardless of listener callback timing.
*/
override fun observeActiveDownloads(): Flow<Map<String, Float>> = flow {
while (true) {
try {
val result = buildMap<String, Float> {
val cursor = downloadManager.downloadIndex.getDownloads(
Download.STATE_QUEUED,
Download.STATE_DOWNLOADING,
Download.STATE_RESTARTING
)
cursor.use {
while (it.moveToNext()) {
val d = it.download
put(d.request.id, d.percentDownloaded)
}
}
}
emit(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to poll active downloads", e)
emit(emptyMap())
}
delay(500)
}
}.flowOn(Dispatchers.IO).distinctUntilChanged()
override fun observeDownloadState(contentId: String): StateFlow<DownloadState> {
val flow = getOrCreateStateFlow(contentId)
// Initialize from current download index
val download = downloadManager.downloadIndex.getDownload(contentId)
flow.value = download?.toDownloadState() ?: DownloadState.NotDownloaded
return flow
}
fun isDownloaded(contentId: String): Boolean {
return downloadManager.downloadIndex.getDownload(contentId)?.state == Download.STATE_COMPLETED
}
override suspend fun downloadMovie(movieId: UUID) {
withContext(Dispatchers.IO) {
try {
val sources = jellyfinApiClient.getMediaSources(movieId)
val source = sources.firstOrNull() ?: run {
Log.e(TAG, "No media sources for $movieId")
return@withContext
}
val shouldTranscode = source.shouldUseTranscodingUrl()
val url = jellyfinApiClient.getMediaPlaybackUrl(movieId, source) ?: run {
Log.e(TAG, "No playback URL for $movieId")
return@withContext
}
val itemInfo = jellyfinApiClient.getItemInfo(movieId) ?: run {
Log.e(TAG, "No item info for $movieId")
return@withContext
}
val serverUrl = userSessionRepository.serverUrl.first().trim()
val movie = Movie(
id = itemInfo.id,
libraryId = itemInfo.parentId ?: UUID.randomUUID(),
title = itemInfo.name ?: "Unknown title",
progress = itemInfo.userData?.playedPercentage,
watched = itemInfo.userData?.played ?: false,
year = itemInfo.productionYear?.toString()
?: itemInfo.premiereDate?.year?.toString().orEmpty(),
rating = itemInfo.officialRating ?: "NR",
runtime = formatRuntime(itemInfo.runTimeTicks),
synopsis = itemInfo.overview ?: "No synopsis available",
format = itemInfo.container?.uppercase() ?: "VIDEO",
imageUrlPrefix = JellyfinImageHelper.toPrefixImageUrl(
url = serverUrl,
itemId = itemInfo.id
),
subtitles = "ENG",
audioTrack = "ENG",
cast = emptyList()
)
offlineCatalogStore.saveMovies(listOf(movie))
Log.d(TAG, "Starting download for '${movie.title}' from: $url")
val request = buildDownloadRequest(
mediaId = movieId,
playbackUrl = url,
title = movie.title,
isDirectPlay = !shouldTranscode
)
PurefinDownloadService.sendAddDownload(context, request)
Log.d(TAG, "Download request sent for $movieId")
} catch (e: Exception) {
Log.e(TAG, "Failed to start download for $movieId", e)
getOrCreateStateFlow(movieId.toString()).value = DownloadState.Failed
}
}
}
override suspend fun cancelDownload(movieId: UUID) {
withContext(Dispatchers.IO) {
PurefinDownloadService.sendRemoveDownload(context, movieId.toString())
try {
offlineCatalogStore.deleteMovie(movieId)
} catch (e: Exception) {
Log.e(TAG, "Failed to remove movie from offline DB", e)
}
}
}
override suspend fun downloadEpisode(episodeId: UUID) {
withContext(Dispatchers.IO) {
try {
val serverUrl = userSessionRepository.serverUrl.first().trim()
val sources = jellyfinApiClient.getMediaSources(episodeId)
val source = sources.firstOrNull() ?: run {
Log.e(TAG, "No media sources for episode $episodeId")
return@withContext
}
val shouldTranscode = source.shouldUseTranscodingUrl()
val url = jellyfinApiClient.getMediaPlaybackUrl(episodeId, source) ?: run {
Log.e(TAG, "No playback URL for episode $episodeId")
return@withContext
}
val episode = jellyfinApiClient.getItemInfo(episodeId)?.toEpisode(serverUrl) ?: run {
Log.e(TAG, "Episode not found $episodeId")
return@withContext
}
val series = jellyfinApiClient.getItemInfo(episode.seriesId)?.toSeries(serverUrl) ?: run {
Log.e(TAG, "Series not found ${episode.seriesId}")
return@withContext
}
val season = jellyfinApiClient.getItemInfo(episode.seasonId)?.toSeason(series.id) ?: run {
Log.e(TAG, "Season not found ${episode.seasonId}")
return@withContext
}
if (offlineCatalogStore.getSeriesBasic(series.id) == null) {
offlineCatalogStore.saveSeries(listOf(series))
}
if (offlineCatalogStore.getSeason(season.id) == null) {
offlineCatalogStore.saveSeason(season)
}
offlineCatalogStore.saveEpisode(episode)
Log.d(TAG, "Starting download for episode '${episode.title}' from: $url")
val request = buildDownloadRequest(
mediaId = episodeId,
playbackUrl = url,
title = episode.title,
isDirectPlay = !shouldTranscode
)
PurefinDownloadService.sendAddDownload(context, request)
Log.d(TAG, "Download request sent for episode $episodeId")
} catch (e: Exception) {
Log.e(TAG, "Failed to start download for episode $episodeId", e)
getOrCreateStateFlow(episodeId.toString()).value = DownloadState.Failed
}
}
}
override suspend fun downloadEpisodes(episodeIds: List<UUID>) {
coroutineScope {
for (episodeId in episodeIds) {
launch { downloadEpisode(episodeId) }
}
}
}
override suspend fun cancelEpisodeDownload(episodeId: UUID) {
withContext(Dispatchers.IO) {
PurefinDownloadService.sendRemoveDownload(context, episodeId.toString())
try {
offlineCatalogStore.deleteEpisodeAndCleanup(episodeId)
} catch (e: Exception) {
Log.e(TAG, "Failed to remove episode from offline DB", e)
}
}
}
// ── Smart Download ──────────────────────────────────────────────────
override suspend fun enableSmartDownload(seriesId: UUID) {
smartDownloadStore.enable(seriesId)
syncSmartDownloadsForSeries(seriesId)
}
suspend fun disableSmartDownload(seriesId: UUID) {
smartDownloadStore.disable(seriesId)
}
fun isSmartDownloadEnabled(seriesId: UUID): Flow<Boolean> = smartDownloadStore.observe(seriesId)
override suspend fun syncSmartDownloads() {
withContext(Dispatchers.IO) {
val enabledSeriesIds = smartDownloadStore.getEnabledSeriesIds()
for (seriesId in enabledSeriesIds) {
try {
syncSmartDownloadsForSeries(seriesId)
} catch (e: Exception) {
Log.e(TAG, "Smart download sync failed for series $seriesId", e)
}
}
}
}
private suspend fun syncSmartDownloadsForSeries(seriesId: UUID) {
withContext(Dispatchers.IO) {
val serverUrl = userSessionRepository.serverUrl.first().trim()
// 1. Get currently downloaded episodes for this series
val downloadedEpisodes = offlineCatalogStore.getEpisodesBySeries(seriesId)
// 2. Check watched status from server and delete watched downloads
val unwatchedDownloaded = mutableListOf<UUID>()
for (episode in downloadedEpisodes) {
val itemInfo = jellyfinApiClient.getItemInfo(episode.id)
val isWatched = itemInfo?.userData?.played ?: false
if (isWatched) {
Log.d(TAG, "Smart download: removing watched episode ${episode.title}")
cancelEpisodeDownload(episode.id)
} else {
unwatchedDownloaded.add(episode.id)
}
}
// 3. Get all episodes of the series from the server in order
val seasons = jellyfinApiClient.getSeasons(seriesId)
val allEpisodes = mutableListOf<BaseItemDto>()
for (season in seasons) {
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, season.id)
allEpisodes.addAll(episodes)
}
// 4. Find unwatched episodes not already downloaded
val needed = SMART_DOWNLOAD_COUNT - unwatchedDownloaded.size
if (needed <= 0) return@withContext
val toDownload = allEpisodes
.filter { ep -> ep.userData?.played != true && ep.id !in unwatchedDownloaded }
.take(needed)
.map { it.id }
if (toDownload.isNotEmpty()) {
Log.d(TAG, "Smart download: queuing ${toDownload.size} episodes for series $seriesId")
downloadEpisodes(toDownload)
}
}
}
private fun getOrCreateStateFlow(contentId: String): MutableStateFlow<DownloadState> {
return stateFlows.getOrPut(contentId) { MutableStateFlow(DownloadState.NotDownloaded) }
}
private fun buildDownloadRequest(
mediaId: UUID,
playbackUrl: String,
title: String,
isDirectPlay: Boolean
): DownloadRequest {
val builder = DownloadRequest.Builder(mediaId.toString(), playbackUrl.toUri())
.setData(title.toByteArray(Charsets.UTF_8))
if (isDirectPlay) {
playbackCustomCacheKey(
mediaId = mediaId.toString(),
playbackUrl = playbackUrl,
playMethod = PlayMethod.DIRECT_PLAY
)?.let(builder::setCustomCacheKey)
}
return builder.build()
}
private fun Download.toDownloadState(): DownloadState = when (state) {
Download.STATE_COMPLETED -> DownloadState.Downloaded
Download.STATE_DOWNLOADING -> DownloadState.Downloading(percentDownloaded)
Download.STATE_QUEUED, Download.STATE_RESTARTING -> DownloadState.Downloading(0f)
Download.STATE_FAILED -> DownloadState.Failed
Download.STATE_REMOVING -> DownloadState.NotDownloaded
Download.STATE_STOPPED -> DownloadState.NotDownloaded
else -> DownloadState.NotDownloaded
}
private fun formatRuntime(ticks: Long?): String {
if (ticks == null || ticks <= 0) return ""
val totalSeconds = ticks / 10_000_000
val hours = java.util.concurrent.TimeUnit.SECONDS.toHours(totalSeconds)
val minutes = java.util.concurrent.TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
}
private fun BaseItemDto.toEpisode(serverUrl: String): Episode {
return Episode(
id = id,
seriesId = seriesId!!,
seasonId = parentId!!,
title = name ?: "Unknown title",
index = indexNumber ?: 0,
releaseDate = productionYear?.toString() ?: "",
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
progress = userData?.playedPercentage,
watched = userData?.played ?: false,
format = container?.uppercase() ?: "VIDEO",
synopsis = overview ?: "No synopsis available.",
imageUrlPrefix = JellyfinImageHelper.toPrefixImageUrl(
url = serverUrl,
itemId = id
),
cast = emptyList()
)
}
private fun BaseItemDto.toSeries(serverUrl: String): Series {
return Series(
id = id,
libraryId = parentId ?: java.util.UUID.randomUUID(),
name = name ?: "Unknown",
synopsis = overview ?: "No synopsis available",
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
imageUrlPrefix = JellyfinImageHelper.toPrefixImageUrl(
url = serverUrl,
itemId = id
),
unwatchedEpisodeCount = userData?.unplayedItemCount ?: 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()
)
}
companion object {
private const val TAG = "MediaDownloadManager"
private const val SMART_DOWNLOAD_COUNT = 5
}
}
private fun MediaSourceInfo.shouldUseTranscodingUrl(): Boolean {
return supportsTranscoding == true &&
(supportsDirectPlay == false || transcodingUrl != null)
}

View File

@@ -1,140 +0,0 @@
package hu.bbara.purefin.feature.download
import android.app.Notification
import android.content.Context
import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.offline.Download
import androidx.media3.exoplayer.offline.DownloadManager
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
import androidx.media3.exoplayer.offline.DownloadRequest
import androidx.media3.exoplayer.offline.DownloadService
import androidx.media3.exoplayer.scheduler.Scheduler
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
@OptIn(UnstableApi::class)
class PurefinDownloadService : DownloadService(
FOREGROUND_NOTIFICATION_ID,
DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL,
DOWNLOAD_CHANNEL_ID,
R.string.download_channel_name,
R.string.download_channel_description
) {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface DownloadServiceEntryPoint {
fun downloadManager(): DownloadManager
fun downloadNotificationHelper(): DownloadNotificationHelper
}
private val entryPoint: DownloadServiceEntryPoint by lazy {
EntryPointAccessors.fromApplication(applicationContext, DownloadServiceEntryPoint::class.java)
}
private var lastBytesDownloaded: Long = 0L
private var lastUpdateTimeMs: Long = 0L
override fun getDownloadManager(): DownloadManager = entryPoint.downloadManager()
override fun getForegroundNotification(
downloads: MutableList<Download>,
notMetRequirements: Int
): Notification {
val activeDownloads = downloads.filter { it.state == Download.STATE_DOWNLOADING }
if (activeDownloads.isEmpty()) {
return entryPoint.downloadNotificationHelper().buildProgressNotification(
this,
R.drawable.ic_launcher_foreground,
null,
null,
downloads,
notMetRequirements
)
}
val totalBytes = activeDownloads.sumOf { it.bytesDownloaded }
val now = System.currentTimeMillis()
val speedText = if (lastUpdateTimeMs > 0L) {
val elapsed = (now - lastUpdateTimeMs).coerceAtLeast(1)
val bytesPerSec = (totalBytes - lastBytesDownloaded) * 1000L / elapsed
formatSpeed(bytesPerSec)
} else {
""
}
lastBytesDownloaded = totalBytes
lastUpdateTimeMs = now
val percent = if (activeDownloads.size == 1) {
activeDownloads[0].percentDownloaded
} else {
activeDownloads.map { it.percentDownloaded }.average().toFloat()
}
val title = if (activeDownloads.size == 1) {
activeDownloads[0].request.data
?.toString(Charsets.UTF_8)
?.takeIf { it.isNotBlank() }
?: "Downloading"
} else {
"Downloading ${activeDownloads.size} files"
}
val contentText = buildString {
append("${percent.toInt()}%")
if (speedText.isNotEmpty()) {
append(" · $speedText")
}
}
return NotificationCompat.Builder(this, DOWNLOAD_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title)
.setContentText(contentText)
.setProgress(100, percent.toInt(), false)
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
}
override fun getScheduler(): Scheduler? = null
private fun formatSpeed(bytesPerSec: Long): String {
if (bytesPerSec <= 0) return ""
return when {
bytesPerSec >= 1_000_000 -> String.format("%.1f MB/s", bytesPerSec / 1_000_000.0)
bytesPerSec >= 1_000 -> String.format("%.0f KB/s", bytesPerSec / 1_000.0)
else -> "$bytesPerSec B/s"
}
}
companion object {
private const val FOREGROUND_NOTIFICATION_ID = 1
private const val DOWNLOAD_CHANNEL_ID = "purefin_downloads"
fun sendAddDownload(context: Context, request: DownloadRequest) {
sendAddDownload(
context,
PurefinDownloadService::class.java,
request,
false
)
}
fun sendRemoveDownload(context: Context, contentId: String) {
sendRemoveDownload(
context,
PurefinDownloadService::class.java,
contentId,
false
)
}
}
}

View File

@@ -1,30 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="download_channel_name">Downloads</string>
<string name="download_channel_description">Media download progress</string>
</resources>

View File

@@ -1,43 +0,0 @@
package hu.bbara.purefin.feature.download
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.NoOpCacheEvictor
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class DownloadModuleTest {
@Test
fun `download cache keeps no-op evictor`() {
assertTrue(DownloadModuleFactories.createDownloadCacheEvictor() is NoOpCacheEvictor)
}
@Test
fun `phone playback data source reads download cache and writes upstream only`() {
val upstreamFactory = FakeDataSourceFactory()
val factory = DownloadModuleFactories.newPhonePlaybackDataSourceFactory(upstreamFactory)
assertTrue(readPrivateBoolean(factory, "cacheIsReadOnly"))
assertNull(readPrivateField(factory, "cacheWriteDataSinkFactory"))
assertSame(upstreamFactory, readPrivateField(factory, "upstreamDataSourceFactory"))
}
private fun readPrivateBoolean(instance: CacheDataSource.Factory, fieldName: String): Boolean {
return readPrivateField(instance, fieldName) as Boolean
}
private fun readPrivateField(instance: CacheDataSource.Factory, fieldName: String): Any? {
val field = instance.javaClass.getDeclaredField(fieldName)
field.isAccessible = true
return field.get(instance)
}
}
private class FakeDataSourceFactory : DataSource.Factory {
override fun createDataSource(): DataSource {
throw UnsupportedOperationException("Unused in unit tests")
}
}

View File

@@ -0,0 +1,42 @@
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.feature.downloads"
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(project(":core:download"))
implementation(project(":feature:browse"))
implementation(libs.androidx.navigation3.runtime)
implementation(libs.hilt)
ksp(libs.hilt.compiler)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
testImplementation(libs.junit)
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.feature.shared.download
package hu.bbara.purefin.feature.downloads
data class ActiveDownloadItem(
val contentId: String,

View File

@@ -1,23 +1,23 @@
package hu.bbara.purefin.feature.shared.download
package hu.bbara.purefin.feature.downloads
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.OfflineCatalogReader
import hu.bbara.purefin.core.data.download.MediaDownloadController
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.download.MediaDownloadController
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.feature.shared.navigation.MovieDto
import hu.bbara.purefin.feature.shared.navigation.NavigationManager
import hu.bbara.purefin.feature.shared.navigation.Route
import hu.bbara.purefin.feature.shared.navigation.SeriesDto
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.core.navigation.MovieDto
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.core.navigation.SeriesDto
import hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.ImageType
import hu.bbara.purefin.core.image.ArtworkKind
import javax.inject.Inject
@HiltViewModel
@@ -72,7 +72,7 @@ class DownloadsViewModel @Inject constructor(
contentId = contentId,
title = movie.title,
subtitle = "",
imageUrl = JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY),
imageUrl = ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.PRIMARY),
progress = progress
)
} else {
@@ -82,7 +82,7 @@ class DownloadsViewModel @Inject constructor(
contentId = contentId,
title = it.title,
subtitle = seriesMap[it.seriesId]?.name ?: "",
imageUrl = JellyfinImageHelper.finishImageUrl(it.imageUrlPrefix, ImageType.PRIMARY),
imageUrl = ImageUrlBuilder.finishImageUrl(it.imageUrlPrefix, ArtworkKind.PRIMARY),
progress = progress
)
}

View File

@@ -0,0 +1,36 @@
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.feature.login"
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:data"))
implementation(libs.hilt)
ksp(libs.hilt.compiler)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
testImplementation(libs.junit)
}

View File

@@ -1,9 +1,9 @@
package hu.bbara.purefin.feature.shared.login
package hu.bbara.purefin.feature.login
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.AuthenticationRepository
import hu.bbara.purefin.core.data.session.UserSessionRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -15,7 +15,7 @@ import javax.inject.Inject
@HiltViewModel
class LoginViewModel @Inject constructor(
private val userSessionRepository: UserSessionRepository,
private val jellyfinApiClient: JellyfinApiClient,
private val authenticationRepository: AuthenticationRepository,
) : ViewModel() {
private val _username = MutableStateFlow("")
val username: StateFlow<String> = _username.asStateFlow()
@@ -57,7 +57,7 @@ class LoginViewModel @Inject constructor(
suspend fun login(): Boolean {
_errorMessage.value = null
userSessionRepository.setServerUrl(url.value)
val success = jellyfinApiClient.login(url.value, username.value, password.value)
val success = authenticationRepository.login(url.value, username.value, password.value)
if (!success) {
_errorMessage.value = "Login failed. Check your server URL, username, and password."
}

View File

@@ -8,7 +8,7 @@ plugins {
}
android {
namespace = "hu.bbara.purefin.feature.download"
namespace = "hu.bbara.purefin.feature.search"
compileSdk = 36
defaultConfig {
@@ -30,11 +30,9 @@ kotlin {
dependencies {
implementation(project(":core:model"))
implementation(project(":core:data"))
implementation(project(":core:image"))
implementation(libs.hilt)
ksp(libs.hilt.compiler)
implementation(libs.medi3.exoplayer)
implementation(libs.media3.datasource.okhttp)
implementation(libs.okhttp)
implementation(libs.jellyfin.core)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
testImplementation(libs.junit)
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.feature.shared.search
package hu.bbara.purefin.feature.search
import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.core.model.Movie

View File

@@ -1,10 +1,10 @@
package hu.bbara.purefin.feature.shared.search
package hu.bbara.purefin.feature.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaCatalogReader
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.data.session.UserSessionRepository
import java.util.UUID
import kotlinx.coroutines.FlowPreview
@@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import org.jellyfin.sdk.model.api.ImageType
import hu.bbara.purefin.core.image.ArtworkKind
import javax.inject.Inject
@HiltViewModel
@@ -55,7 +55,7 @@ class SearchViewModel @Inject constructor(
}
private suspend fun createImageUrl(id: UUID) : String {
return JellyfinImageHelper.toImageUrl(userSessionRepository.serverUrl.first(), id,
ImageType.PRIMARY)
return ImageUrlBuilder.toImageUrl(userSessionRepository.serverUrl.first(), id,
ArtworkKind.PRIMARY)
}
}

View File

@@ -1,15 +0,0 @@
package hu.bbara.purefin.feature.shared.navigation
import java.util.UUID
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.serializer.UUIDSerializer
@Serializable
data class EpisodeDto(
@Serializable(with = UUIDSerializer::class)
val id: UUID,
@Serializable(with = UUIDSerializer::class)
val seasonId: UUID,
@Serializable(with = UUIDSerializer::class)
val seriesId: UUID,
)

View File

@@ -1,12 +0,0 @@
package hu.bbara.purefin.feature.shared.navigation
import java.util.UUID
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.serializer.UUIDSerializer
@Serializable
data class LibraryDto(
@Serializable(with = UUIDSerializer::class)
val id: UUID,
val name: String,
)

View File

@@ -1,11 +0,0 @@
package hu.bbara.purefin.feature.shared.navigation
import java.util.UUID
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.serializer.UUIDSerializer
@Serializable
data class MovieDto(
@Serializable(with = UUIDSerializer::class)
val id: UUID,
)

View File

@@ -1,40 +0,0 @@
package hu.bbara.purefin.feature.shared.navigation
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
sealed interface NavigationCommand {
data class Navigate(val route: Route) : NavigationCommand
data class ReplaceAll(val route: Route) : NavigationCommand
data object Pop : NavigationCommand
}
interface NavigationManager {
val commands: SharedFlow<NavigationCommand>
fun navigate(route: Route)
fun replaceAll(route: Route)
fun pop()
}
class DefaultNavigationManager : NavigationManager {
private val commandsFlow = MutableSharedFlow<NavigationCommand>(
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val commands: SharedFlow<NavigationCommand> = commandsFlow.asSharedFlow()
override fun navigate(route: Route) {
commandsFlow.tryEmit(NavigationCommand.Navigate(route))
}
override fun replaceAll(route: Route) {
commandsFlow.tryEmit(NavigationCommand.ReplaceAll(route))
}
override fun pop() {
commandsFlow.tryEmit(NavigationCommand.Pop)
}
}

View File

@@ -1,15 +0,0 @@
package hu.bbara.purefin.feature.shared.navigation
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NavigationManagerModule {
@Provides
@Singleton
fun provideNavigationManager(): NavigationManager = DefaultNavigationManager()
}

View File

@@ -1,28 +0,0 @@
package hu.bbara.purefin.feature.shared.navigation
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
sealed interface Route : NavKey {
@Serializable
data object Home : Route
@Serializable
data class MovieRoute(val item: MovieDto) : Route
@Serializable
data class SeriesRoute(val item: SeriesDto) : Route
@Serializable
data class EpisodeRoute(val item: EpisodeDto) : Route
@Serializable
data class LibraryRoute(val library: LibraryDto) : Route
@Serializable
data object LoginRoute : Route
@Serializable
data class PlayerRoute(val mediaId: String) : Route
}

View File

@@ -1,11 +0,0 @@
package hu.bbara.purefin.feature.shared.navigation
import java.util.UUID
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.serializer.UUIDSerializer
@Serializable
data class SeriesDto(
@Serializable(with = UUIDSerializer::class)
val id: UUID,
)

View File

@@ -1,66 +0,0 @@
package hu.bbara.purefin.feature.shared.navigation
import java.util.UUID
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Test
class RouteSerializationTest {
private val json = Json
@Test
fun `episode route round trips with nested dto ids`() {
val route: Route = Route.EpisodeRoute(
item = EpisodeDto(
id = UUID.randomUUID(),
seasonId = UUID.randomUUID(),
seriesId = UUID.randomUUID(),
)
)
assertRoundTrip(route)
}
@Test
fun `library route round trips`() {
val route: Route = Route.LibraryRoute(
library = LibraryDto(
id = UUID.randomUUID(),
name = "Shows",
)
)
assertRoundTrip(route)
}
@Test
fun `movie and series routes round trip`() {
assertRoundTrip(
Route.MovieRoute(
item = MovieDto(id = UUID.randomUUID())
)
)
assertRoundTrip(
Route.SeriesRoute(
item = SeriesDto(id = UUID.randomUUID())
)
)
}
@Test
fun `singleton routes round trip`() {
assertRoundTrip(Route.Home)
assertRoundTrip(Route.LoginRoute)
assertRoundTrip(Route.PlayerRoute(mediaId = UUID.randomUUID().toString()))
}
private fun assertRoundTrip(route: Route) {
val encoded = json.encodeToString(route)
val decoded = json.decodeFromString<Route>(encoded)
assertEquals(route, decoded)
}
}