queueing system for manga downloads
This commit is contained in:
parent
20acd71b1a
commit
390fc18c4c
2 changed files with 169 additions and 56 deletions
|
@ -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
|
||||
}
|
|
@ -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) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue