Batch manga fix functionality
parent
10109ab2c0
commit
e48beae324
@ -0,0 +1,93 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.domain
|
||||||
|
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.lastOrNull
|
||||||
|
import kotlinx.coroutines.flow.runningFold
|
||||||
|
import kotlinx.coroutines.flow.transformWhile
|
||||||
|
import kotlinx.coroutines.flow.withIndex
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
class AutoFixUseCase @Inject constructor(
|
||||||
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
private val alternativesUseCase: AlternativesUseCase,
|
||||||
|
private val migrateUseCase: MigrateUseCase,
|
||||||
|
private val mangaDataRepository: MangaDataRepository,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend operator fun invoke(mangaId: Long): Pair<Manga, Manga?> {
|
||||||
|
val seed = checkNotNull(mangaDataRepository.findMangaById(mangaId)) { "Manga $mangaId not found" }
|
||||||
|
.getDetailsSafe()
|
||||||
|
if (seed.isHealthy()) {
|
||||||
|
return seed to null // no fix required
|
||||||
|
}
|
||||||
|
val replacement = alternativesUseCase(seed, matchThreshold = 0.02f)
|
||||||
|
.filter { it.isHealthy() }
|
||||||
|
.runningFold<Manga, Manga?>(null) { best, candidate ->
|
||||||
|
if (best == null || best < candidate) {
|
||||||
|
candidate
|
||||||
|
} else {
|
||||||
|
best
|
||||||
|
}
|
||||||
|
}.selectLastWithTimeout(4, 40, TimeUnit.SECONDS)
|
||||||
|
migrateUseCase(seed, replacement ?: throw NoAlternativesException(ParcelableManga(seed)))
|
||||||
|
return seed to replacement
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun Manga.isHealthy(): Boolean = runCatchingCancellable {
|
||||||
|
val repo = mangaRepositoryFactory.create(source)
|
||||||
|
val details = if (this.chapters != null) this else repo.getDetails(this)
|
||||||
|
val firstChapter = details.chapters?.firstOrNull() ?: return@runCatchingCancellable false
|
||||||
|
val pageUrl = repo.getPageUrl(repo.getPages(firstChapter).first())
|
||||||
|
pageUrl.toHttpUrlOrNull() != null
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
private suspend fun Manga.getDetailsSafe() = runCatchingCancellable {
|
||||||
|
mangaRepositoryFactory.create(source).getDetails(this)
|
||||||
|
}.getOrDefault(this)
|
||||||
|
|
||||||
|
private operator fun Manga.compareTo(other: Manga) = chaptersCount().compareTo(other.chaptersCount())
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST", "OPT_IN_USAGE")
|
||||||
|
private suspend fun <T> Flow<T>.selectLastWithTimeout(
|
||||||
|
minCount: Int,
|
||||||
|
timeout: Long,
|
||||||
|
timeUnit: TimeUnit
|
||||||
|
): T? = channelFlow<T?> {
|
||||||
|
var lastValue: T? = null
|
||||||
|
launch {
|
||||||
|
delay(timeUnit.toMillis(timeout))
|
||||||
|
close(InternalTimeoutException(lastValue))
|
||||||
|
}
|
||||||
|
withIndex().transformWhile { (index, value) ->
|
||||||
|
lastValue = value
|
||||||
|
emit(value)
|
||||||
|
index < minCount && !isClosedForSend
|
||||||
|
}.collect {
|
||||||
|
send(it)
|
||||||
|
}
|
||||||
|
}.catch { e ->
|
||||||
|
if (e is InternalTimeoutException) {
|
||||||
|
emit(e.value as T?)
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}.lastOrNull()
|
||||||
|
|
||||||
|
class NoAlternativesException(val seed: ParcelableManga) : NoSuchElementException()
|
||||||
|
|
||||||
|
private class InternalTimeoutException(val value: Any?) : CancellationException()
|
||||||
|
}
|
||||||
@ -0,0 +1,196 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import androidx.core.app.NotificationChannelCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
|
||||||
|
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||||
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
|
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
||||||
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import javax.inject.Inject
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class AutoFixService : CoroutineIntentService() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var autoFixUseCase: AutoFixUseCase
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
|
private lateinit var notificationManager: NotificationManagerCompat
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun processIntent(startId: Int, intent: Intent) {
|
||||||
|
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
|
||||||
|
startForeground(startId)
|
||||||
|
try {
|
||||||
|
for (mangaId in ids) {
|
||||||
|
val result = runCatchingCancellable {
|
||||||
|
autoFixUseCase.invoke(mangaId)
|
||||||
|
}
|
||||||
|
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
val notification = buildNotification(result)
|
||||||
|
notificationManager.notify(TAG, startId, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(startId: Int, error: Throwable) {
|
||||||
|
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
val notification = runBlocking { buildNotification(Result.failure(error)) }
|
||||||
|
notificationManager.notify(TAG, startId, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("InlinedApi")
|
||||||
|
private fun startForeground(startId: Int) {
|
||||||
|
val title = applicationContext.getString(R.string.fixing_manga)
|
||||||
|
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
|
||||||
|
.setName(title)
|
||||||
|
.setShowBadge(false)
|
||||||
|
.setVibrationEnabled(false)
|
||||||
|
.setSound(null, null)
|
||||||
|
.setLightsEnabled(false)
|
||||||
|
.build()
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setProgress(0, 0, true)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_auto_fix)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
.addAction(
|
||||||
|
materialR.drawable.material_ic_clear_black_24dp,
|
||||||
|
applicationContext.getString(android.R.string.cancel),
|
||||||
|
getCancelIntent(startId),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
ServiceCompat.startForeground(
|
||||||
|
this,
|
||||||
|
FOREGROUND_NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun buildNotification(result: Result<Pair<Manga, Manga?>>): Notification {
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
result.onSuccess { (seed, replacement) ->
|
||||||
|
if (replacement != null) {
|
||||||
|
notification.setLargeIcon(
|
||||||
|
coil.execute(
|
||||||
|
ImageRequest.Builder(applicationContext)
|
||||||
|
.data(replacement.coverUrl)
|
||||||
|
.tag(replacement.source)
|
||||||
|
.build(),
|
||||||
|
).toBitmapOrNull(),
|
||||||
|
)
|
||||||
|
notification.setSubText(replacement.title)
|
||||||
|
val intent = DetailsActivity.newIntent(applicationContext, replacement)
|
||||||
|
notification.setContentIntent(
|
||||||
|
PendingIntentCompat.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
replacement.id.toInt(),
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
).setVisibility(
|
||||||
|
if (replacement.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC,
|
||||||
|
)
|
||||||
|
notification
|
||||||
|
.setContentTitle(applicationContext.getString(R.string.fixed))
|
||||||
|
.setContentText(
|
||||||
|
applicationContext.getString(
|
||||||
|
R.string.manga_replaced,
|
||||||
|
seed.title,
|
||||||
|
seed.source.getTitle(applicationContext),
|
||||||
|
replacement.title,
|
||||||
|
replacement.source.getTitle(applicationContext),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_done)
|
||||||
|
} else {
|
||||||
|
notification
|
||||||
|
.setContentTitle(applicationContext.getString(R.string.fixing_manga))
|
||||||
|
.setContentText(applicationContext.getString(R.string.no_fix_required, seed.title))
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
notification
|
||||||
|
.setContentTitle(applicationContext.getString(R.string.error_occurred))
|
||||||
|
.setContentText(
|
||||||
|
if (error is AutoFixUseCase.NoAlternativesException) {
|
||||||
|
applicationContext.getString(R.string.no_alternatives_found, error.seed.manga.title)
|
||||||
|
} else {
|
||||||
|
error.getDisplayMessage(applicationContext.resources)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
|
.addAction(
|
||||||
|
R.drawable.ic_alert_outline,
|
||||||
|
applicationContext.getString(R.string.report),
|
||||||
|
ErrorReporterReceiver.getPendingIntent(applicationContext, error),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return notification.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val DATA_IDS = "ids"
|
||||||
|
private const val TAG = "auto_fix"
|
||||||
|
private const val CHANNEL_ID = "auto_fix"
|
||||||
|
private const val FOREGROUND_NOTIFICATION_ID = 38
|
||||||
|
|
||||||
|
fun start(context: Context, mangaIds: Collection<Long>): Boolean = try {
|
||||||
|
val intent = Intent(context, AutoFixService::class.java)
|
||||||
|
intent.putExtra(DATA_IDS, mangaIds.toLongArray())
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="#FFFFFF"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<group
|
||||||
|
android:scaleX="0.92"
|
||||||
|
android:scaleY="0.92"
|
||||||
|
android:translateX="0.96"
|
||||||
|
android:translateY="0.96">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M7.5,5.6L5,7L6.4,4.5L5,2L7.5,3.4L10,2L8.6,4.5L10,7L7.5,5.6M19.5,15.4L22,14L20.6,16.5L22,19L19.5,17.6L17,19L18.4,16.5L17,14L19.5,15.4M22,2L20.6,4.5L22,7L19.5,5.6L17,7L18.4,4.5L17,2L19.5,3.4L22,2M13.34,12.78L15.78,10.34L13.66,8.22L11.22,10.66L13.34,12.78M14.37,7.29L16.71,9.63C17.1,10 17.1,10.65 16.71,11.04L5.04,22.71C4.65,23.1 4,23.1 3.63,22.71L1.29,20.37C0.9,20 0.9,19.35 1.29,18.96L12.96,7.29C13.35,6.9 14,6.9 14.37,7.29Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 631 B |
Binary file not shown.
|
After Width: | Height: | Size: 404 B |
Binary file not shown.
|
After Width: | Height: | Size: 851 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@ -0,0 +1,11 @@
|
|||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:viewportWidth="24.0"
|
||||||
|
android:viewportHeight="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000"
|
||||||
|
android:pathData="M7.5,5.6L5,7L6.4,4.5L5,2L7.5,3.4L10,2L8.6,4.5L10,7L7.5,5.6M19.5,15.4L22,14L20.6,16.5L22,19L19.5,17.6L17,19L18.4,16.5L17,14L19.5,15.4M22,2L20.6,4.5L22,7L19.5,5.6L17,7L18.4,4.5L17,2L19.5,3.4L22,2M13.34,12.78L15.78,10.34L13.66,8.22L11.22,10.66L13.34,12.78M14.37,7.29L16.71,9.63C17.1,10 17.1,10.65 16.71,11.04L5.04,22.71C4.65,23.1 4,23.1 3.63,22.71L1.29,20.37C0.9,20 0.9,19.35 1.29,18.96L12.96,7.29C13.35,6.9 14,6.9 14.37,7.29Z" />
|
||||||
|
</vector>
|
||||||
Loading…
Reference in New Issue