feat: add smart download feature for series

Automatically manages downloaded episodes per series — keeps 5 unwatched
episodes downloaded, removing watched ones and fetching new ones on
HomeScreen open or pull-to-refresh. A single download button on the
Series screen opens a dialog to choose between downloading all episodes
or enabling smart download.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 22:21:21 +01:00
parent 2d278bd348
commit 3941c67d8b
9 changed files with 230 additions and 12 deletions

View File

@@ -33,14 +33,20 @@ 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.Autorenew
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier
@@ -116,10 +122,14 @@ internal fun SeriesMetaChips(series: Series) {
internal fun SeriesActionButtons(
nextUpEpisode: Episode?,
downloadState: DownloadState,
onDownloadClick: () -> Unit,
isSmartDownloadEnabled: Boolean,
onDownloadAllClick: () -> Unit,
onSmartDownloadToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val scheme = MaterialTheme.colorScheme
var showDownloadDialog by remember { mutableStateOf(false) }
val episodeId = nextUpEpisode?.id
val playAction = remember(episodeId) {
episodeId?.let { id ->
@@ -148,18 +158,72 @@ internal fun SeriesActionButtons(
)
Spacer(modifier = Modifier.width(12.dp))
MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.secondary,
iconColor = MaterialTheme.colorScheme.onSecondary,
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
backgroundColor = if (isSmartDownloadEnabled) scheme.primary else scheme.secondary,
iconColor = if (isSmartDownloadEnabled) scheme.onPrimary else scheme.onSecondary,
icon = when {
isSmartDownloadEnabled -> Icons.Outlined.Autorenew
downloadState is DownloadState.Downloading -> Icons.Outlined.Close
downloadState is DownloadState.Downloaded -> Icons.Outlined.DownloadDone
else -> Icons.Outlined.Download
},
height = 32.dp,
onClick = onDownloadClick
onClick = { showDownloadDialog = true }
)
}
if (showDownloadDialog) {
DownloadOptionsDialog(
isSmartDownloadEnabled = isSmartDownloadEnabled,
onDownloadAll = {
showDownloadDialog = false
onDownloadAllClick()
},
onSmartDownload = {
showDownloadDialog = false
onSmartDownloadToggle()
},
onDismiss = { showDownloadDialog = false }
)
}
}
@Composable
private fun DownloadOptionsDialog(
isSmartDownloadEnabled: Boolean,
onDownloadAll: () -> Unit,
onSmartDownload: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Download") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = "Choose how to download this series.",
style = MaterialTheme.typography.bodyMedium
)
if (isSmartDownloadEnabled) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Smart download is currently enabled.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
},
confirmButton = {
TextButton(onClick = onDownloadAll) {
Text("Download All")
}
},
dismissButton = {
TextButton(onClick = onSmartDownload) {
Text(if (isSmartDownloadEnabled) "Disable Smart Download" else "Smart Download")
}
}
)
}
@Composable

View File

@@ -89,6 +89,7 @@ private fun SeriesScreenInternal(
val seriesDownloadState by viewModel.seriesDownloadState.collectAsState()
val seasonDownloadState by viewModel.seasonDownloadState.collectAsState()
val isSmartDownloadEnabled by viewModel.isSmartDownloadEnabled.collectAsState()
LaunchedEffect(selectedSeason.value) {
viewModel.observeSeasonDownloadState(selectedSeason.value.episodes)
@@ -134,7 +135,9 @@ private fun SeriesScreenInternal(
SeriesActionButtons(
nextUpEpisode = nextUpEpisode,
downloadState = seriesDownloadState,
onDownloadClick = { viewModel.downloadSeries(series) }
isSmartDownloadEnabled = isSmartDownloadEnabled,
onDownloadAllClick = { viewModel.downloadSeries(series) },
onSmartDownloadToggle = { viewModel.toggleSmartDownload(series.id) }
)
Spacer(modifier = Modifier.height(24.dp))
MediaSynopsis(

View File

@@ -11,6 +11,7 @@ import hu.bbara.purefin.core.data.room.dao.EpisodeDao
import hu.bbara.purefin.core.data.room.dao.MovieDao
import hu.bbara.purefin.core.data.room.dao.SeasonDao
import hu.bbara.purefin.core.data.room.dao.SeriesDao
import hu.bbara.purefin.core.data.room.dao.SmartDownloadDao
import hu.bbara.purefin.core.data.room.offline.OfflineMediaDatabase
import hu.bbara.purefin.core.data.room.offline.OfflineRoomMediaLocalDataSource
import javax.inject.Singleton
@@ -39,6 +40,9 @@ object MediaDatabaseModule {
@Provides
fun provideOfflineEpisodeDao(db: OfflineMediaDatabase) = db.episodeDao()
@Provides
fun provideSmartDownloadDao(db: OfflineMediaDatabase): SmartDownloadDao = db.smartDownloadDao()
@Provides
@Singleton
fun provideOfflineDataSource(

View File

@@ -0,0 +1,30 @@
package hu.bbara.purefin.core.data.room.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import hu.bbara.purefin.core.data.room.entity.SmartDownloadEntity
import kotlinx.coroutines.flow.Flow
import java.util.UUID
@Dao
interface SmartDownloadDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entity: SmartDownloadEntity)
@Query("DELETE FROM smart_downloads WHERE seriesId = :seriesId")
suspend fun delete(seriesId: UUID)
@Query("SELECT * FROM smart_downloads")
suspend fun getAll(): List<SmartDownloadEntity>
@Query("SELECT EXISTS(SELECT 1 FROM smart_downloads WHERE seriesId = :seriesId)")
suspend fun exists(seriesId: UUID): Boolean
@Query("SELECT EXISTS(SELECT 1 FROM smart_downloads WHERE seriesId = :seriesId)")
fun observe(seriesId: UUID): Flow<Boolean>
@Query("SELECT * FROM smart_downloads")
fun observeAll(): Flow<List<SmartDownloadEntity>>
}

View File

@@ -0,0 +1,10 @@
package hu.bbara.purefin.core.data.room.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.UUID
@Entity(tableName = "smart_downloads")
data class SmartDownloadEntity(
@PrimaryKey val seriesId: UUID
)

View File

@@ -8,10 +8,12 @@ import hu.bbara.purefin.core.data.room.dao.EpisodeDao
import hu.bbara.purefin.core.data.room.dao.MovieDao
import hu.bbara.purefin.core.data.room.dao.SeasonDao
import hu.bbara.purefin.core.data.room.dao.SeriesDao
import hu.bbara.purefin.core.data.room.dao.SmartDownloadDao
import hu.bbara.purefin.core.data.room.entity.EpisodeEntity
import hu.bbara.purefin.core.data.room.entity.MovieEntity
import hu.bbara.purefin.core.data.room.entity.SeasonEntity
import hu.bbara.purefin.core.data.room.entity.SeriesEntity
import hu.bbara.purefin.core.data.room.entity.SmartDownloadEntity
@Database(
entities = [
@@ -19,8 +21,9 @@ import hu.bbara.purefin.core.data.room.entity.SeriesEntity
SeriesEntity::class,
SeasonEntity::class,
EpisodeEntity::class,
SmartDownloadEntity::class,
],
version = 5,
version = 6,
exportSchema = false
)
@TypeConverters(UuidConverters::class)
@@ -29,4 +32,5 @@ abstract class OfflineMediaDatabase : RoomDatabase() {
abstract fun seriesDao(): SeriesDao
abstract fun seasonDao(): SeasonDao
abstract fun episodeDao(): EpisodeDao
abstract fun smartDownloadDao(): SmartDownloadDao
}

View File

@@ -13,6 +13,8 @@ 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.dao.SmartDownloadDao
import hu.bbara.purefin.core.data.room.entity.SmartDownloadEntity
import hu.bbara.purefin.core.data.room.offline.OfflineRoomMediaLocalDataSource
import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.model.Episode
@@ -46,6 +48,7 @@ class MediaDownloadManager @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val offlineDataSource: OfflineRoomMediaLocalDataSource,
private val movieDao: MovieDao,
private val smartDownloadDao: SmartDownloadDao,
private val userSessionRepository: UserSessionRepository,
private val inMemoryMediaRepository: InMemoryMediaRepository,
) {
@@ -261,6 +264,76 @@ class MediaDownloadManager @Inject constructor(
}
}
// ── Smart Download ──────────────────────────────────────────────────
suspend fun enableSmartDownload(seriesId: UUID) {
smartDownloadDao.insert(SmartDownloadEntity(seriesId))
syncSmartDownloadsForSeries(seriesId)
}
suspend fun disableSmartDownload(seriesId: UUID) {
smartDownloadDao.delete(seriesId)
}
fun isSmartDownloadEnabled(seriesId: UUID): Flow<Boolean> = smartDownloadDao.observe(seriesId)
suspend fun syncSmartDownloads() {
withContext(Dispatchers.IO) {
val enabled = smartDownloadDao.getAll()
for (entry in enabled) {
try {
syncSmartDownloadsForSeries(entry.seriesId)
} catch (e: Exception) {
Log.e(TAG, "Smart download sync failed for series ${entry.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 = offlineDataSource.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) }
}
@@ -339,5 +412,6 @@ class MediaDownloadManager @Inject constructor(
companion object {
private const val TAG = "MediaDownloadManager"
private const val SMART_DOWNLOAD_COUNT = 5
}
}

View File

@@ -39,6 +39,13 @@ class SeriesViewModel @Inject constructor(
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
@OptIn(ExperimentalCoroutinesApi::class)
val isSmartDownloadEnabled: StateFlow<Boolean> = _seriesId
.flatMapLatest { id ->
if (id != null) mediaDownloadManager.isSmartDownloadEnabled(id) else flowOf(false)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
private val _seriesDownloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
val seriesDownloadState: StateFlow<DownloadState> = _seriesDownloadState
@@ -76,6 +83,16 @@ class SeriesViewModel @Inject constructor(
}
}
fun toggleSmartDownload(seriesId: UUID) {
viewModelScope.launch {
if (isSmartDownloadEnabled.value) {
mediaDownloadManager.disableSmartDownload(seriesId)
} else {
mediaDownloadManager.enableSmartDownload(seriesId)
}
}
}
fun downloadSeries(series: Series) {
viewModelScope.launch {
val allEpisodeIds = series.seasons.flatMap { season ->

View File

@@ -12,6 +12,7 @@ import hu.bbara.purefin.core.data.navigation.NavigationManager
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.data.navigation.SeriesDto
import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.feature.download.MediaDownloadManager
import hu.bbara.purefin.core.model.Media
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -31,7 +32,8 @@ class HomePageViewModel @Inject constructor(
private val appContentRepository: AppContentRepository,
private val userSessionRepository: UserSessionRepository,
private val navigationManager: NavigationManager,
private val refreshHomeDataUseCase: RefreshHomeDataUseCase
private val refreshHomeDataUseCase: RefreshHomeDataUseCase,
private val mediaDownloadManager: MediaDownloadManager,
) : ViewModel() {
private val _isRefreshing = MutableStateFlow(false)
@@ -199,6 +201,11 @@ class HomePageViewModel @Inject constructor(
// Refresh is best-effort; don't crash on failure
}
}
viewModelScope.launch {
try {
mediaDownloadManager.syncSmartDownloads()
} catch (_: Exception) { }
}
}
fun onRefresh() {
@@ -212,6 +219,11 @@ class HomePageViewModel @Inject constructor(
_isRefreshing.value = false
}
}
viewModelScope.launch {
try {
mediaDownloadManager.syncSmartDownloads()
} catch (_: Exception) { }
}
}
fun logout() {