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.Add
import androidx.compose.material.icons.outlined.ArrowBack import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast 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.Download
import androidx.compose.material.icons.outlined.DownloadDone
import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text 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.MediaPlaybackSettings
import hu.bbara.purefin.common.ui.components.MediaResumeButton import hu.bbara.purefin.common.ui.components.MediaResumeButton
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.feature.download.DownloadState
import hu.bbara.purefin.player.PlayerActivity import hu.bbara.purefin.player.PlayerActivity
@Composable @Composable
@@ -69,6 +72,8 @@ internal fun EpisodeTopBar(
@Composable @Composable
internal fun EpisodeDetails( internal fun EpisodeDetails(
episode: Episode, episode: Episode,
downloadState: DownloadState,
onDownloadClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -153,8 +158,14 @@ internal fun EpisodeDetails(
MediaActionButton( MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.secondary, backgroundColor = MaterialTheme.colorScheme.secondary,
iconColor = MaterialTheme.colorScheme.onSecondary, iconColor = MaterialTheme.colorScheme.onSecondary,
icon = Icons.Outlined.Download, icon = when (downloadState) {
height = 48.dp 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.common.ui.components.MediaHero
import hu.bbara.purefin.core.data.navigation.EpisodeDto import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.feature.download.DownloadState
import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel
@Composable @Composable
@@ -36,6 +37,7 @@ fun EpisodeScreen(
} }
val episode = viewModel.episode.collectAsState() val episode = viewModel.episode.collectAsState()
val downloadState = viewModel.downloadState.collectAsState()
if (episode.value == null) { if (episode.value == null) {
PurefinWaitingScreen() PurefinWaitingScreen()
@@ -44,7 +46,9 @@ fun EpisodeScreen(
EpisodeScreenInternal( EpisodeScreenInternal(
episode = episode.value!!, episode = episode.value!!,
downloadState = downloadState.value,
onBack = viewModel::onBack, onBack = viewModel::onBack,
onDownloadClick = viewModel::onDownloadClick,
modifier = modifier modifier = modifier
) )
} }
@@ -52,7 +56,9 @@ fun EpisodeScreen(
@Composable @Composable
private fun EpisodeScreenInternal( private fun EpisodeScreenInternal(
episode: Episode, episode: Episode,
downloadState: DownloadState,
onBack: () -> Unit, onBack: () -> Unit,
onDownloadClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -79,6 +85,8 @@ private fun EpisodeScreenInternal(
) )
EpisodeDetails( EpisodeDetails(
episode = episode, episode = episode,
downloadState = downloadState,
onDownloadClick = onDownloadClick,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)

View File

@@ -213,18 +213,18 @@ class InMemoryAppContentRepository @Inject constructor(
} }
} }
suspend fun loadMovie(movie: Movie): Movie { suspend fun loadMovie(movieId: UUID): Movie {
val movieItem = jellyfinApiClient.getItemInfo(movie.id) val movieItem = jellyfinApiClient.getItemInfo(movieId)
?: throw RuntimeException("Movie not found") ?: 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) } mediaRepository._movies.update { it + (updatedMovie.id to updatedMovie) }
return updatedMovie return updatedMovie
} }
suspend fun loadSeries(series: Series): Series { suspend fun loadSeries(seriesId: UUID): Series {
val seriesItem = jellyfinApiClient.getItemInfo(series.id) val seriesItem = jellyfinApiClient.getItemInfo(seriesId)
?: throw RuntimeException("Series not found") ?: 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) } mediaRepository._series.update { it + (updatedSeries.id to updatedSeries) }
return updatedSeries return updatedSeries
} }

View File

@@ -41,4 +41,10 @@ interface EpisodeDao {
@Query("DELETE FROM episodes WHERE seasonId = :seasonId") @Query("DELETE FROM episodes WHERE seasonId = :seasonId")
suspend fun deleteBySeasonId(seasonId: UUID) 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") @Query("DELETE FROM seasons WHERE seriesId = :seriesId")
suspend fun deleteBySeriesId(seriesId: UUID) 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") @Query("DELETE FROM series")
suspend fun clear() 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) { suspend fun saveEpisode(episode: Episode) {
database.withTransaction { database.withTransaction {
seriesDao.getById(episode.seriesId) 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> { suspend fun getMovies(): List<Movie> {
val movies = movieDao.getAll() val movies = movieDao.getAll()
return movies.map { entity -> 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.DownloadManager
import androidx.media3.exoplayer.offline.DownloadRequest import androidx.media3.exoplayer.offline.DownloadRequest
import dagger.hilt.android.qualifiers.ApplicationContext 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.client.JellyfinApiClient
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.room.dao.MovieDao import hu.bbara.purefin.core.data.room.dao.MovieDao
import hu.bbara.purefin.core.data.room.offline.OfflineRoomMediaLocalDataSource import hu.bbara.purefin.core.data.room.offline.OfflineRoomMediaLocalDataSource
import hu.bbara.purefin.core.data.session.UserSessionRepository 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.Movie
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@@ -34,7 +39,8 @@ class MediaDownloadManager @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient, private val jellyfinApiClient: JellyfinApiClient,
private val offlineDataSource: OfflineRoomMediaLocalDataSource, private val offlineDataSource: OfflineRoomMediaLocalDataSource,
private val movieDao: MovieDao, private val movieDao: MovieDao,
private val userSessionRepository: UserSessionRepository private val userSessionRepository: UserSessionRepository,
private val inMemoryMediaRepository: InMemoryMediaRepository,
) { ) {
private val stateFlows = ConcurrentHashMap<String, MutableStateFlow<DownloadState>>() 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> { private fun getOrCreateStateFlow(contentId: String): MutableStateFlow<DownloadState> {
return stateFlows.getOrPut(contentId) { MutableStateFlow(DownloadState.NotDownloaded) } 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" 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 { companion object {
private const val TAG = "MediaDownloadManager" 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.NavigationManager
import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.model.Episode 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.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class EpisodeScreenViewModel @Inject constructor( class EpisodeScreenViewModel @Inject constructor(
private val mediaRepository: MediaRepository, private val appContentRepository: AppContentRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadManager,
): ViewModel() { ): ViewModel() {
private val _episodeId = MutableStateFlow<UUID?>(null) private val _episodeId = MutableStateFlow<UUID?>(null)
val episode: StateFlow<Episode?> = combine( val episode: StateFlow<Episode?> = combine(
_episodeId, _episodeId,
mediaRepository.episodes appContentRepository.episodes
) { id, episodesMap -> ) { id, episodesMap ->
id?.let { episodesMap[it] } id?.let { episodesMap[it] }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
private val _downloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
val downloadState: StateFlow<DownloadState> = _downloadState.asStateFlow()
fun onBack() { fun onBack() {
navigationManager.pop() navigationManager.pop()
} }
@@ -41,6 +49,25 @@ class EpisodeScreenViewModel @Inject constructor(
fun selectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) { fun selectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
_episodeId.value = episodeId _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)
}
}
}
} }
} }