feat: add batch download for seasons and entire series

Wire up the existing download button on the Series screen to download
all episodes, and add a per-season download button next to the season
tabs. Episode metadata is fetched in parallel for faster queuing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 21:50:12 +01:00
parent cf078c760e
commit 2d278bd348
4 changed files with 129 additions and 5 deletions

View File

@@ -7,14 +7,19 @@ import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.data.navigation.EpisodeDto
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.core.model.Series
import hu.bbara.purefin.feature.download.DownloadState
import hu.bbara.purefin.feature.download.MediaDownloadManager
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID
import javax.inject.Inject
@@ -22,6 +27,7 @@ import javax.inject.Inject
class SeriesViewModel @Inject constructor(
private val mediaRepository: MediaRepository,
private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadManager,
) : ViewModel() {
private val _seriesId = MutableStateFlow<UUID?>(null)
@@ -33,6 +39,65 @@ class SeriesViewModel @Inject constructor(
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
private val _seriesDownloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
val seriesDownloadState: StateFlow<DownloadState> = _seriesDownloadState
private val _seasonDownloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
val seasonDownloadState: StateFlow<DownloadState> = _seasonDownloadState
fun observeSeasonDownloadState(episodes: List<Episode>) {
viewModelScope.launch {
if (episodes.isEmpty()) {
_seasonDownloadState.value = DownloadState.NotDownloaded
return@launch
}
val flows = episodes.map { mediaDownloadManager.observeDownloadState(it.id.toString()) }
combine(flows) { states -> aggregateDownloadStates(states.toList()) }
.collect { _seasonDownloadState.value = it }
}
}
fun observeSeriesDownloadState(series: Series) {
viewModelScope.launch {
val allEpisodes = series.seasons.flatMap { it.episodes }
if (allEpisodes.isEmpty()) {
_seriesDownloadState.value = DownloadState.NotDownloaded
return@launch
}
val flows = allEpisodes.map { mediaDownloadManager.observeDownloadState(it.id.toString()) }
combine(flows) { states -> aggregateDownloadStates(states.toList()) }
.collect { _seriesDownloadState.value = it }
}
}
fun downloadSeason(episodes: List<Episode>) {
viewModelScope.launch {
mediaDownloadManager.downloadEpisodes(episodes.map { it.id })
}
}
fun downloadSeries(series: Series) {
viewModelScope.launch {
val allEpisodeIds = series.seasons.flatMap { season ->
season.episodes.map { it.id }
}
mediaDownloadManager.downloadEpisodes(allEpisodeIds)
}
}
private fun aggregateDownloadStates(states: List<DownloadState>): DownloadState {
if (states.isEmpty()) return DownloadState.NotDownloaded
if (states.all { it is DownloadState.Downloaded }) return DownloadState.Downloaded
if (states.any { it is DownloadState.Downloading }) {
val avg = states.filterIsInstance<DownloadState.Downloading>()
.map { it.progressPercent }
.average()
.toFloat()
return DownloadState.Downloading(avg)
}
return DownloadState.NotDownloaded
}
fun onSelectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
navigationManager.navigate(Route.EpisodeRoute(
EpisodeDto(