Add additional checks to download task #50
This commit is contained in:
@@ -3,26 +3,4 @@ package org.koitharu.kotatsu.core.model
|
|||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
|
||||||
fun Manga.withoutChapters() = if (chapters.isNullOrEmpty()) {
|
|
||||||
this
|
|
||||||
} else {
|
|
||||||
Manga(
|
|
||||||
id = id,
|
|
||||||
title = title,
|
|
||||||
altTitle = altTitle,
|
|
||||||
url = url,
|
|
||||||
publicUrl = publicUrl,
|
|
||||||
rating = rating,
|
|
||||||
isNsfw = isNsfw,
|
|
||||||
coverUrl = coverUrl,
|
|
||||||
tags = tags,
|
|
||||||
state = state,
|
|
||||||
author = author,
|
|
||||||
largeCoverUrl = largeCoverUrl,
|
|
||||||
description = description,
|
|
||||||
chapters = null,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Collection<Manga>.ids() = mapToSet { it.id }
|
fun Collection<Manga>.ids() = mapToSet { it.id }
|
||||||
@@ -10,13 +10,18 @@ private const val MAX_SAFE_CHAPTERS_COUNT = 40 // this is 100% safe
|
|||||||
|
|
||||||
class ParcelableManga(
|
class ParcelableManga(
|
||||||
val manga: Manga,
|
val manga: Manga,
|
||||||
|
private val withChapters: Boolean,
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
|
|
||||||
constructor(parcel: Parcel) : this(parcel.readManga())
|
constructor(parcel: Parcel) : this(parcel.readManga(), true)
|
||||||
|
|
||||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||||
val chapters = manga.chapters
|
val chapters = manga.chapters
|
||||||
if (chapters == null || chapters.size <= MAX_SAFE_CHAPTERS_COUNT) {
|
if (!withChapters || chapters == null) {
|
||||||
|
manga.writeToParcel(parcel, flags, withChapters = false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (chapters.size <= MAX_SAFE_CHAPTERS_COUNT) {
|
||||||
// fast path
|
// fast path
|
||||||
manga.writeToParcel(parcel, flags, withChapters = true)
|
manga.writeToParcel(parcel, flags, withChapters = true)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
|||||||
|
|
||||||
fun newIntent(context: Context, manga: Manga): Intent {
|
fun newIntent(context: Context, manga: Manga): Intent {
|
||||||
return Intent(context, DetailsActivity::class.java)
|
return Intent(context, DetailsActivity::class.java)
|
||||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun newIntent(context: Context, mangaId: Long): Intent {
|
fun newIntent(context: Context, mangaId: Long): Intent {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.webkit.MimeTypeMap
|
|||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import coil.size.Scale
|
import coil.size.Scale
|
||||||
|
import java.io.File
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
@@ -21,12 +22,12 @@ import org.koitharu.kotatsu.local.data.MangaZip
|
|||||||
import org.koitharu.kotatsu.local.data.PagesCache
|
import org.koitharu.kotatsu.local.data.PagesCache
|
||||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.await
|
import org.koitharu.kotatsu.parsers.util.await
|
||||||
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
||||||
import org.koitharu.kotatsu.utils.ext.referer
|
import org.koitharu.kotatsu.utils.ext.referer
|
||||||
import org.koitharu.kotatsu.utils.ext.waitForNetwork
|
import org.koitharu.kotatsu.utils.ext.waitForNetwork
|
||||||
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
private const val MAX_DOWNLOAD_ATTEMPTS = 3
|
private const val MAX_DOWNLOAD_ATTEMPTS = 3
|
||||||
private const val MAX_PARALLEL_DOWNLOADS = 2
|
private const val MAX_PARALLEL_DOWNLOADS = 2
|
||||||
@@ -55,22 +56,24 @@ class DownloadManager(
|
|||||||
|
|
||||||
fun downloadManga(
|
fun downloadManga(
|
||||||
manga: Manga,
|
manga: Manga,
|
||||||
chaptersIds: Set<Long>?,
|
chaptersIds: LongArray?,
|
||||||
startId: Int,
|
startId: Int,
|
||||||
): ProgressJob<DownloadState> {
|
): ProgressJob<DownloadState> {
|
||||||
val stateFlow = MutableStateFlow<DownloadState>(
|
val stateFlow = MutableStateFlow<DownloadState>(
|
||||||
DownloadState.Queued(startId = startId, manga = manga, cover = null)
|
DownloadState.Queued(startId = startId, manga = manga, cover = null)
|
||||||
)
|
)
|
||||||
val job = downloadMangaImpl(manga, chaptersIds, stateFlow, startId)
|
val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, startId)
|
||||||
return ProgressJob(job, stateFlow)
|
return ProgressJob(job, stateFlow)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadMangaImpl(
|
private fun downloadMangaImpl(
|
||||||
manga: Manga,
|
manga: Manga,
|
||||||
chaptersIds: Set<Long>?,
|
chaptersIds: LongArray?,
|
||||||
outState: MutableStateFlow<DownloadState>,
|
outState: MutableStateFlow<DownloadState>,
|
||||||
startId: Int,
|
startId: Int,
|
||||||
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
|
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
|
||||||
|
@Suppress("NAME_SHADOWING") var manga = manga
|
||||||
|
val chaptersIdsSet = chaptersIds?.toMutableSet()
|
||||||
semaphore.acquire()
|
semaphore.acquire()
|
||||||
coroutineContext[WakeLockNode]?.acquire()
|
coroutineContext[WakeLockNode]?.acquire()
|
||||||
outState.value = DownloadState.Preparing(startId, manga, null)
|
outState.value = DownloadState.Preparing(startId, manga, null)
|
||||||
@@ -79,6 +82,9 @@ class DownloadManager(
|
|||||||
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
|
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
|
||||||
var output: MangaZip? = null
|
var output: MangaZip? = null
|
||||||
try {
|
try {
|
||||||
|
if (manga.source == MangaSource.LOCAL) {
|
||||||
|
manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance")
|
||||||
|
}
|
||||||
val repo = MangaRepository(manga.source)
|
val repo = MangaRepository(manga.source)
|
||||||
cover = runCatching {
|
cover = runCatching {
|
||||||
imageLoader.execute(
|
imageLoader.execute(
|
||||||
@@ -91,32 +97,36 @@ class DownloadManager(
|
|||||||
).drawable
|
).drawable
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
outState.value = DownloadState.Preparing(startId, manga, cover)
|
outState.value = DownloadState.Preparing(startId, manga, cover)
|
||||||
val data = if (manga.chapters == null) repo.getDetails(manga) else manga
|
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
|
||||||
output = MangaZip.findInDir(destination, data)
|
output = MangaZip.findInDir(destination, data)
|
||||||
output.prepare(data)
|
output.prepare(data)
|
||||||
val coverUrl = data.largeCoverUrl ?: data.coverUrl
|
val coverUrl = data.largeCoverUrl ?: data.coverUrl
|
||||||
downloadFile(coverUrl, data.publicUrl, destination).let { file ->
|
downloadFile(coverUrl, data.publicUrl, destination).let { file ->
|
||||||
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
|
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
|
||||||
}
|
}
|
||||||
val chapters = if (chaptersIds == null) {
|
val chapters = checkNotNull(
|
||||||
data.chapters.orEmpty()
|
if (chaptersIdsSet == null) {
|
||||||
|
data.chapters
|
||||||
} else {
|
} else {
|
||||||
data.chapters.orEmpty().filter { x -> x.id in chaptersIds }
|
data.chapters?.filter { x -> chaptersIdsSet.remove(x.id) }
|
||||||
|
}
|
||||||
|
) { "Chapters list must not be null" }
|
||||||
|
check(chapters.isNotEmpty()) { "Chapters list must not be empty" }
|
||||||
|
check(chaptersIdsSet.isNullOrEmpty()) {
|
||||||
|
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
|
||||||
}
|
}
|
||||||
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
||||||
if (chaptersIds == null || chapter.id in chaptersIds) {
|
|
||||||
val pages = repo.getPages(chapter)
|
val pages = repo.getPages(chapter)
|
||||||
for ((pageIndex, page) in pages.withIndex()) {
|
for ((pageIndex, page) in pages.withIndex()) {
|
||||||
failsafe@ do {
|
failsafe@ do {
|
||||||
try {
|
try {
|
||||||
val url = repo.getPageUrl(page)
|
val url = repo.getPageUrl(page)
|
||||||
val file =
|
val file = cache[url] ?: downloadFile(url, page.referer, destination)
|
||||||
cache[url] ?: downloadFile(url, page.referer, destination)
|
|
||||||
output.addPage(
|
output.addPage(
|
||||||
chapter,
|
chapter = chapter,
|
||||||
file,
|
file = file,
|
||||||
pageIndex,
|
pageNumber = pageIndex,
|
||||||
MimeTypeMap.getFileExtensionFromUrl(url)
|
ext = MimeTypeMap.getFileExtensionFromUrl(url),
|
||||||
)
|
)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
|
outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
|
||||||
@@ -134,7 +144,6 @@ class DownloadManager(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
outState.value = DownloadState.PostProcessing(startId, data, cover)
|
outState.value = DownloadState.PostProcessing(startId, data, cover)
|
||||||
if (!output.compress()) {
|
if (!output.compress()) {
|
||||||
throw RuntimeException("Cannot create target file")
|
throw RuntimeException("Cannot create target file")
|
||||||
@@ -189,7 +198,8 @@ class DownloadManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) = CoroutineExceptionHandler { _, throwable ->
|
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
|
||||||
|
CoroutineExceptionHandler { _, throwable ->
|
||||||
val prevValue = outState.value
|
val prevValue = outState.value
|
||||||
outState.value = DownloadState.Error(
|
outState.value = DownloadState.Error(
|
||||||
startId = prevValue.startId,
|
startId = prevValue.startId,
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import org.koitharu.kotatsu.R
|
|||||||
import org.koitharu.kotatsu.base.ui.BaseService
|
import org.koitharu.kotatsu.base.ui.BaseService
|
||||||
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
|
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
import org.koitharu.kotatsu.core.model.withoutChapters
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.download.domain.DownloadManager
|
import org.koitharu.kotatsu.download.domain.DownloadManager
|
||||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||||
@@ -32,7 +31,6 @@ import org.koitharu.kotatsu.download.domain.WakeLockNode
|
|||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.utils.ext.connectivityManager
|
import org.koitharu.kotatsu.utils.ext.connectivityManager
|
||||||
import org.koitharu.kotatsu.utils.ext.throttle
|
import org.koitharu.kotatsu.utils.ext.throttle
|
||||||
import org.koitharu.kotatsu.utils.ext.toArraySet
|
|
||||||
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@@ -66,7 +64,7 @@ class DownloadService : BaseService() {
|
|||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
super.onStartCommand(intent, flags, startId)
|
super.onStartCommand(intent, flags, startId)
|
||||||
val manga = intent?.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga
|
val manga = intent?.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga
|
||||||
val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet()
|
val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)
|
||||||
return if (manga != null) {
|
return if (manga != null) {
|
||||||
jobs[startId] = downloadManga(startId, manga, chapters)
|
jobs[startId] = downloadManga(startId, manga, chapters)
|
||||||
jobCount.value = jobs.size
|
jobCount.value = jobs.size
|
||||||
@@ -96,7 +94,7 @@ class DownloadService : BaseService() {
|
|||||||
private fun downloadManga(
|
private fun downloadManga(
|
||||||
startId: Int,
|
startId: Int,
|
||||||
manga: Manga,
|
manga: Manga,
|
||||||
chaptersIds: Set<Long>?,
|
chaptersIds: LongArray?,
|
||||||
): ProgressJob<DownloadState> {
|
): ProgressJob<DownloadState> {
|
||||||
val job = downloadManager.downloadManga(manga, chaptersIds, startId)
|
val job = downloadManager.downloadManga(manga, chaptersIds, startId)
|
||||||
listenJob(job)
|
listenJob(job)
|
||||||
@@ -118,7 +116,7 @@ class DownloadService : BaseService() {
|
|||||||
(job.progressValue as? DownloadState.Done)?.let {
|
(job.progressValue as? DownloadState.Done)?.let {
|
||||||
sendBroadcast(
|
sendBroadcast(
|
||||||
Intent(ACTION_DOWNLOAD_COMPLETE)
|
Intent(ACTION_DOWNLOAD_COMPLETE)
|
||||||
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga.withoutChapters()))
|
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
notificationSwitcher.detach(
|
notificationSwitcher.detach(
|
||||||
@@ -178,7 +176,7 @@ class DownloadService : BaseService() {
|
|||||||
}
|
}
|
||||||
confirmDataTransfer(context) {
|
confirmDataTransfer(context) {
|
||||||
val intent = Intent(context, DownloadService::class.java)
|
val intent = Intent(context, DownloadService::class.java)
|
||||||
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga))
|
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
|
||||||
if (chaptersIds != null) {
|
if (chaptersIds != null) {
|
||||||
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
|
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
|
||||||
}
|
}
|
||||||
@@ -194,7 +192,7 @@ class DownloadService : BaseService() {
|
|||||||
confirmDataTransfer(context) {
|
confirmDataTransfer(context) {
|
||||||
for (item in manga) {
|
for (item in manga) {
|
||||||
val intent = Intent(context, DownloadService::class.java)
|
val intent = Intent(context, DownloadService::class.java)
|
||||||
intent.putExtra(EXTRA_MANGA, ParcelableManga(item))
|
intent.putExtra(EXTRA_MANGA, ParcelableManga(item, withChapters = false))
|
||||||
ContextCompat.startForegroundService(context, intent)
|
ContextCompat.startForegroundService(context, intent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import org.koitharu.kotatsu.base.ui.BaseBottomSheet
|
|||||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
import org.koitharu.kotatsu.core.model.withoutChapters
|
|
||||||
import org.koitharu.kotatsu.databinding.DialogFavoriteCategoriesBinding
|
import org.koitharu.kotatsu.databinding.DialogFavoriteCategoriesBinding
|
||||||
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
|
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
|
||||||
import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter
|
import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter
|
||||||
@@ -99,7 +98,7 @@ class FavouriteCategoriesDialog :
|
|||||||
fun show(fm: FragmentManager, manga: Collection<Manga>) = FavouriteCategoriesDialog().withArgs(1) {
|
fun show(fm: FragmentManager, manga: Collection<Manga>) = FavouriteCategoriesDialog().withArgs(1) {
|
||||||
putParcelableArrayList(
|
putParcelableArrayList(
|
||||||
KEY_MANGA_LIST,
|
KEY_MANGA_LIST,
|
||||||
manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it.withoutChapters()) }
|
manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withChapters = false) }
|
||||||
)
|
)
|
||||||
}.show(fm, TAG)
|
}.show(fm, TAG)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -410,18 +410,18 @@ class ReaderActivity :
|
|||||||
|
|
||||||
fun newIntent(context: Context, manga: Manga): Intent {
|
fun newIntent(context: Context, manga: Manga): Intent {
|
||||||
return Intent(context, ReaderActivity::class.java)
|
return Intent(context, ReaderActivity::class.java)
|
||||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun newIntent(context: Context, manga: Manga, branch: String?): Intent {
|
fun newIntent(context: Context, manga: Manga, branch: String?): Intent {
|
||||||
return Intent(context, ReaderActivity::class.java)
|
return Intent(context, ReaderActivity::class.java)
|
||||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
|
||||||
.putExtra(EXTRA_BRANCH, branch)
|
.putExtra(EXTRA_BRANCH, branch)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun newIntent(context: Context, manga: Manga, state: ReaderState?): Intent {
|
fun newIntent(context: Context, manga: Manga, state: ReaderState?): Intent {
|
||||||
return Intent(context, ReaderActivity::class.java)
|
return Intent(context, ReaderActivity::class.java)
|
||||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
|
||||||
.putExtra(EXTRA_STATE, state)
|
.putExtra(EXTRA_STATE, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package org.koitharu.kotatsu.utils.ext
|
|||||||
import androidx.collection.ArraySet
|
import androidx.collection.ArraySet
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
fun LongArray.toArraySet(): Set<Long> = createSet(size) { i -> this[i] }
|
|
||||||
|
|
||||||
fun <T : Enum<T>> Array<T>.names() = Array(size) { i ->
|
fun <T : Enum<T>> Array<T>.names() = Array(size) { i ->
|
||||||
this[i].name
|
this[i].name
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user