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.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.net.Uri
import android.os.Environment
import android.os.IBinder
import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
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.EXTRA_CHAPTER_NUMBER
import ani.dantotsu.snackString
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.google.gson.GsonBuilder
import com.google.gson.InstanceCreator
import eu.kanade.tachiyomi.source.model.SChapter
@ -41,19 +46,21 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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() {
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 builder: NotificationCompat.Builder
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? {
// This is only required for bound services.
return null
@ -64,34 +71,93 @@ class MangaDownloaderService : Service() {
notificationManager = NotificationManagerCompat.from(this)
builder = NotificationCompat.Builder(this, CHANNEL_DOWNLOADER_PROGRESS).apply {
setContentTitle("Manga Download Progress")
setContentText("Downloading $title - $chapter")
setSmallIcon(R.drawable.ic_round_download_24)
priority = NotificationCompat.PRIORITY_DEFAULT
setOnlyAlertOnce(true)
setProgress(0, 0, false)
}
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 {
snackString("Download started")
title = intent?.getStringExtra("title") ?: ""
chapter = intent?.getStringExtra("chapter") ?: ""
retries = intent?.getIntExtra("retries", 2) ?: 2
simultaneousDownloads = intent?.getIntExtra("simultaneousDownloads", 2) ?: 2
imageData = ServiceDataSingleton.imageData
sourceMedia = ServiceDataSingleton.sourceMedia
ServiceDataSingleton.imageData = listOf()
ServiceDataSingleton.sourceMedia = null
CoroutineScope(Dispatchers.Default).launch {
download()
val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
serviceScope.launch {
mutex.withLock {
if (!isCurrentlyProcessing) {
isCurrentlyProcessing = true
processQueue()
isCurrentlyProcessing = false
}
}
}
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) {
if (ContextCompat.checkSelfPermission(
this@MangaDownloaderService,
@ -105,15 +171,16 @@ class MangaDownloaderService : Service() {
).show()
return@withContext
}
notificationManager.notify(NOTIFICATION_ID, builder.build())
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
for ((index, image) in imageData.withIndex()) {
// Limit the number of simultaneous downloads
if (deferredList.size >= simultaneousDownloads) {
for ((index, image) in task.imageData.withIndex()) {
// Limit the number of simultaneous downloads from the task
if (deferredList.size >= task.simultaneousDownloads) {
// Wait for all deferred to complete and clear the list
deferredList.awaitAll()
deferredList.clear()
@ -124,10 +191,10 @@ class MangaDownloaderService : Service() {
var bitmap: Bitmap? = null
var retryCount = 0
while (bitmap == null && retryCount < retries) {
bitmap = imageData[index].fetchAndProcessImage(
imageData[index].page,
imageData[index].source,
while (bitmap == null && retryCount < task.retries) {
bitmap = image.fetchAndProcessImage(
image.page,
image.source,
this@MangaDownloaderService
)
retryCount++
@ -135,10 +202,10 @@ class MangaDownloaderService : Service() {
// Cache the image if successful
if (bitmap != null) {
saveToDisk("$index.jpg", bitmap)
saveToDisk("$index.jpg", bitmap, task.title, task.chapter)
}
farthest++
builder.setProgress(imageData.size, farthest + 1, false)
builder.setProgress(task.imageData.size, farthest, false)
notificationManager.notify(NOTIFICATION_ID, builder.build())
bitmap
@ -150,21 +217,20 @@ class MangaDownloaderService : Service() {
// Wait for any remaining deferred to complete
deferredList.awaitAll()
builder.setContentText("Download complete")
builder.setContentText("${task.title} - ${task.chapter} Download complete")
.setProgress(0, 0, false)
notificationManager.notify(NOTIFICATION_ID, builder.build())
saveMediaInfo()
downloadsManager.addDownload(Download(title, chapter, Download.Type.MANGA))
downloadsManager.exportDownloads(Download(title, chapter, Download.Type.MANGA))
broadcastDownloadFinished(chapter)
snackString("Download finished")
stopSelf()
saveMediaInfo(task)
downloadsManager.addDownload(Download(task.title, task.chapter, Download.Type.MANGA))
downloadsManager.exportDownloads(Download(task.title, task.chapter, Download.Type.MANGA))
broadcastDownloadFinished(task.chapter)
snackString("${task.title} - ${task.chapter} Download finished")
}
}
fun saveToDisk(fileName: String, bitmap: Bitmap) {
fun saveToDisk(fileName: String, bitmap: Bitmap, title: String, chapter: String) {
try {
// Define the directory within the private external storage space
val directory = File(
@ -187,16 +253,16 @@ class MangaDownloaderService : Service() {
} catch (e: Exception) {
println("Exception while saving image: ${e.message}")
Toast.makeText(this, "Exception while saving image: ${e.message}", Toast.LENGTH_LONG)
.show()
snackString("Exception while saving image: ${e.message}")
FirebaseCrashlytics.getInstance().recordException(e)
}
}
fun saveMediaInfo() {
fun saveMediaInfo(task: DownloadTask) {
GlobalScope.launch(Dispatchers.IO) {
val directory = File(
getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"Dantotsu/Manga/$title/$chapter"
"Dantotsu/Manga/${task.title}/${task.chapter}"
)
if (!directory.exists()) directory.mkdirs()
@ -206,7 +272,7 @@ class MangaDownloaderService : Service() {
SChapterImpl() // Provide an instance of SChapterImpl
})
.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)
if (media != null) {
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) {
var connection: HttpURLConnection? = null
println("Downloading url $url")
@ -262,12 +329,38 @@ class MangaDownloaderService : Service() {
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 {
private const val NOTIFICATION_ID = 1103
const val ACTION_CANCEL_DOWNLOAD = "action_cancel_download"
const val EXTRA_CHAPTER = "extra_chapter"
}
}
object ServiceDataSingleton {
var imageData: List<ImageData> = listOf()
var sourceMedia: Media? = null
var downloadQueue: Queue<MangaDownloaderService.DownloadTask> = ConcurrentLinkedQueue()
@Volatile
var isServiceRunning: Boolean = false
}

View file

@ -357,23 +357,38 @@ open class MangaReadFragment : Fragment() {
val parser = model.mangaReadSources?.get(media.selected!!.sourceIndex) as? DynamicMangaParser
parser?.let {
CoroutineScope(Dispatchers.IO).launch {
// Fetch the image list and set it in the singleton
ServiceDataSingleton.imageData = parser.imageList("", chapter.sChapter)
val images = parser.imageList("", chapter.sChapter)
// Now that imageData is set, start the service
ServiceDataSingleton.sourceMedia = media
val intent = Intent(context, MangaDownloaderService::class.java).apply {
putExtra("title", media.nameMAL)
putExtra("chapter", chapter.title)
}
// Create a download task
val downloadTask = MangaDownloaderService.DownloadTask(
title = media.nameMAL ?: "",
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) {
chapterAdapter.startDownload(i)
ContextCompat.startForegroundService(requireContext(), intent)
}
ServiceDataSingleton.isServiceRunning = true
}
// Inform the adapter that the download has started
withContext(Dispatchers.Main) {
chapterAdapter.startDownload(i)
}
}
}
}
}
fun onMangaChapterRemoveDownloadClick(i: String){
@ -381,10 +396,15 @@ open class MangaReadFragment : Fragment() {
chapterAdapter.deleteDownload(i)
}
fun onMangaChapterStopDownloadClick(i: String) {
val intent = Intent(requireContext(), MangaDownloaderService::class.java)
requireContext().stopService(intent)
val cancelIntent = Intent().apply {
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))
chapterAdapter.deleteDownload(i)
chapterAdapter.stopDownload(i)
}
private val downloadStatusReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {