mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
refactor: modularize app into multi-module architecture
This commit is contained in:
39
feature/download/build.gradle.kts
Normal file
39
feature/download/build.gradle.kts
Normal file
@@ -0,0 +1,39 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "hu.bbara.purefin.feature.download"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 29
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
implementation(project(":core:data"))
|
||||
implementation(libs.hilt)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.medi3.exoplayer)
|
||||
implementation(libs.media3.datasource.okhttp)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.jellyfin.core)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package hu.bbara.purefin.feature.download
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.NoOpCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
|
||||
import dagger.Module
|
||||
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)
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DownloadModule {
|
||||
|
||||
private const val DOWNLOAD_CHANNEL_ID = "purefin_downloads"
|
||||
|
||||
@Provides
|
||||
@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)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDownloadNotificationHelper(@ApplicationContext context: Context): DownloadNotificationHelper {
|
||||
return DownloadNotificationHelper(context, DOWNLOAD_CHANNEL_ID)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDownloadManager(
|
||||
@ApplicationContext context: Context,
|
||||
cache: SimpleCache,
|
||||
okHttpDataSourceFactory: OkHttpDataSource.Factory
|
||||
): DownloadManager {
|
||||
return DownloadManager(
|
||||
context,
|
||||
StandaloneDatabaseProvider(context),
|
||||
cache,
|
||||
okHttpDataSourceFactory,
|
||||
Executors.newFixedThreadPool(2)
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCacheDataSourceFactory(
|
||||
cache: SimpleCache,
|
||||
okHttpDataSourceFactory: OkHttpDataSource.Factory
|
||||
): CacheDataSource.Factory {
|
||||
return CacheDataSource.Factory()
|
||||
.setCache(cache)
|
||||
.setUpstreamDataSourceFactory(okHttpDataSourceFactory)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package hu.bbara.purefin.feature.download
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.offline.Download
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadRequest
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import hu.bbara.purefin.core.data.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.core.data.local.room.OfflineDatabase
|
||||
import hu.bbara.purefin.core.data.local.room.OfflineRoomMediaLocalDataSource
|
||||
import hu.bbara.purefin.core.data.local.room.dao.MovieDao
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Singleton
|
||||
class MediaDownloadManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val downloadManager: DownloadManager,
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
@OfflineDatabase private val offlineDataSource: OfflineRoomMediaLocalDataSource,
|
||||
@OfflineDatabase private val movieDao: MovieDao,
|
||||
private val userSessionRepository: UserSessionRepository
|
||||
) {
|
||||
|
||||
private val stateFlows = ConcurrentHashMap<String, MutableStateFlow<DownloadState>>()
|
||||
|
||||
init {
|
||||
downloadManager.resumeDownloads()
|
||||
|
||||
downloadManager.addListener(object : DownloadManager.Listener {
|
||||
override fun onDownloadChanged(
|
||||
manager: DownloadManager,
|
||||
download: Download,
|
||||
finalException: Exception?
|
||||
) {
|
||||
val contentId = download.request.id
|
||||
val state = download.toDownloadState()
|
||||
Log.d(TAG, "Download changed: $contentId -> $state (${download.percentDownloaded}%)")
|
||||
if (finalException != null) {
|
||||
Log.e(TAG, "Download exception for $contentId", finalException)
|
||||
}
|
||||
getOrCreateStateFlow(contentId).value = state
|
||||
}
|
||||
|
||||
override fun onDownloadRemoved(manager: DownloadManager, download: Download) {
|
||||
val contentId = download.request.id
|
||||
Log.d(TAG, "Download removed: $contentId")
|
||||
getOrCreateStateFlow(contentId).value = DownloadState.NotDownloaded
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun observeDownloadState(contentId: String): StateFlow<DownloadState> {
|
||||
val flow = getOrCreateStateFlow(contentId)
|
||||
// Initialize from current download index
|
||||
val download = downloadManager.downloadIndex.getDownload(contentId)
|
||||
flow.value = download?.toDownloadState() ?: DownloadState.NotDownloaded
|
||||
return flow
|
||||
}
|
||||
|
||||
fun isDownloaded(contentId: String): Boolean {
|
||||
return downloadManager.downloadIndex.getDownload(contentId)?.state == Download.STATE_COMPLETED
|
||||
}
|
||||
|
||||
suspend fun downloadMovie(movieId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val sources = jellyfinApiClient.getMediaSources(movieId)
|
||||
val source = sources.firstOrNull() ?: run {
|
||||
Log.e(TAG, "No media sources for $movieId")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val url = jellyfinApiClient.getMediaPlaybackUrl(movieId, source) ?: run {
|
||||
Log.e(TAG, "No playback URL for $movieId")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val itemInfo = jellyfinApiClient.getItemInfo(movieId) ?: run {
|
||||
Log.e(TAG, "No item info for $movieId")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val serverUrl = userSessionRepository.serverUrl.first().trim()
|
||||
val movie = Movie(
|
||||
id = itemInfo.id,
|
||||
libraryId = itemInfo.parentId ?: UUID.randomUUID(),
|
||||
title = itemInfo.name ?: "Unknown title",
|
||||
progress = itemInfo.userData?.playedPercentage,
|
||||
watched = itemInfo.userData?.played ?: false,
|
||||
year = itemInfo.productionYear?.toString()
|
||||
?: itemInfo.premiereDate?.year?.toString().orEmpty(),
|
||||
rating = itemInfo.officialRating ?: "NR",
|
||||
runtime = formatRuntime(itemInfo.runTimeTicks),
|
||||
synopsis = itemInfo.overview ?: "No synopsis available",
|
||||
format = itemInfo.container?.uppercase() ?: "VIDEO",
|
||||
heroImageUrl = JellyfinImageHelper.toImageUrl(
|
||||
url = serverUrl,
|
||||
itemId = itemInfo.id,
|
||||
type = ImageType.PRIMARY
|
||||
),
|
||||
subtitles = "ENG",
|
||||
audioTrack = "ENG",
|
||||
cast = emptyList()
|
||||
)
|
||||
|
||||
offlineDataSource.saveMovies(listOf(movie))
|
||||
|
||||
Log.d(TAG, "Starting download for '${movie.title}' from: $url")
|
||||
val request = DownloadRequest.Builder(movieId.toString(), url.toUri()).build()
|
||||
PurefinDownloadService.sendAddDownload(context, request)
|
||||
Log.d(TAG, "Download request sent for $movieId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start download for $movieId", e)
|
||||
getOrCreateStateFlow(movieId.toString()).value = DownloadState.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun cancelDownload(movieId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
PurefinDownloadService.sendRemoveDownload(context, movieId.toString())
|
||||
try {
|
||||
movieDao.deleteById(movieId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove movie from offline DB", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreateStateFlow(contentId: String): MutableStateFlow<DownloadState> {
|
||||
return stateFlows.getOrPut(contentId) { MutableStateFlow(DownloadState.NotDownloaded) }
|
||||
}
|
||||
|
||||
private fun Download.toDownloadState(): DownloadState = when (state) {
|
||||
Download.STATE_COMPLETED -> DownloadState.Downloaded
|
||||
Download.STATE_DOWNLOADING -> DownloadState.Downloading(percentDownloaded)
|
||||
Download.STATE_QUEUED, Download.STATE_RESTARTING -> DownloadState.Downloading(0f)
|
||||
Download.STATE_FAILED -> DownloadState.Failed
|
||||
Download.STATE_REMOVING -> DownloadState.NotDownloaded
|
||||
Download.STATE_STOPPED -> DownloadState.NotDownloaded
|
||||
else -> DownloadState.NotDownloaded
|
||||
}
|
||||
|
||||
private fun formatRuntime(ticks: Long?): String {
|
||||
if (ticks == null || ticks <= 0) return "—"
|
||||
val totalSeconds = ticks / 10_000_000
|
||||
val hours = java.util.concurrent.TimeUnit.SECONDS.toHours(totalSeconds)
|
||||
val minutes = java.util.concurrent.TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
|
||||
return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MediaDownloadManager"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package hu.bbara.purefin.feature.download
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.offline.Download
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
|
||||
import androidx.media3.exoplayer.offline.DownloadRequest
|
||||
import androidx.media3.exoplayer.offline.DownloadService
|
||||
import androidx.media3.exoplayer.scheduler.Scheduler
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class PurefinDownloadService : DownloadService(
|
||||
FOREGROUND_NOTIFICATION_ID,
|
||||
DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL,
|
||||
DOWNLOAD_CHANNEL_ID,
|
||||
R.string.download_channel_name,
|
||||
R.string.download_channel_description
|
||||
) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface DownloadServiceEntryPoint {
|
||||
fun downloadManager(): DownloadManager
|
||||
fun downloadNotificationHelper(): DownloadNotificationHelper
|
||||
}
|
||||
|
||||
private val entryPoint: DownloadServiceEntryPoint by lazy {
|
||||
EntryPointAccessors.fromApplication(applicationContext, DownloadServiceEntryPoint::class.java)
|
||||
}
|
||||
|
||||
private var lastBytesDownloaded: Long = 0L
|
||||
private var lastUpdateTimeMs: Long = 0L
|
||||
|
||||
override fun getDownloadManager(): DownloadManager = entryPoint.downloadManager()
|
||||
|
||||
override fun getForegroundNotification(
|
||||
downloads: MutableList<Download>,
|
||||
notMetRequirements: Int
|
||||
): Notification {
|
||||
val activeDownloads = downloads.filter { it.state == Download.STATE_DOWNLOADING }
|
||||
|
||||
if (activeDownloads.isEmpty()) {
|
||||
return entryPoint.downloadNotificationHelper().buildProgressNotification(
|
||||
this,
|
||||
R.drawable.ic_launcher_foreground,
|
||||
null,
|
||||
null,
|
||||
downloads,
|
||||
notMetRequirements
|
||||
)
|
||||
}
|
||||
|
||||
val totalBytes = activeDownloads.sumOf { it.bytesDownloaded }
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
val speedText = if (lastUpdateTimeMs > 0L) {
|
||||
val elapsed = (now - lastUpdateTimeMs).coerceAtLeast(1)
|
||||
val bytesPerSec = (totalBytes - lastBytesDownloaded) * 1000L / elapsed
|
||||
formatSpeed(bytesPerSec)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
lastBytesDownloaded = totalBytes
|
||||
lastUpdateTimeMs = now
|
||||
|
||||
val percent = if (activeDownloads.size == 1) {
|
||||
activeDownloads[0].percentDownloaded
|
||||
} else {
|
||||
activeDownloads.map { it.percentDownloaded }.average().toFloat()
|
||||
}
|
||||
|
||||
val title = if (activeDownloads.size == 1) {
|
||||
"Downloading"
|
||||
} else {
|
||||
"Downloading ${activeDownloads.size} files"
|
||||
}
|
||||
|
||||
val contentText = buildString {
|
||||
append("${percent.toInt()}%")
|
||||
if (speedText.isNotEmpty()) {
|
||||
append(" · $speedText")
|
||||
}
|
||||
}
|
||||
|
||||
return NotificationCompat.Builder(this, DOWNLOAD_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(title)
|
||||
.setContentText(contentText)
|
||||
.setProgress(100, percent.toInt(), false)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getScheduler(): Scheduler? = null
|
||||
|
||||
private fun formatSpeed(bytesPerSec: Long): String {
|
||||
if (bytesPerSec <= 0) return ""
|
||||
return when {
|
||||
bytesPerSec >= 1_000_000 -> String.format("%.1f MB/s", bytesPerSec / 1_000_000.0)
|
||||
bytesPerSec >= 1_000 -> String.format("%.0f KB/s", bytesPerSec / 1_000.0)
|
||||
else -> "$bytesPerSec B/s"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FOREGROUND_NOTIFICATION_ID = 1
|
||||
private const val DOWNLOAD_CHANNEL_ID = "purefin_downloads"
|
||||
|
||||
fun sendAddDownload(context: Context, request: DownloadRequest) {
|
||||
sendAddDownload(
|
||||
context,
|
||||
PurefinDownloadService::class.java,
|
||||
request,
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
fun sendRemoveDownload(context: Context, contentId: String) {
|
||||
sendRemoveDownload(
|
||||
context,
|
||||
PurefinDownloadService::class.java,
|
||||
contentId,
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
5
feature/download/src/main/res/values/strings.xml
Normal file
5
feature/download/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="download_channel_name">Downloads</string>
|
||||
<string name="download_channel_description">Media download progress</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user