feat: Implement Episode download functionality

This commit is contained in:
2026-02-22 15:41:42 +01:00
parent 9ec09a0e94
commit 8c7ddab9c2
9 changed files with 220 additions and 12 deletions

View File

@@ -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
)
}
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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<Movie> {
val movies = movieDao.getAll()
return movies.map { entity ->

View File

@@ -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<String, MutableStateFlow<DownloadState>>()
@@ -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<DownloadState> {
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"
}

View File

@@ -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<UUID?>(null)
val episode: StateFlow<Episode?> = combine(
_episodeId,
mediaRepository.episodes
appContentRepository.episodes
) { id, episodesMap ->
id?.let { episodesMap[it] }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
private val _downloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
val downloadState: StateFlow<DownloadState> = _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)
}
}
}
}
}