From 8c7ddab9c29e9caf3fa1e1d2f1d428ff94124e82 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sun, 22 Feb 2026 15:41:42 +0100 Subject: [PATCH] feat: Implement Episode download functionality --- .../app/content/episode/EpisodeComponents.kt | 15 ++- .../app/content/episode/EpisodeScreen.kt | 8 ++ .../core/data/InMemoryAppContentRepository.kt | 12 +- .../purefin/core/data/room/dao/EpisodeDao.kt | 6 + .../purefin/core/data/room/dao/SeasonDao.kt | 6 + .../purefin/core/data/room/dao/SeriesDao.kt | 3 + .../OfflineRoomMediaLocalDataSource.kt | 25 ++++ .../feature/download/MediaDownloadManager.kt | 124 +++++++++++++++++- .../content/episode/EpisodeScreenViewModel.kt | 33 ++++- 9 files changed, 220 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt index d490199..909fe26 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt @@ -17,7 +17,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.ArrowBack import androidx.compose.material.icons.outlined.Cast +import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.DownloadDone import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -38,6 +40,7 @@ import hu.bbara.purefin.common.ui.components.MediaActionButton import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings import hu.bbara.purefin.common.ui.components.MediaResumeButton import hu.bbara.purefin.core.model.Episode +import hu.bbara.purefin.feature.download.DownloadState import hu.bbara.purefin.player.PlayerActivity @Composable @@ -69,6 +72,8 @@ internal fun EpisodeTopBar( @Composable internal fun EpisodeDetails( episode: Episode, + downloadState: DownloadState, + onDownloadClick: () -> Unit, modifier: Modifier = Modifier ) { val scheme = MaterialTheme.colorScheme @@ -153,8 +158,14 @@ internal fun EpisodeDetails( MediaActionButton( backgroundColor = MaterialTheme.colorScheme.secondary, iconColor = MaterialTheme.colorScheme.onSecondary, - icon = Icons.Outlined.Download, - height = 48.dp + icon = when (downloadState) { + is DownloadState.NotDownloaded -> Icons.Outlined.Download + is DownloadState.Downloading -> Icons.Outlined.Close + is DownloadState.Downloaded -> Icons.Outlined.DownloadDone + is DownloadState.Failed -> Icons.Outlined.Download + }, + height = 48.dp, + onClick = onDownloadClick ) } } diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt index 919d5c3..5662076 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt @@ -18,6 +18,7 @@ import hu.bbara.purefin.common.ui.PurefinWaitingScreen import hu.bbara.purefin.common.ui.components.MediaHero import hu.bbara.purefin.core.data.navigation.EpisodeDto import hu.bbara.purefin.core.model.Episode +import hu.bbara.purefin.feature.download.DownloadState import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel @Composable @@ -36,6 +37,7 @@ fun EpisodeScreen( } val episode = viewModel.episode.collectAsState() + val downloadState = viewModel.downloadState.collectAsState() if (episode.value == null) { PurefinWaitingScreen() @@ -44,7 +46,9 @@ fun EpisodeScreen( EpisodeScreenInternal( episode = episode.value!!, + downloadState = downloadState.value, onBack = viewModel::onBack, + onDownloadClick = viewModel::onDownloadClick, modifier = modifier ) } @@ -52,7 +56,9 @@ fun EpisodeScreen( @Composable private fun EpisodeScreenInternal( episode: Episode, + downloadState: DownloadState, onBack: () -> Unit, + onDownloadClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -79,6 +85,8 @@ private fun EpisodeScreenInternal( ) EpisodeDetails( episode = episode, + downloadState = downloadState, + onDownloadClick = onDownloadClick, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryAppContentRepository.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryAppContentRepository.kt index c928219..d830504 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryAppContentRepository.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryAppContentRepository.kt @@ -213,18 +213,18 @@ class InMemoryAppContentRepository @Inject constructor( } } - suspend fun loadMovie(movie: Movie): Movie { - val movieItem = jellyfinApiClient.getItemInfo(movie.id) + suspend fun loadMovie(movieId: UUID): Movie { + val movieItem = jellyfinApiClient.getItemInfo(movieId) ?: throw RuntimeException("Movie not found") - val updatedMovie = movieItem.toMovie(serverUrl(), movie.libraryId) + val updatedMovie = movieItem.toMovie(serverUrl(), movieItem.parentId!!) mediaRepository._movies.update { it + (updatedMovie.id to updatedMovie) } return updatedMovie } - suspend fun loadSeries(series: Series): Series { - val seriesItem = jellyfinApiClient.getItemInfo(series.id) + suspend fun loadSeries(seriesId: UUID): Series { + val seriesItem = jellyfinApiClient.getItemInfo(seriesId) ?: throw RuntimeException("Series not found") - val updatedSeries = seriesItem.toSeries(serverUrl(), series.libraryId) + val updatedSeries = seriesItem.toSeries(serverUrl(), seriesItem.parentId!!) mediaRepository._series.update { it + (updatedSeries.id to updatedSeries) } return updatedSeries } diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/room/dao/EpisodeDao.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/room/dao/EpisodeDao.kt index 24729f6..4b6cd2f 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/room/dao/EpisodeDao.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/room/dao/EpisodeDao.kt @@ -41,4 +41,10 @@ interface EpisodeDao { @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 } diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/room/dao/SeasonDao.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/room/dao/SeasonDao.kt index ad361af..d4e5791 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/room/dao/SeasonDao.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/room/dao/SeasonDao.kt @@ -25,4 +25,10 @@ interface SeasonDao { @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 } diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/room/dao/SeriesDao.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/room/dao/SeriesDao.kt index cc200a7..bd03301 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/room/dao/SeriesDao.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/room/dao/SeriesDao.kt @@ -35,4 +35,7 @@ interface SeriesDao { @Query("DELETE FROM series") suspend fun clear() + + @Query("DELETE FROM series WHERE id = :id") + suspend fun deleteById(id: UUID) } diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/room/offline/OfflineRoomMediaLocalDataSource.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/room/offline/OfflineRoomMediaLocalDataSource.kt index 1826e60..81ce808 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/room/offline/OfflineRoomMediaLocalDataSource.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/room/offline/OfflineRoomMediaLocalDataSource.kt @@ -80,6 +80,14 @@ class OfflineRoomMediaLocalDataSource( } } + 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) @@ -89,6 +97,23 @@ class OfflineRoomMediaLocalDataSource( } } + 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 { val movies = movieDao.getAll() return movies.map { entity -> diff --git a/feature/download/src/main/java/hu/bbara/purefin/feature/download/MediaDownloadManager.kt b/feature/download/src/main/java/hu/bbara/purefin/feature/download/MediaDownloadManager.kt index da6f7bc..2ef1acb 100644 --- a/feature/download/src/main/java/hu/bbara/purefin/feature/download/MediaDownloadManager.kt +++ b/feature/download/src/main/java/hu/bbara/purefin/feature/download/MediaDownloadManager.kt @@ -9,17 +9,22 @@ 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.InMemoryMediaRepository import hu.bbara.purefin.core.data.client.JellyfinApiClient import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.room.dao.MovieDao import hu.bbara.purefin.core.data.room.offline.OfflineRoomMediaLocalDataSource 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.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext +import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.ImageType import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -34,7 +39,8 @@ class MediaDownloadManager @Inject constructor( private val jellyfinApiClient: JellyfinApiClient, private val offlineDataSource: OfflineRoomMediaLocalDataSource, private val movieDao: MovieDao, - private val userSessionRepository: UserSessionRepository + private val userSessionRepository: UserSessionRepository, + private val inMemoryMediaRepository: InMemoryMediaRepository, ) { private val stateFlows = ConcurrentHashMap>() @@ -143,6 +149,68 @@ class MediaDownloadManager @Inject constructor( } } + 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 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 (offlineDataSource.getSeriesBasic(series.id) == null) { + offlineDataSource.saveSeries(listOf(series)) + } + + if (offlineDataSource.getSeason(series.id, season.id) == null) { + offlineDataSource.saveSeason(season) + } + + offlineDataSource.saveEpisode(episode) + + Log.d(TAG, "Starting download for episode '${episode.title}' from: $url") + val request = DownloadRequest.Builder(episodeId.toString(), url.toUri()).build() + 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 + } + } + } + + suspend fun cancelEpisodeDownload(episodeId: UUID) { + withContext(Dispatchers.IO) { + PurefinDownloadService.sendRemoveDownload(context, episodeId.toString()) + try { + offlineDataSource.deleteEpisodeAndCleanup(episodeId) + } catch (e: Exception) { + Log.e(TAG, "Failed to remove episode from offline DB", e) + } + } + } + private fun getOrCreateStateFlow(contentId: String): MutableStateFlow { return stateFlows.getOrPut(contentId) { MutableStateFlow(DownloadState.NotDownloaded) } } @@ -165,6 +233,60 @@ class MediaDownloadManager @Inject constructor( 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.", + heroImageUrl = JellyfinImageHelper.toImageUrl( + url = serverUrl, + itemId = id, + type = ImageType.PRIMARY + ), + 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(), + heroImageUrl = JellyfinImageHelper.toImageUrl( + url = serverUrl, + itemId = id, + type = ImageType.PRIMARY + ), + 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" } diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/episode/EpisodeScreenViewModel.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/episode/EpisodeScreenViewModel.kt index 3a1fccc..cc82868 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/episode/EpisodeScreenViewModel.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/episode/EpisodeScreenViewModel.kt @@ -3,33 +3,41 @@ package hu.bbara.purefin.feature.shared.content.episode import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import hu.bbara.purefin.core.data.MediaRepository +import hu.bbara.purefin.core.data.AppContentRepository import hu.bbara.purefin.core.data.navigation.NavigationManager import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.model.Episode +import hu.bbara.purefin.feature.download.DownloadState +import hu.bbara.purefin.feature.download.MediaDownloadManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import org.jellyfin.sdk.model.UUID import javax.inject.Inject @HiltViewModel class EpisodeScreenViewModel @Inject constructor( - private val mediaRepository: MediaRepository, + private val appContentRepository: AppContentRepository, private val navigationManager: NavigationManager, + private val mediaDownloadManager: MediaDownloadManager, ): ViewModel() { private val _episodeId = MutableStateFlow(null) val episode: StateFlow = combine( _episodeId, - mediaRepository.episodes + appContentRepository.episodes ) { id, episodesMap -> id?.let { episodesMap[it] } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + private val _downloadState = MutableStateFlow(DownloadState.NotDownloaded) + val downloadState: StateFlow = _downloadState.asStateFlow() + fun onBack() { navigationManager.pop() } @@ -41,6 +49,25 @@ class EpisodeScreenViewModel @Inject constructor( fun selectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) { _episodeId.value = episodeId + viewModelScope.launch { + mediaDownloadManager.observeDownloadState(episodeId.toString()).collect { + _downloadState.value = it + } + } + } + + fun onDownloadClick() { + val episodeId = _episodeId.value ?: return + viewModelScope.launch { + when (_downloadState.value) { + is DownloadState.NotDownloaded, is DownloadState.Failed -> { + mediaDownloadManager.downloadEpisode(episodeId) + } + is DownloadState.Downloading, is DownloadState.Downloaded -> { + mediaDownloadManager.cancelEpisodeDownload(episodeId) + } + } + } } }