queueing system for manga downloads

This commit is contained in:
Finnley Somdahl 2023-11-04 01:40:40 -05:00
parent 20acd71b1a
commit 390fc18c4c
2 changed files with 169 additions and 56 deletions

View file

@ -2,13 +2,17 @@ package ani.dantotsu.download.manga
import android.Manifest import android.Manifest
import android.app.Service import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
import android.os.IBinder import android.os.IBinder
import android.widget.Toast import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import ani.dantotsu.R import ani.dantotsu.R
@ -33,6 +37,7 @@ import ani.dantotsu.media.manga.MangaReadFragment.Companion.ACTION_DOWNLOAD_FINI
import ani.dantotsu.media.manga.MangaReadFragment.Companion.ACTION_DOWNLOAD_STARTED import ani.dantotsu.media.manga.MangaReadFragment.Companion.ACTION_DOWNLOAD_STARTED
import ani.dantotsu.media.manga.MangaReadFragment.Companion.EXTRA_CHAPTER_NUMBER import ani.dantotsu.media.manga.MangaReadFragment.Companion.EXTRA_CHAPTER_NUMBER
import ani.dantotsu.snackString import ani.dantotsu.snackString
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.InstanceCreator import com.google.gson.InstanceCreator
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
@ -41,19 +46,21 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.Queue
import java.util.concurrent.ConcurrentLinkedQueue
class MangaDownloaderService : Service() { class MangaDownloaderService : Service() {
private var title: String = ""
private var chapter: String = ""
private var retries: Int = 2
private var simultaneousDownloads: Int = 2
private var imageData: List<ImageData> = listOf()
private var sourceMedia: Media? = null
private lateinit var notificationManager: NotificationManagerCompat private lateinit var notificationManager: NotificationManagerCompat
private lateinit var builder: NotificationCompat.Builder private lateinit var builder: NotificationCompat.Builder
private val downloadsManager: DownloadsManager = Injekt.get<DownloadsManager>() private val downloadsManager: DownloadsManager = Injekt.get<DownloadsManager>()
private val downloadJobs = mutableMapOf<String, Job>()
private val mutex = Mutex()
var isCurrentlyProcessing = false
override fun onBind(intent: Intent?): IBinder? { override fun onBind(intent: Intent?): IBinder? {
// This is only required for bound services. // This is only required for bound services.
return null return null
@ -64,34 +71,93 @@ class MangaDownloaderService : Service() {
notificationManager = NotificationManagerCompat.from(this) notificationManager = NotificationManagerCompat.from(this)
builder = NotificationCompat.Builder(this, CHANNEL_DOWNLOADER_PROGRESS).apply { builder = NotificationCompat.Builder(this, CHANNEL_DOWNLOADER_PROGRESS).apply {
setContentTitle("Manga Download Progress") setContentTitle("Manga Download Progress")
setContentText("Downloading $title - $chapter")
setSmallIcon(R.drawable.ic_round_download_24) setSmallIcon(R.drawable.ic_round_download_24)
priority = NotificationCompat.PRIORITY_DEFAULT priority = NotificationCompat.PRIORITY_DEFAULT
setOnlyAlertOnce(true) setOnlyAlertOnce(true)
setProgress(0, 0, false) setProgress(0, 0, false)
} }
startForeground(NOTIFICATION_ID, builder.build()) startForeground(NOTIFICATION_ID, builder.build())
registerReceiver(cancelReceiver, IntentFilter(ACTION_CANCEL_DOWNLOAD))
}
override fun onDestroy() {
super.onDestroy()
ServiceDataSingleton.downloadQueue.clear()
downloadJobs.clear()
ServiceDataSingleton.isServiceRunning = false
unregisterReceiver(cancelReceiver)
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
snackString("Download started") snackString("Download started")
title = intent?.getStringExtra("title") ?: "" val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
chapter = intent?.getStringExtra("chapter") ?: "" serviceScope.launch {
retries = intent?.getIntExtra("retries", 2) ?: 2 mutex.withLock {
simultaneousDownloads = intent?.getIntExtra("simultaneousDownloads", 2) ?: 2 if (!isCurrentlyProcessing) {
imageData = ServiceDataSingleton.imageData isCurrentlyProcessing = true
sourceMedia = ServiceDataSingleton.sourceMedia processQueue()
ServiceDataSingleton.imageData = listOf() isCurrentlyProcessing = false
ServiceDataSingleton.sourceMedia = null }
}
CoroutineScope(Dispatchers.Default).launch {
download()
} }
return START_NOT_STICKY return START_NOT_STICKY
} }
suspend fun download() { private fun processQueue() {
CoroutineScope(Dispatchers.Default).launch {
while (ServiceDataSingleton.downloadQueue.isNotEmpty()) {
val task = ServiceDataSingleton.downloadQueue.poll()
if (task != null) {
val job = launch { download(task) }
mutex.withLock {
downloadJobs[task.chapter] = job
}
job.join() // Wait for the job to complete before continuing to the next task
mutex.withLock {
downloadJobs.remove(task.chapter)
}
updateNotification() // Update the notification after each task is completed
}
if (ServiceDataSingleton.downloadQueue.isEmpty()) {
withContext(Dispatchers.Main) {
stopSelf() // Stop the service when the queue is empty
}
}
}
}
}
fun cancelDownload(chapter: String) {
CoroutineScope(Dispatchers.Default).launch {
mutex.withLock {
downloadJobs[chapter]?.cancel()
downloadJobs.remove(chapter)
ServiceDataSingleton.downloadQueue.removeAll { it.chapter == chapter }
updateNotification() // Update the notification after cancellation
}
}
}
private fun updateNotification() {
// Update the notification to reflect the current state of the queue
val pendingDownloads = ServiceDataSingleton.downloadQueue.size
val text = if (pendingDownloads > 0) {
"Pending downloads: $pendingDownloads"
} else {
"All downloads completed"
}
builder.setContentText(text)
if (ActivityCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return
}
notificationManager.notify(NOTIFICATION_ID, builder.build())
}
suspend fun download(task: DownloadTask) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (ContextCompat.checkSelfPermission( if (ContextCompat.checkSelfPermission(
this@MangaDownloaderService, this@MangaDownloaderService,
@ -105,15 +171,16 @@ class MangaDownloaderService : Service() {
).show() ).show()
return@withContext return@withContext
} }
notificationManager.notify(NOTIFICATION_ID, builder.build())
val deferredList = mutableListOf<Deferred<Bitmap?>>() val deferredList = mutableListOf<Deferred<Bitmap?>>()
builder.setContentText("Downloading ${task.title} - ${task.chapter}")
notificationManager.notify(NOTIFICATION_ID, builder.build())
// Loop through each ImageData object // Loop through each ImageData object from the task
var farthest = 0 var farthest = 0
for ((index, image) in imageData.withIndex()) { for ((index, image) in task.imageData.withIndex()) {
// Limit the number of simultaneous downloads // Limit the number of simultaneous downloads from the task
if (deferredList.size >= simultaneousDownloads) { if (deferredList.size >= task.simultaneousDownloads) {
// Wait for all deferred to complete and clear the list // Wait for all deferred to complete and clear the list
deferredList.awaitAll() deferredList.awaitAll()
deferredList.clear() deferredList.clear()
@ -124,10 +191,10 @@ class MangaDownloaderService : Service() {
var bitmap: Bitmap? = null var bitmap: Bitmap? = null
var retryCount = 0 var retryCount = 0
while (bitmap == null && retryCount < retries) { while (bitmap == null && retryCount < task.retries) {
bitmap = imageData[index].fetchAndProcessImage( bitmap = image.fetchAndProcessImage(
imageData[index].page, image.page,
imageData[index].source, image.source,
this@MangaDownloaderService this@MangaDownloaderService
) )
retryCount++ retryCount++
@ -135,10 +202,10 @@ class MangaDownloaderService : Service() {
// Cache the image if successful // Cache the image if successful
if (bitmap != null) { if (bitmap != null) {
saveToDisk("$index.jpg", bitmap) saveToDisk("$index.jpg", bitmap, task.title, task.chapter)
} }
farthest++ farthest++
builder.setProgress(imageData.size, farthest + 1, false) builder.setProgress(task.imageData.size, farthest, false)
notificationManager.notify(NOTIFICATION_ID, builder.build()) notificationManager.notify(NOTIFICATION_ID, builder.build())
bitmap bitmap
@ -150,21 +217,20 @@ class MangaDownloaderService : Service() {
// Wait for any remaining deferred to complete // Wait for any remaining deferred to complete
deferredList.awaitAll() deferredList.awaitAll()
builder.setContentText("Download complete") builder.setContentText("${task.title} - ${task.chapter} Download complete")
.setProgress(0, 0, false) .setProgress(0, 0, false)
notificationManager.notify(NOTIFICATION_ID, builder.build()) notificationManager.notify(NOTIFICATION_ID, builder.build())
saveMediaInfo() saveMediaInfo(task)
downloadsManager.addDownload(Download(title, chapter, Download.Type.MANGA)) downloadsManager.addDownload(Download(task.title, task.chapter, Download.Type.MANGA))
downloadsManager.exportDownloads(Download(title, chapter, Download.Type.MANGA)) downloadsManager.exportDownloads(Download(task.title, task.chapter, Download.Type.MANGA))
broadcastDownloadFinished(chapter) broadcastDownloadFinished(task.chapter)
snackString("Download finished") snackString("${task.title} - ${task.chapter} Download finished")
stopSelf()
} }
} }
fun saveToDisk(fileName: String, bitmap: Bitmap) {
fun saveToDisk(fileName: String, bitmap: Bitmap, title: String, chapter: String) {
try { try {
// Define the directory within the private external storage space // Define the directory within the private external storage space
val directory = File( val directory = File(
@ -187,16 +253,16 @@ class MangaDownloaderService : Service() {
} catch (e: Exception) { } catch (e: Exception) {
println("Exception while saving image: ${e.message}") println("Exception while saving image: ${e.message}")
Toast.makeText(this, "Exception while saving image: ${e.message}", Toast.LENGTH_LONG) snackString("Exception while saving image: ${e.message}")
.show() FirebaseCrashlytics.getInstance().recordException(e)
} }
} }
fun saveMediaInfo() { fun saveMediaInfo(task: DownloadTask) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
val directory = File( val directory = File(
getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"Dantotsu/Manga/$title/$chapter" "Dantotsu/Manga/${task.title}/${task.chapter}"
) )
if (!directory.exists()) directory.mkdirs() if (!directory.exists()) directory.mkdirs()
@ -206,7 +272,7 @@ class MangaDownloaderService : Service() {
SChapterImpl() // Provide an instance of SChapterImpl SChapterImpl() // Provide an instance of SChapterImpl
}) })
.create() .create()
val mediaJson = gson.toJson(sourceMedia) //need a deep copy of sourceMedia val mediaJson = gson.toJson(task.sourceMedia) // Assuming sourceMedia is part of DownloadTask
val media = gson.fromJson(mediaJson, Media::class.java) val media = gson.fromJson(mediaJson, Media::class.java)
if (media != null) { if (media != null) {
media.cover = media.cover?.let { downloadImage(it, directory, "cover.jpg") } media.cover = media.cover?.let { downloadImage(it, directory, "cover.jpg") }
@ -220,6 +286,7 @@ class MangaDownloaderService : Service() {
} }
} }
suspend fun downloadImage(url: String, directory: File, name: String): String? = withContext(Dispatchers.IO) { suspend fun downloadImage(url: String, directory: File, name: String): String? = withContext(Dispatchers.IO) {
var connection: HttpURLConnection? = null var connection: HttpURLConnection? = null
println("Downloading url $url") println("Downloading url $url")
@ -262,12 +329,38 @@ class MangaDownloaderService : Service() {
sendBroadcast(intent) sendBroadcast(intent)
} }
private val cancelReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == ACTION_CANCEL_DOWNLOAD) {
val chapter = intent.getStringExtra(EXTRA_CHAPTER)
chapter?.let {
cancelDownload(it)
}
}
}
}
data class DownloadTask(
val title: String,
val chapter: String,
val imageData: List<ImageData>,
val sourceMedia: Media? = null,
val retries: Int = 2,
val simultaneousDownloads: Int = 2,
)
companion object { companion object {
private const val NOTIFICATION_ID = 1103 private const val NOTIFICATION_ID = 1103
const val ACTION_CANCEL_DOWNLOAD = "action_cancel_download"
const val EXTRA_CHAPTER = "extra_chapter"
} }
} }
object ServiceDataSingleton { object ServiceDataSingleton {
var imageData: List<ImageData> = listOf() var imageData: List<ImageData> = listOf()
var sourceMedia: Media? = null var sourceMedia: Media? = null
var downloadQueue: Queue<MangaDownloaderService.DownloadTask> = ConcurrentLinkedQueue()
@Volatile
var isServiceRunning: Boolean = false
} }

View file

@ -357,18 +357,32 @@ open class MangaReadFragment : Fragment() {
val parser = model.mangaReadSources?.get(media.selected!!.sourceIndex) as? DynamicMangaParser val parser = model.mangaReadSources?.get(media.selected!!.sourceIndex) as? DynamicMangaParser
parser?.let { parser?.let {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
// Fetch the image list and set it in the singleton val images = parser.imageList("", chapter.sChapter)
ServiceDataSingleton.imageData = parser.imageList("", chapter.sChapter)
// Now that imageData is set, start the service // Create a download task
ServiceDataSingleton.sourceMedia = media val downloadTask = MangaDownloaderService.DownloadTask(
val intent = Intent(context, MangaDownloaderService::class.java).apply { title = media.nameMAL ?: "",
putExtra("title", media.nameMAL) chapter = chapter.title!!,
putExtra("chapter", chapter.title) imageData = images,
sourceMedia = media,
retries = 2,
simultaneousDownloads = 2
)
ServiceDataSingleton.downloadQueue.offer(downloadTask)
// If the service is not already running, start it
if (!ServiceDataSingleton.isServiceRunning) {
val intent = Intent(context, MangaDownloaderService::class.java)
withContext(Dispatchers.Main) {
ContextCompat.startForegroundService(requireContext(), intent)
}
ServiceDataSingleton.isServiceRunning = true
} }
// Inform the adapter that the download has started
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
chapterAdapter.startDownload(i) chapterAdapter.startDownload(i)
ContextCompat.startForegroundService(requireContext(), intent)
} }
} }
} }
@ -376,15 +390,21 @@ open class MangaReadFragment : Fragment() {
} }
fun onMangaChapterRemoveDownloadClick(i: String){ fun onMangaChapterRemoveDownloadClick(i: String){
downloadManager.removeDownload(Download(media.nameMAL!!, i, Download.Type.MANGA)) downloadManager.removeDownload(Download(media.nameMAL!!, i, Download.Type.MANGA))
chapterAdapter.deleteDownload(i) chapterAdapter.deleteDownload(i)
} }
fun onMangaChapterStopDownloadClick(i: String) { fun onMangaChapterStopDownloadClick(i: String) {
val intent = Intent(requireContext(), MangaDownloaderService::class.java) val cancelIntent = Intent().apply {
requireContext().stopService(intent) action = MangaDownloaderService.ACTION_CANCEL_DOWNLOAD
putExtra(MangaDownloaderService.EXTRA_CHAPTER, i)
}
requireContext().sendBroadcast(cancelIntent)
// Remove the download from the manager and update the UI
downloadManager.removeDownload(Download(media.nameMAL!!, i, Download.Type.MANGA)) downloadManager.removeDownload(Download(media.nameMAL!!, i, Download.Type.MANGA))
chapterAdapter.deleteDownload(i) chapterAdapter.stopDownload(i)
} }
private val downloadStatusReceiver = object : BroadcastReceiver() { private val downloadStatusReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {