From 3941c67d8b1fba7027f5cb2eafea3ad26961b110 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Tue, 3 Mar 2026 22:21:21 +0100 Subject: [PATCH] feat: add smart download feature for series MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../app/content/series/SeriesComponents.kt | 82 +++++++++++++++++-- .../app/content/series/SeriesScreen.kt | 5 +- .../core/data/room/MediaDatabaseModule.kt | 4 + .../core/data/room/dao/SmartDownloadDao.kt | 30 +++++++ .../data/room/entity/SmartDownloadEntity.kt | 10 +++ .../data/room/offline/OfflineMediaDatabase.kt | 6 +- .../feature/download/MediaDownloadManager.kt | 74 +++++++++++++++++ .../shared/content/series/SeriesViewModel.kt | 17 ++++ .../feature/shared/home/HomePageViewModel.kt | 14 +++- 9 files changed, 230 insertions(+), 12 deletions(-) create mode 100644 core/data/src/main/java/hu/bbara/purefin/core/data/room/dao/SmartDownloadDao.kt create mode 100644 core/data/src/main/java/hu/bbara/purefin/core/data/room/entity/SmartDownloadEntity.kt diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt index 6f38403..c316a8e 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt @@ -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 diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt index 1ec5116..5913c70 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt @@ -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( diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/room/MediaDatabaseModule.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/room/MediaDatabaseModule.kt index 20cd441..3211874 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/room/MediaDatabaseModule.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/room/MediaDatabaseModule.kt @@ -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( diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/room/dao/SmartDownloadDao.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/room/dao/SmartDownloadDao.kt new file mode 100644 index 0000000..068a7e4 --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/room/dao/SmartDownloadDao.kt @@ -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 + + @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 + + @Query("SELECT * FROM smart_downloads") + fun observeAll(): Flow> +} diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/room/entity/SmartDownloadEntity.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/room/entity/SmartDownloadEntity.kt new file mode 100644 index 0000000..ea3d84e --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/room/entity/SmartDownloadEntity.kt @@ -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 +) diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/room/offline/OfflineMediaDatabase.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/room/offline/OfflineMediaDatabase.kt index dabd083..dff7c5f 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/room/offline/OfflineMediaDatabase.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/room/offline/OfflineMediaDatabase.kt @@ -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 } \ No newline at end of file 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 073e379..f73ba5c 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 @@ -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 = 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() + 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() + 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 { 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 } } diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/series/SeriesViewModel.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/series/SeriesViewModel.kt index c23bf40..9f1e8f7 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/series/SeriesViewModel.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/series/SeriesViewModel.kt @@ -39,6 +39,13 @@ class SeriesViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + @OptIn(ExperimentalCoroutinesApi::class) + val isSmartDownloadEnabled: StateFlow = _seriesId + .flatMapLatest { id -> + if (id != null) mediaDownloadManager.isSmartDownloadEnabled(id) else flowOf(false) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + private val _seriesDownloadState = MutableStateFlow(DownloadState.NotDownloaded) val seriesDownloadState: StateFlow = _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 -> diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomePageViewModel.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomePageViewModel.kt index 51b8043..5562a33 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomePageViewModel.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomePageViewModel.kt @@ -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() {