Separate mobile downloads from TV playback

This commit is contained in:
2026-04-13 16:31:57 +02:00
parent 9a130e9738
commit 3509a09064
26 changed files with 369 additions and 60 deletions

View File

@@ -36,4 +36,5 @@ dependencies {
implementation(libs.media3.datasource.okhttp)
implementation(libs.okhttp)
implementation(libs.jellyfin.core)
testImplementation(libs.junit)
}

View File

@@ -0,0 +1,19 @@
package hu.bbara.purefin.feature.download
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.download.MediaDownloadController
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class DownloadControllerModule {
@Binds
@Singleton
abstract fun bindMediaDownloadController(
impl: MediaDownloadManager
): MediaDownloadController
}

View File

@@ -4,7 +4,9 @@ import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.CacheEvictor
import androidx.media3.datasource.cache.NoOpCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
@@ -15,11 +17,25 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import java.io.File
import java.util.concurrent.Executors
import javax.inject.Singleton
@OptIn(UnstableApi::class)
internal object DownloadModuleFactories {
fun createDownloadCacheEvictor(): CacheEvictor {
return NoOpCacheEvictor()
}
fun newPhonePlaybackDataSourceFactory(
upstreamDataSourceFactory: DataSource.Factory
): CacheDataSource.Factory {
return CacheDataSource.Factory()
.setUpstreamDataSourceFactory(upstreamDataSourceFactory)
.setCacheWriteDataSinkFactory(null)
}
}
@OptIn(UnstableApi::class)
@Module
@InstallIn(SingletonComponent::class)
@@ -31,13 +47,11 @@ object DownloadModule {
@Singleton
fun provideDownloadCache(@ApplicationContext context: Context): SimpleCache {
val downloadDir = File(context.getExternalFilesDir(null), "downloads")
return SimpleCache(downloadDir, NoOpCacheEvictor(), StandaloneDatabaseProvider(context))
}
@Provides
@Singleton
fun provideOkHttpDataSourceFactory(okHttpClient: OkHttpClient): OkHttpDataSource.Factory {
return OkHttpDataSource.Factory(okHttpClient)
return SimpleCache(
downloadDir,
DownloadModuleFactories.createDownloadCacheEvictor(),
StandaloneDatabaseProvider(context)
)
}
@Provides
@@ -64,12 +78,12 @@ object DownloadModule {
@Provides
@Singleton
fun provideCacheDataSourceFactory(
fun providePlaybackDataSourceFactory(
cache: SimpleCache,
okHttpDataSourceFactory: OkHttpDataSource.Factory
): CacheDataSource.Factory {
return CacheDataSource.Factory()
): DataSource.Factory {
return DownloadModuleFactories
.newPhonePlaybackDataSourceFactory(okHttpDataSourceFactory)
.setCache(cache)
.setUpstreamDataSourceFactory(okHttpDataSourceFactory)
}
}

View File

@@ -1,8 +0,0 @@
package hu.bbara.purefin.feature.download
sealed class DownloadState {
data object NotDownloaded : DownloadState()
data class Downloading(val progressPercent: Float) : DownloadState()
data object Downloaded : DownloadState()
data object Failed : DownloadState()
}

View File

@@ -10,8 +10,11 @@ import androidx.media3.exoplayer.offline.DownloadManager
import androidx.media3.exoplayer.offline.DownloadRequest
import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.core.data.InMemoryMediaRepository
import hu.bbara.purefin.core.data.client.playbackCustomCacheKey
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.download.MediaDownloadController
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
@@ -33,9 +36,11 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import java.util.UUID
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@@ -51,7 +56,7 @@ class MediaDownloadManager @Inject constructor(
private val smartDownloadDao: SmartDownloadDao,
private val userSessionRepository: UserSessionRepository,
private val inMemoryMediaRepository: InMemoryMediaRepository,
) {
) : MediaDownloadController {
private val stateFlows = ConcurrentHashMap<String, MutableStateFlow<DownloadState>>()
@@ -87,7 +92,7 @@ class MediaDownloadManager @Inject constructor(
* Uses the download index directly so it always reflects the true download state,
* regardless of listener callback timing.
*/
fun observeActiveDownloads(): Flow<Map<String, Float>> = flow {
override fun observeActiveDownloads(): Flow<Map<String, Float>> = flow {
while (true) {
try {
val result = buildMap<String, Float> {
@@ -112,7 +117,7 @@ class MediaDownloadManager @Inject constructor(
}
}.flowOn(Dispatchers.IO).distinctUntilChanged()
fun observeDownloadState(contentId: String): StateFlow<DownloadState> {
override fun observeDownloadState(contentId: String): StateFlow<DownloadState> {
val flow = getOrCreateStateFlow(contentId)
// Initialize from current download index
val download = downloadManager.downloadIndex.getDownload(contentId)
@@ -124,7 +129,7 @@ class MediaDownloadManager @Inject constructor(
return downloadManager.downloadIndex.getDownload(contentId)?.state == Download.STATE_COMPLETED
}
suspend fun downloadMovie(movieId: UUID) {
override suspend fun downloadMovie(movieId: UUID) {
withContext(Dispatchers.IO) {
try {
val sources = jellyfinApiClient.getMediaSources(movieId)
@@ -132,6 +137,7 @@ class MediaDownloadManager @Inject constructor(
Log.e(TAG, "No media sources for $movieId")
return@withContext
}
val shouldTranscode = source.shouldUseTranscodingUrl()
val url = jellyfinApiClient.getMediaPlaybackUrl(movieId, source) ?: run {
Log.e(TAG, "No playback URL for $movieId")
@@ -168,9 +174,12 @@ class MediaDownloadManager @Inject constructor(
offlineDataSource.saveMovies(listOf(movie))
Log.d(TAG, "Starting download for '${movie.title}' from: $url")
val request = DownloadRequest.Builder(movieId.toString(), url.toUri())
.setData(movie.title.toByteArray(Charsets.UTF_8))
.build()
val request = buildDownloadRequest(
mediaId = movieId,
playbackUrl = url,
title = movie.title,
isDirectPlay = !shouldTranscode
)
PurefinDownloadService.sendAddDownload(context, request)
Log.d(TAG, "Download request sent for $movieId")
} catch (e: Exception) {
@@ -180,7 +189,7 @@ class MediaDownloadManager @Inject constructor(
}
}
suspend fun cancelDownload(movieId: UUID) {
override suspend fun cancelDownload(movieId: UUID) {
withContext(Dispatchers.IO) {
PurefinDownloadService.sendRemoveDownload(context, movieId.toString())
try {
@@ -191,7 +200,7 @@ class MediaDownloadManager @Inject constructor(
}
}
suspend fun downloadEpisode(episodeId: UUID) {
override suspend fun downloadEpisode(episodeId: UUID) {
withContext(Dispatchers.IO) {
try {
val serverUrl = userSessionRepository.serverUrl.first().trim()
@@ -200,6 +209,7 @@ class MediaDownloadManager @Inject constructor(
Log.e(TAG, "No media sources for episode $episodeId")
return@withContext
}
val shouldTranscode = source.shouldUseTranscodingUrl()
val url = jellyfinApiClient.getMediaPlaybackUrl(episodeId, source) ?: run {
Log.e(TAG, "No playback URL for episode $episodeId")
@@ -232,9 +242,12 @@ class MediaDownloadManager @Inject constructor(
offlineDataSource.saveEpisode(episode)
Log.d(TAG, "Starting download for episode '${episode.title}' from: $url")
val request = DownloadRequest.Builder(episodeId.toString(), url.toUri())
.setData(episode.title.toByteArray(Charsets.UTF_8))
.build()
val request = buildDownloadRequest(
mediaId = episodeId,
playbackUrl = url,
title = episode.title,
isDirectPlay = !shouldTranscode
)
PurefinDownloadService.sendAddDownload(context, request)
Log.d(TAG, "Download request sent for episode $episodeId")
} catch (e: Exception) {
@@ -244,7 +257,7 @@ class MediaDownloadManager @Inject constructor(
}
}
suspend fun downloadEpisodes(episodeIds: List<UUID>) {
override suspend fun downloadEpisodes(episodeIds: List<UUID>) {
coroutineScope {
for (episodeId in episodeIds) {
launch { downloadEpisode(episodeId) }
@@ -252,7 +265,7 @@ class MediaDownloadManager @Inject constructor(
}
}
suspend fun cancelEpisodeDownload(episodeId: UUID) {
override suspend fun cancelEpisodeDownload(episodeId: UUID) {
withContext(Dispatchers.IO) {
PurefinDownloadService.sendRemoveDownload(context, episodeId.toString())
try {
@@ -265,7 +278,7 @@ class MediaDownloadManager @Inject constructor(
// ── Smart Download ──────────────────────────────────────────────────
suspend fun enableSmartDownload(seriesId: UUID) {
override suspend fun enableSmartDownload(seriesId: UUID) {
smartDownloadDao.insert(SmartDownloadEntity(seriesId))
syncSmartDownloadsForSeries(seriesId)
}
@@ -276,7 +289,7 @@ class MediaDownloadManager @Inject constructor(
fun isSmartDownloadEnabled(seriesId: UUID): Flow<Boolean> = smartDownloadDao.observe(seriesId)
suspend fun syncSmartDownloads() {
override suspend fun syncSmartDownloads() {
withContext(Dispatchers.IO) {
val enabled = smartDownloadDao.getAll()
for (entry in enabled) {
@@ -337,6 +350,26 @@ class MediaDownloadManager @Inject constructor(
return stateFlows.getOrPut(contentId) { MutableStateFlow(DownloadState.NotDownloaded) }
}
private fun buildDownloadRequest(
mediaId: UUID,
playbackUrl: String,
title: String,
isDirectPlay: Boolean
): DownloadRequest {
val builder = DownloadRequest.Builder(mediaId.toString(), playbackUrl.toUri())
.setData(title.toByteArray(Charsets.UTF_8))
if (isDirectPlay) {
playbackCustomCacheKey(
mediaId = mediaId.toString(),
playbackUrl = playbackUrl,
playMethod = PlayMethod.DIRECT_PLAY
)?.let(builder::setCustomCacheKey)
}
return builder.build()
}
private fun Download.toDownloadState(): DownloadState = when (state) {
Download.STATE_COMPLETED -> DownloadState.Downloaded
Download.STATE_DOWNLOADING -> DownloadState.Downloading(percentDownloaded)
@@ -380,7 +413,7 @@ class MediaDownloadManager @Inject constructor(
private fun BaseItemDto.toSeries(serverUrl: String): Series {
return Series(
id = id,
libraryId = parentId ?: UUID.randomUUID(),
libraryId = parentId ?: java.util.UUID.randomUUID(),
name = name ?: "Unknown",
synopsis = overview ?: "No synopsis available",
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
@@ -412,3 +445,8 @@ class MediaDownloadManager @Inject constructor(
private const val SMART_DOWNLOAD_COUNT = 5
}
}
private fun MediaSourceInfo.shouldUseTranscodingUrl(): Boolean {
return supportsTranscoding == true &&
(supportsDirectPlay == false || transcodingUrl != null)
}

View File

@@ -0,0 +1,43 @@
package hu.bbara.purefin.feature.download
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.NoOpCacheEvictor
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class DownloadModuleTest {
@Test
fun `download cache keeps no-op evictor`() {
assertTrue(DownloadModuleFactories.createDownloadCacheEvictor() is NoOpCacheEvictor)
}
@Test
fun `phone playback data source reads download cache and writes upstream only`() {
val upstreamFactory = FakeDataSourceFactory()
val factory = DownloadModuleFactories.newPhonePlaybackDataSourceFactory(upstreamFactory)
assertTrue(readPrivateBoolean(factory, "cacheIsReadOnly"))
assertNull(readPrivateField(factory, "cacheWriteDataSinkFactory"))
assertSame(upstreamFactory, readPrivateField(factory, "upstreamDataSourceFactory"))
}
private fun readPrivateBoolean(instance: CacheDataSource.Factory, fieldName: String): Boolean {
return readPrivateField(instance, fieldName) as Boolean
}
private fun readPrivateField(instance: CacheDataSource.Factory, fieldName: String): Any? {
val field = instance.javaClass.getDeclaredField(fieldName)
field.isAccessible = true
return field.get(instance)
}
}
private class FakeDataSourceFactory : DataSource.Factory {
override fun createDataSource(): DataSource {
throw UnsupportedOperationException("Unused in unit tests")
}
}

View File

@@ -30,7 +30,6 @@ kotlin {
dependencies {
implementation(project(":core:model"))
implementation(project(":core:data"))
implementation(project(":feature:download"))
implementation(libs.hilt)
ksp(libs.hilt.compiler)
implementation(libs.jellyfin.core)

View File

@@ -7,9 +7,9 @@ import hu.bbara.purefin.core.data.AppContentRepository
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.download.DownloadState
import hu.bbara.purefin.core.data.download.MediaDownloadController
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.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -24,7 +24,7 @@ import javax.inject.Inject
class EpisodeScreenViewModel @Inject constructor(
private val appContentRepository: AppContentRepository,
private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadManager,
private val mediaDownloadManager: MediaDownloadController,
): ViewModel() {
private val _episodeId = MutableStateFlow<UUID?>(null)

View File

@@ -6,9 +6,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.data.navigation.NavigationManager
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.download.MediaDownloadController
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.download.DownloadState
import hu.bbara.purefin.feature.download.MediaDownloadManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -23,7 +23,7 @@ import javax.inject.Inject
class MovieScreenViewModel @Inject constructor(
private val mediaRepository: MediaRepository,
private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadManager
private val mediaDownloadManager: MediaDownloadController
): ViewModel() {
private val _movieId = MutableStateFlow<UUID?>(null)

View File

@@ -7,10 +7,10 @@ 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.data.download.DownloadState
import hu.bbara.purefin.core.data.download.MediaDownloadController
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
@@ -27,7 +27,7 @@ import javax.inject.Inject
class SeriesViewModel @Inject constructor(
private val mediaRepository: MediaRepository,
private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadManager,
private val mediaDownloadManager: MediaDownloadController,
) : ViewModel() {
private val _seriesId = MutableStateFlow<UUID?>(null)

View File

@@ -4,12 +4,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.OfflineMediaRepository
import hu.bbara.purefin.core.data.download.MediaDownloadController
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.navigation.MovieDto
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.feature.download.MediaDownloadManager
import hu.bbara.purefin.feature.shared.home.PosterItem
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
@@ -24,7 +24,7 @@ import javax.inject.Inject
class DownloadsViewModel @Inject constructor(
private val offlineMediaRepository: OfflineMediaRepository,
private val navigationManager: NavigationManager,
private val downloadManager: MediaDownloadManager
private val downloadManager: MediaDownloadController
) : ViewModel() {
fun onMovieSelected(movieId: UUID) {

View File

@@ -12,8 +12,8 @@ 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.core.data.download.MediaDownloadController
import hu.bbara.purefin.core.model.Media
import hu.bbara.purefin.feature.download.MediaDownloadManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -33,7 +33,7 @@ class AppViewModel @Inject constructor(
private val userSessionRepository: UserSessionRepository,
private val navigationManager: NavigationManager,
private val refreshHomeDataUseCase: RefreshHomeDataUseCase,
private val mediaDownloadManager: MediaDownloadManager,
private val mediaDownloadManager: MediaDownloadController,
) : ViewModel() {
private val _isRefreshing = MutableStateFlow(false)