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.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.DownloadDone
import androidx.compose.material.icons.outlined.Autorenew
import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -116,10 +122,14 @@ internal fun SeriesMetaChips(series: Series) {
internal fun SeriesActionButtons( internal fun SeriesActionButtons(
nextUpEpisode: Episode?, nextUpEpisode: Episode?,
downloadState: DownloadState, downloadState: DownloadState,
onDownloadClick: () -> Unit, isSmartDownloadEnabled: Boolean,
onDownloadAllClick: () -> Unit,
onSmartDownloadToggle: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scheme = MaterialTheme.colorScheme
var showDownloadDialog by remember { mutableStateOf(false) }
val episodeId = nextUpEpisode?.id val episodeId = nextUpEpisode?.id
val playAction = remember(episodeId) { val playAction = remember(episodeId) {
episodeId?.let { id -> episodeId?.let { id ->
@@ -148,18 +158,72 @@ internal fun SeriesActionButtons(
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
MediaActionButton( MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.secondary, backgroundColor = if (isSmartDownloadEnabled) scheme.primary else scheme.secondary,
iconColor = MaterialTheme.colorScheme.onSecondary, iconColor = if (isSmartDownloadEnabled) scheme.onPrimary else scheme.onSecondary,
icon = when (downloadState) { icon = when {
is DownloadState.NotDownloaded -> Icons.Outlined.Download isSmartDownloadEnabled -> Icons.Outlined.Autorenew
is DownloadState.Downloading -> Icons.Outlined.Close downloadState is DownloadState.Downloading -> Icons.Outlined.Close
is DownloadState.Downloaded -> Icons.Outlined.DownloadDone downloadState is DownloadState.Downloaded -> Icons.Outlined.DownloadDone
is DownloadState.Failed -> Icons.Outlined.Download else -> Icons.Outlined.Download
}, },
height = 32.dp, 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 @Composable

View File

@@ -89,6 +89,7 @@ private fun SeriesScreenInternal(
val seriesDownloadState by viewModel.seriesDownloadState.collectAsState() val seriesDownloadState by viewModel.seriesDownloadState.collectAsState()
val seasonDownloadState by viewModel.seasonDownloadState.collectAsState() val seasonDownloadState by viewModel.seasonDownloadState.collectAsState()
val isSmartDownloadEnabled by viewModel.isSmartDownloadEnabled.collectAsState()
LaunchedEffect(selectedSeason.value) { LaunchedEffect(selectedSeason.value) {
viewModel.observeSeasonDownloadState(selectedSeason.value.episodes) viewModel.observeSeasonDownloadState(selectedSeason.value.episodes)
@@ -134,7 +135,9 @@ private fun SeriesScreenInternal(
SeriesActionButtons( SeriesActionButtons(
nextUpEpisode = nextUpEpisode, nextUpEpisode = nextUpEpisode,
downloadState = seriesDownloadState, downloadState = seriesDownloadState,
onDownloadClick = { viewModel.downloadSeries(series) } isSmartDownloadEnabled = isSmartDownloadEnabled,
onDownloadAllClick = { viewModel.downloadSeries(series) },
onSmartDownloadToggle = { viewModel.toggleSmartDownload(series.id) }
) )
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
MediaSynopsis( 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.MovieDao
import hu.bbara.purefin.core.data.room.dao.SeasonDao 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.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.OfflineMediaDatabase
import hu.bbara.purefin.core.data.room.offline.OfflineRoomMediaLocalDataSource import hu.bbara.purefin.core.data.room.offline.OfflineRoomMediaLocalDataSource
import javax.inject.Singleton import javax.inject.Singleton
@@ -39,6 +40,9 @@ object MediaDatabaseModule {
@Provides @Provides
fun provideOfflineEpisodeDao(db: OfflineMediaDatabase) = db.episodeDao() fun provideOfflineEpisodeDao(db: OfflineMediaDatabase) = db.episodeDao()
@Provides
fun provideSmartDownloadDao(db: OfflineMediaDatabase): SmartDownloadDao = db.smartDownloadDao()
@Provides @Provides
@Singleton @Singleton
fun provideOfflineDataSource( 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.MovieDao
import hu.bbara.purefin.core.data.room.dao.SeasonDao 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.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.EpisodeEntity
import hu.bbara.purefin.core.data.room.entity.MovieEntity 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.SeasonEntity
import hu.bbara.purefin.core.data.room.entity.SeriesEntity import hu.bbara.purefin.core.data.room.entity.SeriesEntity
import hu.bbara.purefin.core.data.room.entity.SmartDownloadEntity
@Database( @Database(
entities = [ entities = [
@@ -19,8 +21,9 @@ import hu.bbara.purefin.core.data.room.entity.SeriesEntity
SeriesEntity::class, SeriesEntity::class,
SeasonEntity::class, SeasonEntity::class,
EpisodeEntity::class, EpisodeEntity::class,
SmartDownloadEntity::class,
], ],
version = 5, version = 6,
exportSchema = false exportSchema = false
) )
@TypeConverters(UuidConverters::class) @TypeConverters(UuidConverters::class)
@@ -29,4 +32,5 @@ abstract class OfflineMediaDatabase : RoomDatabase() {
abstract fun seriesDao(): SeriesDao abstract fun seriesDao(): SeriesDao
abstract fun seasonDao(): SeasonDao abstract fun seasonDao(): SeasonDao
abstract fun episodeDao(): EpisodeDao 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.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.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.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.Episode
@@ -46,6 +48,7 @@ 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 smartDownloadDao: SmartDownloadDao,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val inMemoryMediaRepository: InMemoryMediaRepository, 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> { private fun getOrCreateStateFlow(contentId: String): MutableStateFlow<DownloadState> {
return stateFlows.getOrPut(contentId) { MutableStateFlow(DownloadState.NotDownloaded) } return stateFlows.getOrPut(contentId) { MutableStateFlow(DownloadState.NotDownloaded) }
} }
@@ -339,5 +412,6 @@ class MediaDownloadManager @Inject constructor(
companion object { companion object {
private const val TAG = "MediaDownloadManager" 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) .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) private val _seriesDownloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
val seriesDownloadState: StateFlow<DownloadState> = _seriesDownloadState 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) { fun downloadSeries(series: Series) {
viewModelScope.launch { viewModelScope.launch {
val allEpisodeIds = series.seasons.flatMap { season -> 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.Route
import hu.bbara.purefin.core.data.navigation.SeriesDto import hu.bbara.purefin.core.data.navigation.SeriesDto
import hu.bbara.purefin.core.data.session.UserSessionRepository import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.feature.download.MediaDownloadManager
import hu.bbara.purefin.core.model.Media import hu.bbara.purefin.core.model.Media
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -31,7 +32,8 @@ class HomePageViewModel @Inject constructor(
private val appContentRepository: AppContentRepository, private val appContentRepository: AppContentRepository,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val refreshHomeDataUseCase: RefreshHomeDataUseCase private val refreshHomeDataUseCase: RefreshHomeDataUseCase,
private val mediaDownloadManager: MediaDownloadManager,
) : ViewModel() { ) : ViewModel() {
private val _isRefreshing = MutableStateFlow(false) private val _isRefreshing = MutableStateFlow(false)
@@ -199,6 +201,11 @@ class HomePageViewModel @Inject constructor(
// Refresh is best-effort; don't crash on failure // Refresh is best-effort; don't crash on failure
} }
} }
viewModelScope.launch {
try {
mediaDownloadManager.syncSmartDownloads()
} catch (_: Exception) { }
}
} }
fun onRefresh() { fun onRefresh() {
@@ -212,6 +219,11 @@ class HomePageViewModel @Inject constructor(
_isRefreshing.value = false _isRefreshing.value = false
} }
} }
viewModelScope.launch {
try {
mediaDownloadManager.syncSmartDownloads()
} catch (_: Exception) { }
}
} }
fun logout() { fun logout() {