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.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
|
||||||
}
|
}
|
|
@ -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) {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue