From c74d71170211394f0e498dea61ac1220ec5e3b75 Mon Sep 17 00:00:00 2001 From: dev-claw <53543762+dev-claw@users.noreply.github.com> Date: Fri, 27 Dec 2024 19:12:07 +0100 Subject: [PATCH] fixes #208 (#209) --- pom.xml | 4 +- vripper-core/pom.xml | 2 +- .../src/main/kotlin/me/vripper/Module.kt | 8 +- ...adStateRepository.kt => PostRepository.kt} | 7 +- ...epositoryImpl.kt => PostRepositoryImpl.kt} | 24 ++--- .../me/vripper/download/DownloadService.kt | 90 ++++++++---------- .../vripper/download/ImageDownloadContext.kt | 2 - .../vripper/download/ImageDownloadRunnable.kt | 36 +++---- .../src/main/kotlin/me/vripper/host/Host.kt | 4 +- .../me/vripper/services/AppEndpointService.kt | 48 +++++----- .../me/vripper/services/DataTransaction.kt | 61 ++++++++---- .../kotlin/me/vripper/services/HTTPService.kt | 12 +-- .../me/vripper/services/RetryPolicyService.kt | 19 ++-- .../me/vripper/services/ThreadCacheService.kt | 9 +- .../me/vripper/services/VGAuthService.kt | 8 +- .../me/vripper/tasks/ThreadLookupTask.kt | 13 +-- .../me/vripper/utilities/GlobalScope.kt | 14 --- .../main/kotlin/me/vripper/utilities/Utils.kt | 6 ++ .../me/vripper/vgapi/PostLookupAPIParser.kt | 8 +- .../me/vripper/vgapi/ThreadLookupAPIParser.kt | 8 +- .../gui/components/fragments/AboutFragment.kt | 27 ++++-- .../resources/icons/buymeacoffee-logo.png | Bin 0 -> 1455 bytes .../src/main/resources/icons/github-mark.png | Bin 0 -> 6393 bytes 23 files changed, 206 insertions(+), 204 deletions(-) rename vripper-core/src/main/kotlin/me/vripper/data/repositories/{PostDownloadStateRepository.kt => PostRepository.kt} (76%) rename vripper-core/src/main/kotlin/me/vripper/data/repositories/impl/{PostDownloadStateRepositoryImpl.kt => PostRepositoryImpl.kt} (92%) delete mode 100644 vripper-core/src/main/kotlin/me/vripper/utilities/GlobalScope.kt create mode 100644 vripper-core/src/main/kotlin/me/vripper/utilities/Utils.kt create mode 100644 vripper-gui/src/main/resources/icons/buymeacoffee-logo.png create mode 100644 vripper-gui/src/main/resources/icons/github-mark.png diff --git a/pom.xml b/pom.xml index 16b56ac6..356a5442 100644 --- a/pom.xml +++ b/pom.xml @@ -25,7 +25,7 @@ 4.0.0 2.2.224 2.29 - 2.4.4 + 3.3.2 3.1.8 5.14.0 1.7.3 @@ -142,8 +142,8 @@ ${htmlcleaner.version} + dev.failsafe failsafe - net.jodah ${failsafe.version} diff --git a/vripper-core/pom.xml b/vripper-core/pom.xml index 4e347481..f6e788aa 100644 --- a/vripper-core/pom.xml +++ b/vripper-core/pom.xml @@ -69,7 +69,7 @@ failsafe - net.jodah + dev.failsafe caffeine diff --git a/vripper-core/src/main/kotlin/me/vripper/Module.kt b/vripper-core/src/main/kotlin/me/vripper/Module.kt index 1e7517f8..7478bea3 100644 --- a/vripper-core/src/main/kotlin/me/vripper/Module.kt +++ b/vripper-core/src/main/kotlin/me/vripper/Module.kt @@ -2,11 +2,11 @@ package me.vripper import me.vripper.data.repositories.ImageRepository import me.vripper.data.repositories.MetadataRepository -import me.vripper.data.repositories.PostDownloadStateRepository +import me.vripper.data.repositories.PostRepository import me.vripper.data.repositories.ThreadRepository import me.vripper.data.repositories.impl.ImageRepositoryImpl import me.vripper.data.repositories.impl.MetadataRepositoryImpl -import me.vripper.data.repositories.impl.PostDownloadStateRepositoryImpl +import me.vripper.data.repositories.impl.PostRepositoryImpl import me.vripper.data.repositories.impl.ThreadRepositoryImpl import me.vripper.download.DownloadService import me.vripper.event.EventBus @@ -29,8 +29,8 @@ val coreModule = module { single { ImageRepositoryImpl() } - single { - PostDownloadStateRepositoryImpl() + single { + PostRepositoryImpl() } single { MetadataRepositoryImpl() diff --git a/vripper-core/src/main/kotlin/me/vripper/data/repositories/PostDownloadStateRepository.kt b/vripper-core/src/main/kotlin/me/vripper/data/repositories/PostRepository.kt similarity index 76% rename from vripper-core/src/main/kotlin/me/vripper/data/repositories/PostDownloadStateRepository.kt rename to vripper-core/src/main/kotlin/me/vripper/data/repositories/PostRepository.kt index 3d0fe706..333c8810 100644 --- a/vripper-core/src/main/kotlin/me/vripper/data/repositories/PostDownloadStateRepository.kt +++ b/vripper-core/src/main/kotlin/me/vripper/data/repositories/PostRepository.kt @@ -1,12 +1,11 @@ package me.vripper.data.repositories import me.vripper.entities.PostEntity -import java.util.* -internal interface PostDownloadStateRepository { +internal interface PostRepository { fun save(postEntities: List): List - fun findByPostId(postId: Long): Optional - fun findById(id: Long): Optional + fun findByPostId(postId: Long): PostEntity? + fun findById(id: Long): PostEntity? fun findCompleted(): List fun findAll(): List fun existByPostId(postId: Long): Boolean diff --git a/vripper-core/src/main/kotlin/me/vripper/data/repositories/impl/PostDownloadStateRepositoryImpl.kt b/vripper-core/src/main/kotlin/me/vripper/data/repositories/impl/PostRepositoryImpl.kt similarity index 92% rename from vripper-core/src/main/kotlin/me/vripper/data/repositories/impl/PostDownloadStateRepositoryImpl.kt rename to vripper-core/src/main/kotlin/me/vripper/data/repositories/impl/PostRepositoryImpl.kt index 2fcef1e9..6d0d3263 100644 --- a/vripper-core/src/main/kotlin/me/vripper/data/repositories/impl/PostDownloadStateRepositoryImpl.kt +++ b/vripper-core/src/main/kotlin/me/vripper/data/repositories/impl/PostRepositoryImpl.kt @@ -1,6 +1,6 @@ package me.vripper.data.repositories.impl -import me.vripper.data.repositories.PostDownloadStateRepository +import me.vripper.data.repositories.PostRepository import me.vripper.data.tables.PostTable import me.vripper.entities.PostEntity import me.vripper.entities.Status @@ -8,10 +8,9 @@ import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.transactions.TransactionManager import java.sql.Connection -import java.util.* -internal class PostDownloadStateRepositoryImpl : - PostDownloadStateRepository { +internal class PostRepositoryImpl : + PostRepository { private val delimiter = ";" @@ -37,15 +36,11 @@ internal class PostDownloadStateRepositoryImpl : }.map(::transform) } - override fun findByPostId(postId: Long): Optional { + override fun findByPostId(postId: Long): PostEntity? { val result = PostTable.selectAll().where { PostTable.postId eq postId }.map(::transform) - return if (result.isEmpty()) { - Optional.empty() - } else { - Optional.of(result.first()) - } + return result.firstOrNull() } override fun findCompleted(): List { @@ -54,16 +49,11 @@ internal class PostDownloadStateRepositoryImpl : }.map { it[PostTable.postId] } } - override fun findById(id: Long): Optional { + override fun findById(id: Long): PostEntity? { val result = PostTable.selectAll().where { PostTable.id eq id }.map { transform(it) } - - return if (result.isEmpty()) { - Optional.empty() - } else { - Optional.of(result.first()) - } + return result.firstOrNull() } override fun findAll(): List { diff --git a/vripper-core/src/main/kotlin/me/vripper/download/DownloadService.kt b/vripper-core/src/main/kotlin/me/vripper/download/DownloadService.kt index 780c55bc..6ad1a0fe 100644 --- a/vripper-core/src/main/kotlin/me/vripper/download/DownloadService.kt +++ b/vripper-core/src/main/kotlin/me/vripper/download/DownloadService.kt @@ -1,7 +1,8 @@ package me.vripper.download +import dev.failsafe.Failsafe +import dev.failsafe.RetryPolicy import kotlinx.coroutines.Runnable -import kotlinx.coroutines.launch import me.vripper.entities.ImageEntity import me.vripper.entities.PostEntity import me.vripper.entities.Status @@ -16,10 +17,8 @@ import me.vripper.services.DataTransaction import me.vripper.services.RetryPolicyService import me.vripper.services.SettingsService import me.vripper.services.VGAuthService -import me.vripper.utilities.GlobalScopeCoroutine import me.vripper.utilities.LoggerDelegate -import net.jodah.failsafe.Failsafe -import net.jodah.failsafe.RetryPolicy +import me.vripper.utilities.executorService import org.jetbrains.exposed.sql.transactions.transaction import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock @@ -46,14 +45,14 @@ internal class DownloadService( lock.withLock { candidates.addAll(getCandidates(candidateCount())) candidates.forEach { - if (canRun(it.context.imageEntity.host)) { + if (canRun(it.imageEntity.host)) { accepted.add(it) - running[it.context.imageEntity.host]!!.add(it) - log.debug("${it.context.imageEntity.url} accepted to run") + running[it.imageEntity.host]!!.add(it) + log.debug("${it.imageEntity.url} accepted to run") } } accepted.forEach { - pending[it.context.imageEntity.host]?.remove(it) + pending[it.imageEntity.host]?.remove(it) scheduleForDownload(it) } accepted.clear() @@ -144,18 +143,18 @@ internal class DownloadService( } private fun isPending(postId: Long): Boolean { - return pending.values.flatten().any { it.context.imageEntity.postId == postId } + return pending.values.flatten().any { it.imageEntity.postId == postId } } private fun isRunning(postId: Long): Boolean { - return running.values.flatten().any { it.context.imageEntity.postId == postId } + return running.values.flatten().any { it.imageEntity.postId == postId } } private fun stopAll() { lock.withLock { pending.values.clear() running.values.flatten().forEach { obj: ImageDownloadRunnable -> obj.stop() } - while (running.values.flatten().count { !it.context.completed } > 0) { + while (running.values.flatten().count { !it.completed } > 0) { Thread.sleep(100) } dataTransaction.findAllNonCompletedPostIds().forEach { @@ -169,13 +168,13 @@ internal class DownloadService( lock.withLock { for (postId in postIds) { pending.values.forEach { pending -> - pending.removeIf { it.context.imageEntity.postId == postId } + pending.removeIf { it.imageEntity.postId == postId } } running.values.flatten() - .filter { p: ImageDownloadRunnable -> p.context.imageEntity.postId == postId } + .filter { p: ImageDownloadRunnable -> p.imageEntity.postId == postId } .forEach { obj: ImageDownloadRunnable -> obj.stop() } while (running.values.flatten() - .count { !it.context.completed && it.context.imageEntity.postId == postId } > 0 + .count { !it.completed && it.imageEntity.postId == postId } > 0 ) { Thread.sleep(100) } @@ -213,7 +212,7 @@ internal class DownloadService( val list: List = pending[host]!!.sortedWith(Comparator.comparingInt { it.postRank } - .thenComparingInt { it.context.imageEntity.index }) + .thenComparingInt { it.imageEntity.index }) for (imageDownloadRunnable in list) { val count = hostIntegerMap[host] ?: 0 @@ -229,47 +228,42 @@ internal class DownloadService( } private fun scheduleForDownload(imageDownloadRunnable: ImageDownloadRunnable) { - log.debug("Scheduling a job for ${imageDownloadRunnable.context.imageEntity.url}") - GlobalScopeCoroutine.launch { - eventBus.publishEvent(QueueStateEvent(QueueState(runningCount(), pendingCount()))) - try { - Failsafe.with>(retryPolicyService.buildRetryPolicyForDownload("Failed to download ${imageDownloadRunnable.context.imageEntity.url}: ")) - .onFailure { - log.error( - "Failed to download ${imageDownloadRunnable.context.imageEntity.url} after ${it.attemptCount} tries", - it.failure - ) - val image = imageDownloadRunnable.context.imageEntity - image.status = Status.ERROR - dataTransaction.updateImage(image) - } - .onComplete { - afterJobFinish(imageDownloadRunnable) - eventBus.publishEvent( - QueueStateEvent( - QueueState( - runningCount(), pendingCount() - ) - ) - ) - eventBus.publishEvent(ErrorCountEvent(ErrorCount(dataTransaction.countImagesInError()))) - log.debug( - "Finished downloading ${imageDownloadRunnable.context.imageEntity.url}" - ) - }.run(imageDownloadRunnable::run) - } catch (e: Exception) { - log.error("Download Failure", e) + log.debug("Scheduling a job for ${imageDownloadRunnable.imageEntity.url}") + eventBus.publishEvent(QueueStateEvent(QueueState(runningCount(), pendingCount()))) + Failsafe.with>(retryPolicyService.buildRetryPolicy("Failed to download ${imageDownloadRunnable.imageEntity.url}: ")) + .with(executorService) + .onFailure { + log.error( + "Failed to download ${imageDownloadRunnable.imageEntity.url} after ${it.attemptCount} tries", + it.exception + ) + val image = imageDownloadRunnable.imageEntity + image.status = Status.ERROR + dataTransaction.updateImage(image) } - } + .onComplete { + afterJobFinish(imageDownloadRunnable) + eventBus.publishEvent( + QueueStateEvent( + QueueState( + runningCount(), pendingCount() + ) + ) + ) + eventBus.publishEvent(ErrorCountEvent(ErrorCount(dataTransaction.countImagesInError()))) + log.debug( + "Finished downloading ${imageDownloadRunnable.imageEntity.url}" + ) + }.runAsync(imageDownloadRunnable) } private fun afterJobFinish(imageDownloadRunnable: ImageDownloadRunnable) { lock.withLock { - val image = imageDownloadRunnable.context.imageEntity + val image = imageDownloadRunnable.imageEntity running[image.host]!!.remove(imageDownloadRunnable) if (!isPending(image.postId) && !isRunning( image.postId - ) && !imageDownloadRunnable.context.stopped + ) && !imageDownloadRunnable.stopped ) { dataTransaction.finishPost(image.postId, true) } diff --git a/vripper-core/src/main/kotlin/me/vripper/download/ImageDownloadContext.kt b/vripper-core/src/main/kotlin/me/vripper/download/ImageDownloadContext.kt index 7231f24a..6fef9bb8 100644 --- a/vripper-core/src/main/kotlin/me/vripper/download/ImageDownloadContext.kt +++ b/vripper-core/src/main/kotlin/me/vripper/download/ImageDownloadContext.kt @@ -15,8 +15,6 @@ internal class ImageDownloadContext(val imageEntity: ImageEntity, val settings: HttpClientContext.create().apply { cookieStore = BasicCookieStore() } val requests = mutableListOf() val postId = imageEntity.postIdRef - var stopped = false - var completed = false fun cancelCoroutines() { runBlocking { diff --git a/vripper-core/src/main/kotlin/me/vripper/download/ImageDownloadRunnable.kt b/vripper-core/src/main/kotlin/me/vripper/download/ImageDownloadRunnable.kt index 45a9f4a1..762f9475 100644 --- a/vripper-core/src/main/kotlin/me/vripper/download/ImageDownloadRunnable.kt +++ b/vripper-core/src/main/kotlin/me/vripper/download/ImageDownloadRunnable.kt @@ -1,5 +1,6 @@ package me.vripper.download +import dev.failsafe.function.CheckedRunnable import me.vripper.entities.ImageEntity import me.vripper.entities.Status import me.vripper.exception.DownloadException @@ -13,7 +14,6 @@ import me.vripper.utilities.LoggerDelegate import me.vripper.utilities.PathUtils.getExtension import me.vripper.utilities.PathUtils.getFileNameWithoutExtension import me.vripper.utilities.PathUtils.sanitize -import net.jodah.failsafe.function.CheckedRunnable import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.io.IOException @@ -26,23 +26,23 @@ import kotlin.io.path.Path import kotlin.io.path.pathString internal class ImageDownloadRunnable( - private val imageEntity: ImageEntity, val postRank: Int, private val settings: Settings + val imageEntity: ImageEntity, val postRank: Int, private val settings: Settings ) : KoinComponent, CheckedRunnable { private val log by LoggerDelegate() - private val dataTransaction: DataTransaction by inject() private val hosts: List = getKoin().getAll() + var completed = false + var stopped = false - val context: ImageDownloadContext = ImageDownloadContext(imageEntity, settings) + private lateinit var context: ImageDownloadContext - @Throws(DownloadException::class) fun download() { try { imageEntity.status = Status.DOWNLOADING imageEntity.downloaded = 0 dataTransaction.updateImage(imageEntity) synchronized(imageEntity.postId.toString().intern()) { - val post = dataTransaction.findPostById(context.postId).orElseThrow() + val post = dataTransaction.findPostById(context.postId) if (post.status != Status.DOWNLOADING) { post.status = Status.DOWNLOADING dataTransaction.updatePost(post) @@ -54,7 +54,7 @@ internal class ImageDownloadRunnable( log.debug("Resolved name for ${imageEntity.url}: ${downloadedImage.name}") log.debug("Downloaded image {} to {}", imageEntity.url, downloadedImage.path) synchronized(imageEntity.postId.toString().intern()) { - val post = dataTransaction.findPostById(context.postId).orElseThrow() + val post = dataTransaction.findPostById(context.postId) val downloadDirectory = Path(post.downloadDirectory, post.folderName).pathString checkImageTypeAndRename( downloadDirectory, downloadedImage, imageEntity.index @@ -70,7 +70,7 @@ internal class ImageDownloadRunnable( dataTransaction.updateImage(imageEntity) } } catch (e: Exception) { - if (context.stopped) { + if (stopped) { return } imageEntity.status = Status.ERROR @@ -119,19 +119,26 @@ internal class ImageDownloadRunnable( } } - @Throws(Exception::class) override fun run() { + context = ImageDownloadContext(imageEntity, settings) try { - if (context.stopped) { + if (stopped) { return } download() } finally { - context.completed = true + completed = true context.cancelCoroutines() } } + fun stop() { + stopped = true + context.requests.forEach { it.abort() } + context.cancelCoroutines() + dataTransaction.updateImage(context.imageEntity) + } + override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || javaClass != other.javaClass) return false @@ -142,11 +149,4 @@ internal class ImageDownloadRunnable( override fun hashCode(): Int { return Objects.hash(imageEntity.id) } - - fun stop() { - context.requests.forEach { it.abort() } - context.cancelCoroutines() - context.stopped = true - dataTransaction.updateImage(context.imageEntity) - } } \ No newline at end of file diff --git a/vripper-core/src/main/kotlin/me/vripper/host/Host.kt b/vripper-core/src/main/kotlin/me/vripper/host/Host.kt index e8642f16..ebc9b7e4 100644 --- a/vripper-core/src/main/kotlin/me/vripper/host/Host.kt +++ b/vripper-core/src/main/kotlin/me/vripper/host/Host.kt @@ -106,7 +106,7 @@ internal abstract class Host( return BufferedOutputStream(Files.newOutputStream(tempImage)).use { bos -> val image = context.imageEntity synchronized(image.postId.toString().intern()) { - val post = dataTransaction.findPostById(context.postId).orElseThrow() + val post = dataTransaction.findPostById(context.postId) val size = if (image.size < 0) { response.entity.contentLength } else { @@ -134,7 +134,7 @@ internal abstract class Host( } } while (response.entity.content.read(buffer) - .also { read = it } != -1 && !context.stopped + .also { read = it } != -1 ) { bos.write(buffer, 0, read) image.downloaded += read diff --git a/vripper-core/src/main/kotlin/me/vripper/services/AppEndpointService.kt b/vripper-core/src/main/kotlin/me/vripper/services/AppEndpointService.kt index 2056b46c..691da88a 100644 --- a/vripper-core/src/main/kotlin/me/vripper/services/AppEndpointService.kt +++ b/vripper-core/src/main/kotlin/me/vripper/services/AppEndpointService.kt @@ -1,7 +1,6 @@ package me.vripper.services import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import kotlinx.coroutines.time.sample import kotlinx.serialization.json.Json import me.vripper.download.DownloadService @@ -13,9 +12,9 @@ import me.vripper.tasks.AddPostTask import me.vripper.tasks.ThreadLookupTask import me.vripper.utilities.ApplicationProperties import me.vripper.utilities.ApplicationProperties.VRIPPER_DIR -import me.vripper.utilities.GlobalScopeCoroutine import me.vripper.utilities.LoggerDelegate import me.vripper.utilities.PathUtils +import me.vripper.utilities.executorService import org.h2.jdbc.JdbcSQLNonTransientConnectionException import java.sql.DriverManager import java.time.Duration @@ -59,17 +58,17 @@ internal class AppEndpointService( threadId = m.group(1).toLong() postId = m.group(4)?.toLong() if (postId == null) { - GlobalScopeCoroutine.launch { + executorService.submit( ThreadLookupTask( threadId, settingsService.settings - ).run() - } + ) + ) } else { - GlobalScopeCoroutine.launch { + executorService.submit( AddPostTask( listOf(ThreadPostId(threadId, postId)) - ).run() - } + ) + ) } } else { log.error("Invalid link $link, link is missing the threadId") @@ -81,15 +80,13 @@ internal class AppEndpointService( override suspend fun restartAll(posIds: List) { lock.withLock { - downloadService.restartAll(posIds.map { dataTransaction.findPostByPostId(it) }.filter { it.isPresent } - .map { it.get() }) + downloadService.restartAll(posIds.filter { dataTransaction.exists(it) } + .map { dataTransaction.findPostByPostId(it) }) } } override suspend fun download(posts: List) { - GlobalScopeCoroutine.launch { - AddPostTask(posts).run() - } + executorService.submit(AddPostTask(posts)) } override suspend fun stopAll(postIdList: List) { @@ -185,16 +182,21 @@ internal class AppEndpointService( } override suspend fun rename(postId: Long, newName: String) { - GlobalScopeCoroutine.launch { + executorService.submit { synchronized(postId.toString().intern()) { - dataTransaction.findPostByPostId(postId).ifPresent { post -> - if (Path(post.downloadDirectory, post.folderName).exists()) { - PathUtils.rename( - dataTransaction.findImagesByPostId(postId), post.downloadDirectory, post.folderName, newName - ) + if (dataTransaction.exists(postId)) { + dataTransaction.findPostByPostId(postId).let { post -> + if (Path(post.downloadDirectory, post.folderName).exists()) { + PathUtils.rename( + dataTransaction.findImagesByPostId(postId), + post.downloadDirectory, + post.folderName, + newName + ) + } + post.folderName = PathUtils.sanitize(newName) + dataTransaction.updatePost(post) } - post.folderName = PathUtils.sanitize(newName) - dataTransaction.updatePost(post) } } } @@ -251,7 +253,7 @@ internal class AppEndpointService( } override suspend fun findPost(postId: Long): Post { - return mapper(dataTransaction.findPostByPostId(postId).orElseThrow()) + return mapper(dataTransaction.findPostByPostId(postId)) } override suspend fun findImagesByPostId(postId: Long): List { @@ -353,7 +355,7 @@ internal class AppEndpointService( val addedAt = it.getTimestamp("ADDED_AT") val folderName = it.getString("FOLDER_NAME") ?: "" - val exists = dataTransaction.findPostByPostId(postId).isPresent + val exists = dataTransaction.exists(postId) if (exists) { continue } diff --git a/vripper-core/src/main/kotlin/me/vripper/services/DataTransaction.kt b/vripper-core/src/main/kotlin/me/vripper/services/DataTransaction.kt index d24b7116..447dd16a 100644 --- a/vripper-core/src/main/kotlin/me/vripper/services/DataTransaction.kt +++ b/vripper-core/src/main/kotlin/me/vripper/services/DataTransaction.kt @@ -1,8 +1,10 @@ package me.vripper.services +import com.github.benmanes.caffeine.cache.Caffeine +import com.github.benmanes.caffeine.cache.LoadingCache import me.vripper.data.repositories.ImageRepository import me.vripper.data.repositories.MetadataRepository -import me.vripper.data.repositories.PostDownloadStateRepository +import me.vripper.data.repositories.PostRepository import me.vripper.data.repositories.ThreadRepository import me.vripper.entities.* import me.vripper.event.* @@ -12,12 +14,13 @@ import me.vripper.utilities.PathUtils.sanitize import me.vripper.vgapi.PostItem import org.jetbrains.exposed.sql.transactions.transaction import java.util.* +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import kotlin.io.path.pathString internal class DataTransaction( private val settingsService: SettingsService, - private val postDownloadStateRepository: PostDownloadStateRepository, + private val postRepository: PostRepository, private val imageRepository: ImageRepository, private val threadRepository: ThreadRepository, private val metadataRepository: MetadataRepository, @@ -25,15 +28,24 @@ internal class DataTransaction( ) { private val nextRank = AtomicInteger(transaction { getQueuePosition() }?.plus(1) ?: 0) + private val postEntityIdCache: LoadingCache = + Caffeine.newBuilder().expireAfterAccess(5, TimeUnit.MINUTES).build { id -> + transaction { postRepository.findById(id) } + } + + private val postPostIdCache: LoadingCache = + Caffeine.newBuilder().expireAfterAccess(5, TimeUnit.MINUTES).build { id -> + transaction { postRepository.findByPostId(id) } + } private fun save(postEntities: List): List { - return transaction { postDownloadStateRepository.save(postEntities) } + return transaction { postRepository.save(postEntities) } } fun saveAndNotify(postEntity: PostEntity, images: List) { val savedPost = transaction { val savedPost = - postDownloadStateRepository.save(listOf(postEntity.copy(rank = nextRank.andIncrement))).first() + postRepository.save(listOf(postEntity.copy(rank = nextRank.andIncrement))).first() save(images.map { it.copy(postIdRef = savedPost.id) }) savedPost } @@ -41,12 +53,18 @@ internal class DataTransaction( } fun updatePosts(postEntities: List) { - transaction { postDownloadStateRepository.update(postEntities) } + transaction { postRepository.update(postEntities) } + postEntities.forEach { postEntity -> + postPostIdCache.put(postEntity.postId, postEntity) + postEntityIdCache.put(postEntity.id, postEntity) + } eventBus.publishEvent(PostUpdateEvent(postEntities)) } fun updatePost(postEntity: PostEntity) { - transaction { postDownloadStateRepository.update(postEntity) } + transaction { postRepository.update(postEntity) } + postPostIdCache.put(postEntity.postId, postEntity) + postEntityIdCache.put(postEntity.id, postEntity) eventBus.publishEvent(PostUpdateEvent(listOf(postEntity))) } @@ -75,7 +93,10 @@ internal class DataTransaction( } fun exists(postId: Long): Boolean { - return transaction { postDownloadStateRepository.existByPostId(postId) } + if (postPostIdCache.getIfPresent(postId) != null) { + return true + } + return transaction { postRepository.existByPostId(postId) } } @Synchronized @@ -128,7 +149,7 @@ internal class DataTransaction( } private fun getQueuePosition(): Int? { - return postDownloadStateRepository.findMaxRank() + return postRepository.findMaxRank() } private fun save(imageEntities: List) { @@ -136,7 +157,7 @@ internal class DataTransaction( } fun finishPost(postId: Long, automatic: Boolean = false) { - val post = findPostByPostId(postId).orElseThrow() + val post = findPostByPostId(postId) val imagesInErrorStatus = findByPostIdAndIsError(post.postId) if (imagesInErrorStatus.isNotEmpty()) { post.status = Status.ERROR @@ -167,9 +188,13 @@ internal class DataTransaction( transaction { metadataRepository.deleteAllByPostId(postIds) imageRepository.deleteAllByPostId(postIds) - postDownloadStateRepository.deleteAll(postIds) + postRepository.deleteAll(postIds) sortPostsByRank() } + postIds.forEach { postId -> + postPostIdCache.get(postId)?.let { postEntityIdCache.invalidate(it.id) } + postPostIdCache.invalidate(postId) + } eventBus.publishEvent(PostDeleteEvent(postIds = postIds)) eventBus.publishEvent(ErrorCountEvent(ErrorCount(countImagesInError()))) } @@ -180,7 +205,7 @@ internal class DataTransaction( } fun clearCompleted(): List { - val completed = transaction { postDownloadStateRepository.findCompleted() } + val completed = transaction { postRepository.findCompleted() } remove(completed) return completed } @@ -225,15 +250,15 @@ internal class DataTransaction( } fun setDownloadingToStopped() { - transaction { postDownloadStateRepository.setDownloadingToStopped() } + transaction { postRepository.setDownloadingToStopped() } } fun findAllPosts(): List { - return transaction { postDownloadStateRepository.findAll() } + return transaction { postRepository.findAll() } } - fun findPostById(id: Long): Optional { - return transaction { postDownloadStateRepository.findById(id) } + fun findPostById(id: Long): PostEntity { + return postEntityIdCache.get(id) ?: throw NoSuchElementException("Post with id = $id does not exist") } fun findImagesByPostId(postId: Long): List { @@ -262,8 +287,8 @@ internal class DataTransaction( return transaction { imageRepository.countError() } } - fun findPostByPostId(postId: Long): Optional { - return transaction { postDownloadStateRepository.findByPostId(postId) } + fun findPostByPostId(postId: Long): PostEntity { + return postPostIdCache.get(postId) ?: throw NoSuchElementException("Post with postId = $postId does not exist") } fun findThreadByThreadId(threadId: Long): Optional { @@ -271,7 +296,7 @@ internal class DataTransaction( } fun findAllNonCompletedPostIds(): List { - return transaction { postDownloadStateRepository.findAllNonCompletedPostIds() } + return transaction { postRepository.findAllNonCompletedPostIds() } } fun findMetadataByPostId(postId: Long): Optional { diff --git a/vripper-core/src/main/kotlin/me/vripper/services/HTTPService.kt b/vripper-core/src/main/kotlin/me/vripper/services/HTTPService.kt index d5f4d133..25372e62 100644 --- a/vripper-core/src/main/kotlin/me/vripper/services/HTTPService.kt +++ b/vripper-core/src/main/kotlin/me/vripper/services/HTTPService.kt @@ -14,7 +14,6 @@ import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder import org.apache.hc.core5.pool.PoolConcurrencyPolicy import org.apache.hc.core5.pool.PoolReusePolicy -import org.apache.hc.core5.util.TimeValue import org.apache.hc.core5.util.Timeout internal class HTTPService( @@ -41,8 +40,10 @@ internal class HTTPService( buildConnectionPool() buildClientBuilder() coroutineScope.launch { - pcm.closeIdle(TimeValue.ofSeconds(60)) - delay(15000) + while (isActive) { + pcm.closeExpired() + delay(60_000) + } } coroutineScope.launch { eventBus @@ -64,8 +65,8 @@ internal class HTTPService( private fun buildConnectionPool() { pcm = PoolingHttpClientConnectionManagerBuilder.create() - .setPoolConcurrencyPolicy(PoolConcurrencyPolicy.STRICT) - .setConnPoolPolicy(PoolReusePolicy.LIFO) + .setPoolConcurrencyPolicy(PoolConcurrencyPolicy.LAX) + .setConnPoolPolicy(PoolReusePolicy.FIFO) .setDefaultConnectionConfig(cc) .setMaxConnTotal(Int.MAX_VALUE) .setMaxConnPerRoute(Int.MAX_VALUE) @@ -83,7 +84,6 @@ internal class HTTPService( cc = ConnectionConfig.custom() .setConnectTimeout(Timeout.ofSeconds(connectionTimeout.toLong())) .setSocketTimeout(Timeout.ofSeconds(connectionTimeout.toLong())) - .setTimeToLive(TimeValue.ofMinutes(10)) .build() } diff --git a/vripper-core/src/main/kotlin/me/vripper/services/RetryPolicyService.kt b/vripper-core/src/main/kotlin/me/vripper/services/RetryPolicyService.kt index 362edef6..84479d03 100644 --- a/vripper-core/src/main/kotlin/me/vripper/services/RetryPolicyService.kt +++ b/vripper-core/src/main/kotlin/me/vripper/services/RetryPolicyService.kt @@ -1,5 +1,6 @@ package me.vripper.services +import dev.failsafe.RetryPolicy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -8,8 +9,6 @@ import kotlinx.coroutines.launch import me.vripper.event.EventBus import me.vripper.event.SettingsUpdateEvent import me.vripper.utilities.LoggerDelegate -import net.jodah.failsafe.RetryPolicy -import net.jodah.failsafe.event.ExecutionAttemptedEvent import java.time.temporal.ChronoUnit internal class RetryPolicyService( @@ -29,16 +28,10 @@ internal class RetryPolicyService( } } - fun buildRetryPolicyForDownload(message: String): RetryPolicy { - return RetryPolicy().withDelay(2, 5, ChronoUnit.SECONDS).withMaxAttempts(maxAttempts).onFailedAttempt { - log.warn(message + "#${it.attemptCount} tries failed", it.lastFailure) - } - } - - fun buildGenericRetryPolicy(message: String): RetryPolicy { - return RetryPolicy().withDelay(2, 5, ChronoUnit.SECONDS).withMaxAttempts(maxAttempts) - .onFailedAttempt { e: ExecutionAttemptedEvent -> - log.warn(message + "#${e.attemptCount} tries failed", e.lastFailure) - } + fun buildRetryPolicy(message: String): RetryPolicy { + return RetryPolicy.builder().withDelay(2, 5, ChronoUnit.SECONDS).withMaxAttempts(maxAttempts) + .onFailedAttempt { + log.warn(message + "#${it.attemptCount} tries failed", it.lastException) + }.build() } } \ No newline at end of file diff --git a/vripper-core/src/main/kotlin/me/vripper/services/ThreadCacheService.kt b/vripper-core/src/main/kotlin/me/vripper/services/ThreadCacheService.kt index 22f5b1a7..a906cb73 100644 --- a/vripper-core/src/main/kotlin/me/vripper/services/ThreadCacheService.kt +++ b/vripper-core/src/main/kotlin/me/vripper/services/ThreadCacheService.kt @@ -2,8 +2,11 @@ package me.vripper.services import com.github.benmanes.caffeine.cache.Caffeine import com.github.benmanes.caffeine.cache.LoadingCache -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch import me.vripper.event.EventBus import me.vripper.event.SettingsUpdateEvent import me.vripper.vgapi.ThreadItem @@ -25,9 +28,7 @@ internal class ThreadCacheService(val eventBus: EventBus) { private val cache: LoadingCache = Caffeine.newBuilder().expireAfterWrite(20, TimeUnit.MINUTES).build { threadId -> - runBlocking { - ThreadLookupAPIParser(threadId).parse() - } + ThreadLookupAPIParser(threadId).parse() } @Throws(ExecutionException::class) diff --git a/vripper-core/src/main/kotlin/me/vripper/services/VGAuthService.kt b/vripper-core/src/main/kotlin/me/vripper/services/VGAuthService.kt index a0a5c83a..89bb9bc1 100644 --- a/vripper-core/src/main/kotlin/me/vripper/services/VGAuthService.kt +++ b/vripper-core/src/main/kotlin/me/vripper/services/VGAuthService.kt @@ -12,8 +12,8 @@ import me.vripper.event.VGUserLoginEvent import me.vripper.exception.VripperException import me.vripper.model.Settings import me.vripper.tasks.LeaveThanksTask -import me.vripper.utilities.GlobalScopeCoroutine import me.vripper.utilities.LoggerDelegate +import me.vripper.utilities.executorService import org.apache.hc.client5.http.classic.methods.HttpPost import org.apache.hc.client5.http.cookie.BasicCookieStore import org.apache.hc.client5.http.cookie.Cookie @@ -108,8 +108,8 @@ internal class VGAuthService( } fun leaveThanks(postEntity: PostEntity) { - GlobalScopeCoroutine.launch { - LeaveThanksTask(postEntity, authenticated, context).run() - } + executorService.submit( + LeaveThanksTask(postEntity, authenticated, context) + ) } } \ No newline at end of file diff --git a/vripper-core/src/main/kotlin/me/vripper/tasks/ThreadLookupTask.kt b/vripper-core/src/main/kotlin/me/vripper/tasks/ThreadLookupTask.kt index afc5f386..a6b2addf 100644 --- a/vripper-core/src/main/kotlin/me/vripper/tasks/ThreadLookupTask.kt +++ b/vripper-core/src/main/kotlin/me/vripper/tasks/ThreadLookupTask.kt @@ -1,14 +1,13 @@ package me.vripper.tasks -import kotlinx.coroutines.launch import me.vripper.entities.ThreadEntity import me.vripper.model.Settings import me.vripper.model.ThreadPostId import me.vripper.services.DataTransaction import me.vripper.services.SettingsService import me.vripper.services.ThreadCacheService -import me.vripper.utilities.GlobalScopeCoroutine import me.vripper.utilities.LoggerDelegate +import me.vripper.utilities.executorService import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -34,15 +33,14 @@ internal class ThreadLookupTask(private val threadId: Long, private val settings } if (threadLookupResult.postItemList.size <= settings.downloadSettings.autoQueueThreshold) { - GlobalScopeCoroutine.launch { + executorService.submit( AddPostTask(threadLookupResult.postItemList.map { ThreadPostId( it.threadId, it.postId ) - }).run() - } + }) + ) } else { - try { dataTransaction.save( ThreadEntity( title = threadLookupResult.title, @@ -51,9 +49,6 @@ internal class ThreadLookupTask(private val threadId: Long, private val settings total = threadLookupResult.postItemList.size ) ) - } catch (e: Exception) { - e.printStackTrace() - } } } } catch (e: Exception) { diff --git a/vripper-core/src/main/kotlin/me/vripper/utilities/GlobalScope.kt b/vripper-core/src/main/kotlin/me/vripper/utilities/GlobalScope.kt deleted file mode 100644 index b887f4a8..00000000 --- a/vripper-core/src/main/kotlin/me/vripper/utilities/GlobalScope.kt +++ /dev/null @@ -1,14 +0,0 @@ -package me.vripper.utilities - -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob - -private val log by LoggerDelegate() - -val errorHandler = CoroutineExceptionHandler { _, exception -> - log.error("Unexpected error", exception) -} - -val GlobalScopeCoroutine = CoroutineScope(SupervisorJob() + Dispatchers.IO + errorHandler) \ No newline at end of file diff --git a/vripper-core/src/main/kotlin/me/vripper/utilities/Utils.kt b/vripper-core/src/main/kotlin/me/vripper/utilities/Utils.kt new file mode 100644 index 00000000..05d82fc6 --- /dev/null +++ b/vripper-core/src/main/kotlin/me/vripper/utilities/Utils.kt @@ -0,0 +1,6 @@ +package me.vripper.utilities + +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +val executorService: ExecutorService = Executors.newVirtualThreadPerTaskExecutor() \ No newline at end of file diff --git a/vripper-core/src/main/kotlin/me/vripper/vgapi/PostLookupAPIParser.kt b/vripper-core/src/main/kotlin/me/vripper/vgapi/PostLookupAPIParser.kt index ab09f515..ab43b8ac 100644 --- a/vripper-core/src/main/kotlin/me/vripper/vgapi/PostLookupAPIParser.kt +++ b/vripper-core/src/main/kotlin/me/vripper/vgapi/PostLookupAPIParser.kt @@ -1,5 +1,7 @@ package me.vripper.vgapi +import dev.failsafe.Failsafe +import dev.failsafe.function.CheckedSupplier import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.withPermit import me.vripper.exception.DownloadException @@ -11,8 +13,6 @@ import me.vripper.services.VGAuthService import me.vripper.tasks.Tasks import me.vripper.utilities.LoggerDelegate import me.vripper.utilities.RequestLimit -import net.jodah.failsafe.Failsafe -import net.jodah.failsafe.function.CheckedSupplier import org.apache.hc.client5.http.classic.methods.HttpGet import org.apache.hc.core5.http.io.entity.EntityUtils import org.apache.hc.core5.net.URIBuilder @@ -42,9 +42,9 @@ internal class PostLookupAPIParser(private val threadId: Long, private val postI log.debug("Requesting {}", httpGet) Tasks.increment() return try { - Failsafe.with(retryPolicyService.buildGenericRetryPolicy("Failed to parse $httpGet: ")).onFailure { + Failsafe.with(retryPolicyService.buildRetryPolicy("Failed to parse $httpGet: ")).onFailure { log.error( - "Failed to process thread $threadId, post $postId", it.failure + "Failed to process thread $threadId, post $postId", it.exception ) }.get(CheckedSupplier { runBlocking { diff --git a/vripper-core/src/main/kotlin/me/vripper/vgapi/ThreadLookupAPIParser.kt b/vripper-core/src/main/kotlin/me/vripper/vgapi/ThreadLookupAPIParser.kt index b18b130f..bd7c050c 100644 --- a/vripper-core/src/main/kotlin/me/vripper/vgapi/ThreadLookupAPIParser.kt +++ b/vripper-core/src/main/kotlin/me/vripper/vgapi/ThreadLookupAPIParser.kt @@ -1,5 +1,7 @@ package me.vripper.vgapi +import dev.failsafe.Failsafe +import dev.failsafe.function.CheckedSupplier import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.withPermit import me.vripper.exception.DownloadException @@ -11,8 +13,6 @@ import me.vripper.services.VGAuthService import me.vripper.tasks.Tasks import me.vripper.utilities.LoggerDelegate import me.vripper.utilities.RequestLimit -import net.jodah.failsafe.Failsafe -import net.jodah.failsafe.function.CheckedSupplier import org.apache.hc.client5.http.classic.methods.HttpGet import org.apache.hc.core5.http.io.entity.EntityUtils import org.apache.hc.core5.net.URIBuilder @@ -43,10 +43,10 @@ internal class ThreadLookupAPIParser(private val threadId: Long) : KoinComponent log.debug("Requesting {}", httpGet) Tasks.increment() return try { - Failsafe.with(retryPolicyService.buildGenericRetryPolicy("Failed to parse $httpGet: ")).onFailure { + Failsafe.with(retryPolicyService.buildRetryPolicy("Failed to parse $httpGet: ")).onFailure { log.error( "Failed to process thread $threadId", - it.failure + it.exception ) }.get(CheckedSupplier { runBlocking { diff --git a/vripper-gui/src/main/kotlin/me/vripper/gui/components/fragments/AboutFragment.kt b/vripper-gui/src/main/kotlin/me/vripper/gui/components/fragments/AboutFragment.kt index a8e602fc..5580c3f2 100644 --- a/vripper-gui/src/main/kotlin/me/vripper/gui/components/fragments/AboutFragment.kt +++ b/vripper-gui/src/main/kotlin/me/vripper/gui/components/fragments/AboutFragment.kt @@ -23,8 +23,7 @@ class AboutFragment : Fragment("About") { padding = Insets(15.0, 15.0, 15.0, 15.0) spacing = 15.0 imageview("icons/64x64.png") - vbox { - spacing = 5.0 + vbox(spacing = 5.0) { text("VRipper") { style { fontWeight = FontWeight.BOLD @@ -32,15 +31,29 @@ class AboutFragment : Fragment("About") { } } text("Version ${ApplicationProperties.VERSION}") - text("Developed by death-claw and VRipper working group") - hyperlink("Home Page") { - action { - openLink("https://github.com/death-claw/vripper-project") + text("Developed by dev-claw and VRipper working group") + hbox(spacing = 5.0) { + hyperlink { + imageview("icons/github-mark.png").apply { + isPreserveRatio = true + fitHeight = 32.0 + } + action { + openLink("https://github.com/dev-claw/vripper-project") + } + } + hyperlink { + imageview("icons/buymeacoffee-logo.png").apply { + isPreserveRatio = true + fitHeight = 32.0 + } + action { + openLink("https://buymeacoffee.com/devclaw") + } } } } } - } tab("System") { form { diff --git a/vripper-gui/src/main/resources/icons/buymeacoffee-logo.png b/vripper-gui/src/main/resources/icons/buymeacoffee-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d28f44254e90d08f9913470f97b8e08c3ebdebf1 GIT binary patch literal 1455 zcmV;g1yK5lP)>bLL|bvR9gjBZO3Njzh%N#ik;RJ@WnCoi|5eCL z;wDnXW~t?m&8I3gnacH4s_R!>trNXdXO6d1eWac~PM+0qclP9vdRpt?^wZRnS`R06 z;;h!gS)J0_IJ5StKR@30y?Qwx)6_2?zrx&me_xj*=ZE&{?QU`}l-56i{Y%J;CWGVHX2NNxTkFCY&<*GiYEk^Gh~&ms4k=93w@HY8pumR=o_^WWc0~q)3(KsR_aeMj9Ae zNOD+9gHKeF+|XtT4XMGktWMSLd6sPMXx|8oa~6^z$>U^brDA9_0J0nex#0~V&R?nV zO3=-_jf_zT+0o)6Zb%(Tf@0|`t!K14b!U?$cP6@e@u~)aW_Gb4RRlpNE&eX3r2tuw z!~ygl%r7sB?w8yR6~a%cxCGO1fAW>(+fEOIXSQ)YlI0&9d1?X z%_$47PPO1@Qfdq)ViVvPmEDnYT(saS#}uz)R=BxMM#$3Sc?HdGjT1`Jk8 z28yyJtxDDM2-y5f3yy*7GV@RmxM;vc{WryHla(1%&G6e&0|Wp+zXRKO0Mp3Y#hFU9A@9#ZaY*r>(7==*?p_AmHQ5#z2loL>ANlGSY&Vx$^a7Dc~WX1hSF>*~i zT->Ngjv6A=I0e9rT7ae;oZ9uD{q4JRGjIPi(JI*hNzdxCRATJ3nj`tXvl#k-wn#a~ zEFGNc2-4*KJoViNceRSgT9S^yky;$Cf1-s|sf}r++dHq%H!F{}v_ghd(H$){zhLUZ z*4~@5)K90^wZaISF-P65)_AH6 zI*MYzD<1qkr*w246LmBt^^IN1dagA(UR$F>%k?=5UZ(^0e*ulMjWSDaZT0{F002ov JPDHLkV1fcQsZIa@ literal 0 HcmV?d00001 diff --git a/vripper-gui/src/main/resources/icons/github-mark.png b/vripper-gui/src/main/resources/icons/github-mark.png new file mode 100644 index 0000000000000000000000000000000000000000..6cb3b705d018006a2bd4200ea94c9d5fb98b6f76 GIT binary patch literal 6393 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 Hu0mjfZ$b4` literal 0 HcmV?d00001