This commit is contained in:
death-claw
2024-02-22 21:11:56 +01:00
parent c91d906759
commit 52ccee69dd
47 changed files with 928 additions and 519 deletions
+6 -1
View File
@@ -9,7 +9,7 @@
<version>${revision}</version>
<properties>
<java.version>21</java.version>
<kotlin.version>1.9.20</kotlin.version>
<kotlin.version>1.9.22</kotlin.version>
<maven.deploy.skip>false</maven.deploy.skip>
<timestamp>${maven.build.timestamp}</timestamp>
<maven.build.timestamp.format>yyyy-MM-dd HH:mm:ss</maven.build.timestamp.format>
@@ -150,6 +150,11 @@
<groupId>org.jetbrains.kotlin</groupId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-json</artifactId>
<version>1.6.2</version>
</dependency>
<dependency>
<artifactId>jcl-over-slf4j</artifactId>
<groupId>org.slf4j</groupId>
+19 -24
View File
@@ -43,10 +43,6 @@
<groupId>io.insert-koin</groupId>
<artifactId>koin-core-jvm</artifactId>
</dependency>
<dependency>
<artifactId>jackson-module-kotlin</artifactId>
<groupId>com.fasterxml.jackson.module</groupId>
</dependency>
<dependency>
<artifactId>h2</artifactId>
<groupId>com.h2database</groupId>
@@ -71,18 +67,6 @@
<artifactId>caffeine</artifactId>
<groupId>com.github.ben-manes.caffeine</groupId>
</dependency>
<dependency>
<artifactId>jackson-databind</artifactId>
<groupId>com.fasterxml.jackson.core</groupId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<artifactId>commons-codec</artifactId>
<groupId>commons-codec</groupId>
@@ -100,9 +84,13 @@
<groupId>com.mattbertolini</groupId>
<scope>runtime</scope>
</dependency>
<!-- <dependency>-->
<!-- <groupId>io.projectreactor</groupId>-->
<!-- <artifactId>reactor-core</artifactId>-->
<!-- </dependency>-->
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-json</artifactId>
</dependency>
</dependencies>
@@ -121,14 +109,21 @@
<goals>
<goal>compile</goal>
</goals>
<configuration>
<sourceDirs>
<source>src/main/kotlin</source>
<source>target/generated-sources/annotations</source>
</sourceDirs>
</configuration>
</execution>
</executions>
<configuration>
<jvmTarget>1.8</jvmTarget>
<compilerPlugins>
<plugin>kotlinx-serialization</plugin>
</compilerPlugins>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-serialization</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<artifactId>maven-jar-plugin</artifactId>
@@ -55,6 +55,9 @@ val coreModule = module {
single<AppEndpointService> {
AppEndpointService(get(), get(), get(), get())
}
single<MetadataService> {
MetadataService(get(), get(), get(), get())
}
single {
AcidimgHost(get(), get(), get())
} bind Host::class
@@ -1,6 +1,6 @@
package me.vripper.download
import kotlinx.coroutines.Runnable
import kotlinx.coroutines.*
import me.vripper.entities.Image
import me.vripper.entities.LogEntry
import me.vripper.entities.Post
@@ -37,6 +37,7 @@ class DownloadService(
private val pending: MutableMap<Byte, MutableList<ImageDownloadRunnable>> = mutableMapOf()
private val lock = ReentrantLock()
private val condition = lock.newCondition()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun init() {
Thread.ofVirtual().name("Download Loop").unstarted(Runnable {
@@ -166,7 +167,8 @@ class DownloadService(
pending.values.forEach { pending ->
pending.removeIf { it.context.image.postId == postId }
}
running.values.flatten().filter { p: ImageDownloadRunnable -> p.context.image.postId == postId }
running.values.flatten()
.filter { p: ImageDownloadRunnable -> p.context.image.postId == postId }
.forEach { obj: ImageDownloadRunnable -> obj.stop() }
}
postIds.forEach {
@@ -220,7 +222,9 @@ class DownloadService(
private fun scheduleForDownload(imageDownloadRunnable: ImageDownloadRunnable) {
log.debug("Scheduling a job for ${imageDownloadRunnable.context.image.url}")
CompletableFuture.runAsync({
coroutineScope.launch {
eventBus.publishEvent(QueueStateEvent(QueueState(runningCount(), pendingCount())))
}
Failsafe.with<Any, RetryPolicy<Any>>(retryPolicyService.buildRetryPolicyForDownload())
.onFailure {
try {
@@ -243,6 +247,7 @@ class DownloadService(
dataTransaction.updateImage(image)
}.onComplete {
afterJobFinish(imageDownloadRunnable)
coroutineScope.launch {
eventBus.publishEvent(
QueueStateEvent(
QueueState(
@@ -250,7 +255,10 @@ class DownloadService(
)
)
)
}
coroutineScope.launch {
eventBus.publishEvent(ErrorCountEvent(ErrorCount(dataTransaction.countImagesInError())))
}
log.debug(
"Finished downloading ${imageDownloadRunnable.context.image.url}"
)
@@ -10,8 +10,9 @@ import me.vripper.host.Host
import me.vripper.host.ImageMimeType
import me.vripper.model.Settings
import me.vripper.services.*
import me.vripper.utilities.PathUtils
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
@@ -56,10 +57,6 @@ class ImageDownloadRunnable(
val downloadedImage = host.downloadInternal(image.url, context)
log.debug("Resolved name for ${image.url}: ${downloadedImage.name}")
log.debug("Downloaded image {} to {}", image.url, downloadedImage.path)
val sanitizedFileName = PathUtils.sanitize(downloadedImage.name)
log.debug(
"Sanitizing image name from ${downloadedImage.name} to $sanitizedFileName"
)
synchronized(image.postId.toString().intern()) {
val post = dataTransaction.findPostById(context.postId).orElseThrow()
val downloadDirectory = Path(post.downloadDirectory, post.folderName).pathString
@@ -91,6 +88,7 @@ class ImageDownloadRunnable(
downloadDirectory: String, downloadedImage: DownloadedImage, index: Int
) {
val existingExtension = getExtension(downloadedImage.name).lowercase()
val fileNameWithoutExtension = getFileNameWithoutExtension(downloadedImage.name)
val extension = when (downloadedImage.type) {
ImageMimeType.IMAGE_BMP -> "BMP"
ImageMimeType.IMAGE_GIF -> "GIF"
@@ -99,18 +97,22 @@ class ImageDownloadRunnable(
ImageMimeType.IMAGE_WEBP -> "WEBP"
}
val filename =
if (existingExtension.isBlank()) "${downloadedImage.name}.$extension" else downloadedImage.name
if (existingExtension.isBlank()) "${sanitize(downloadedImage.name)}.$extension" else "${
sanitize(
fileNameWithoutExtension
)
}.$extension"
try {
val downloadDestinationFolder = Path.of(downloadDirectory)
Files.createDirectories(downloadDestinationFolder)
val image = downloadDestinationFolder.resolve(
"${
val finalFilename = "${
if (settings.downloadSettings.forceOrder) String.format(
"%03d_", index + 1
) else ""
}$filename"
)
Files.copy(downloadedImage.path, image, StandardCopyOption.REPLACE_EXISTING)
image.filename = finalFilename
val imageDownloadPath = downloadDestinationFolder.resolve(finalFilename)
Files.copy(downloadedImage.path, imageDownloadPath, StandardCopyOption.REPLACE_EXISTING)
} catch (e: Exception) {
throw HostException("Failed to rename the image", e)
} finally {
@@ -13,6 +13,7 @@ data class Image(
var size: Long = -1,
var downloaded: Long = 0,
var status: Status = Status.STOPPED,
var filename: String = "",
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -1,13 +1,12 @@
package me.vripper.entities
import com.fasterxml.jackson.annotation.JsonFormat
import java.time.LocalDateTime
data class LogEntry(
val id: Long = -1,
val type: Type,
val status: Status,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") val time: LocalDateTime = LocalDateTime.now(),
val time: LocalDateTime = LocalDateTime.now(),
val message: String,
) {
@@ -1,19 +1,11 @@
package me.vripper.entities
class Metadata {
var postIdRef: Long? = null
var postId: String? = null
var postedBy: String? = null
var resolvedNames = emptyList<String>()
import kotlinx.serialization.Serializable
companion object {
fun from(metadata: Metadata): Metadata {
val copy = Metadata()
copy.postIdRef = metadata.postIdRef
copy.postId = metadata.postId
copy.postedBy = metadata.postedBy
copy.resolvedNames = metadata.resolvedNames
return copy
}
}
data class Metadata(val postId: Long, val data: Data) {
@Serializable
data class Data(
val postedBy: String,
val resolvedNames: List<String>
)
}
@@ -1,6 +1,5 @@
package me.vripper.entities
import com.fasterxml.jackson.annotation.JsonFormat
import me.vripper.entities.domain.Status
import java.time.LocalDateTime
import kotlin.io.path.Path
@@ -18,7 +17,7 @@ data class Post(
val total: Int,
val hosts: Set<String>,
val downloadDirectory: String,
@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss") val addedOn: LocalDateTime = LocalDateTime.now(),
val addedOn: LocalDateTime = LocalDateTime.now(),
var folderName: String,
var status: Status = Status.STOPPED,
var done: Int = 0,
@@ -1,9 +1,6 @@
package me.vripper.event
import me.vripper.entities.Image
import me.vripper.entities.LogEntry
import me.vripper.entities.Post
import me.vripper.entities.Thread
import me.vripper.entities.*
import me.vripper.model.DownloadSpeed
import me.vripper.model.ErrorCount
import me.vripper.model.QueueState
@@ -21,6 +18,7 @@ data class DownloadSpeedEvent(val downloadSpeed: DownloadSpeed)
data class QueueStateEvent(val queueState: QueueState)
data class ErrorCountEvent(val errorCount: ErrorCount)
data class SettingsUpdateEvent(val settings: Settings)
data class MetadataUpdateEvent(val metadata: Metadata)
data class LogCreateEvent(val logEntry: LogEntry)
data class LogUpdateEvent(val logEntry: LogEntry)
@@ -1,17 +1,14 @@
package me.vripper.event
import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks
import reactor.core.scheduler.Scheduler
import reactor.core.scheduler.Schedulers
import java.util.concurrent.Executors
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
object EventBus {
private val scheduler: Scheduler = Schedulers.fromExecutor(Executors.newSingleThreadExecutor())
private val _events = Sinks.many().multicast().onBackpressureBuffer<Any>()
val events: Flux<Any> = _events.asFlux().publishOn(scheduler)
fun publishEvent(event: Any) {
_events.emitNext(event) { _, _ -> true }
private val _events = MutableSharedFlow<Any>()
val events = _events.asSharedFlow()
suspend fun publishEvent(event: Any) {
_events.emit(event)
}
}
@@ -165,6 +165,7 @@ abstract class Host(
}
}
@OptIn(ExperimentalStdlibApi::class)
private fun getImageMimeType(headers: Array<Header>): ImageMimeType? {
// first check if content type header exists
@@ -12,6 +12,7 @@ open class OnStartupListener : KoinComponent {
private val retryPolicyService: RetryPolicyService by inject()
private val vgAuthService: VGAuthService by inject()
private val downloadSpeedService: DownloadSpeedService by inject()
private val metadataService: MetadataService by inject()
open fun run() {
dataTransaction.setDownloadingToStopped()
@@ -20,5 +21,6 @@ open class OnStartupListener : KoinComponent {
vgAuthService.init()
downloadService.init()
downloadSpeedService.init()
metadataService.init()
}
}
@@ -1,42 +1,49 @@
package me.vripper.model
import kotlinx.serialization.Serializable
@Serializable
data class Settings(
var connectionSettings: ConnectionSettings = ConnectionSettings(),
var downloadSettings: DownloadSettings = DownloadSettings(),
var viperSettings: ViperSettings = ViperSettings(),
var systemSettings: SystemSettings = SystemSettings()
val connectionSettings: ConnectionSettings = ConnectionSettings(),
val downloadSettings: DownloadSettings = DownloadSettings(),
val viperSettings: ViperSettings = ViperSettings(),
val systemSettings: SystemSettings = SystemSettings()
)
@Serializable
data class ViperSettings(
var login: Boolean = false,
var username: String = "",
var password: String = "",
var thanks: Boolean = false,
var host: String = "https://vipergirls.to",
val login: Boolean = false,
val username: String = "",
val password: String = "",
val thanks: Boolean = false,
val host: String = "https://vipergirls.to",
)
@Serializable
data class DownloadSettings(
var downloadPath: String = System.getProperty("user.home"),
var autoStart: Boolean = true,
var autoQueueThreshold: Int = 1,
var forceOrder: Boolean = false,
var forumSubDirectory: Boolean = false,
var threadSubLocation: Boolean = false,
var clearCompleted: Boolean = false,
var appendPostId: Boolean = false
val downloadPath: String = System.getProperty("user.home"),
val autoStart: Boolean = true,
val autoQueueThreshold: Int = 1,
val forceOrder: Boolean = false,
val forumSubDirectory: Boolean = false,
val threadSubLocation: Boolean = false,
val clearCompleted: Boolean = false,
val appendPostId: Boolean = false
)
@Serializable
data class ConnectionSettings(
var maxConcurrentPerHost: Int = 2,
var maxGlobalConcurrent: Int = 0,
var timeout: Long = 30,
var maxAttempts: Int = 3,
val maxConcurrentPerHost: Int = 2,
val maxGlobalConcurrent: Int = 0,
val timeout: Long = 30,
val maxAttempts: Int = 3,
)
@Serializable
data class SystemSettings(
var tempPath: String = System.getProperty("java.io.tmpdir"),
var cachePath: String = System.getProperty("java.io.tmpdir"),
var enableClipboardMonitoring: Boolean = false,
var clipboardPollingRate: Int = 500,
var maxEventLog: Int = 1_000,
val tempPath: String = System.getProperty("java.io.tmpdir"),
val cachePath: String = System.getProperty("java.io.tmpdir"),
val enableClipboardMonitoring: Boolean = false,
val clipboardPollingRate: Int = 500,
val maxEventLog: Int = 1_000,
)
@@ -7,4 +7,5 @@ interface MetadataRepository {
fun save(metadata: Metadata): Metadata
fun findByPostId(postId: Long): Optional<Metadata>
fun deleteByPostId(postId: Long): Int
fun deleteAllByPostId(postIds: List<Long>)
}
@@ -22,6 +22,7 @@ class ImageRepositoryImpl : ImageRepository {
it[url] = image.url
it[thumbUrl] = image.thumbUrl
it[postIdRef] = image.postIdRef
it[filename] = image.filename
}.value
return image.copy(id = id)
}
@@ -37,6 +38,7 @@ class ImageRepositoryImpl : ImageRepository {
this[ImageTable.url] = it.url
this[ImageTable.thumbUrl] = it.thumbUrl
this[ImageTable.postIdRef] = it.postIdRef
this[ImageTable.filename] = it.filename
}
}
@@ -98,6 +100,7 @@ class ImageRepositoryImpl : ImageRepository {
it[status] = image.status.name
it[downloaded] = image.downloaded
it[size] = image.size
it[filename] = image.filename
}
}
@@ -113,6 +116,7 @@ class ImageRepositoryImpl : ImageRepository {
this[ImageTable.url] = it.url
this[ImageTable.thumbUrl] = it.thumbUrl
this[ImageTable.postIdRef] = it.postIdRef
this[ImageTable.filename] = it.filename
}
}
@@ -150,6 +154,7 @@ class ImageRepositoryImpl : ImageRepository {
val total = resultRow[ImageTable.size]
val status = Status.valueOf(resultRow[ImageTable.status])
val postIdRef = resultRow[ImageTable.postIdRef]
val filename = resultRow[ImageTable.filename]
return Image(
id,
postId,
@@ -160,7 +165,8 @@ class ImageRepositoryImpl : ImageRepository {
postIdRef,
total,
current,
status
status,
filename
)
}
}
@@ -1,20 +1,73 @@
package me.vripper.repositories.impl
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import me.vripper.entities.Metadata
import me.vripper.repositories.MetadataRepository
import me.vripper.tables.MetadataTable
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.TransactionManager
import java.sql.Connection
import java.util.*
class MetadataRepositoryImpl: MetadataRepository {
override fun save(metadata: Metadata): Metadata {
MetadataTable.insert {
it[postId] = metadata.postId
it[data] = Json.encodeToString(metadata.data)
}
return metadata
}
override fun findByPostId(postId: Long): Optional<Metadata> {
return Optional.empty()
val result = MetadataTable.select {
MetadataTable.postId eq postId
}.map(::transform)
return if (result.isEmpty()) {
Optional.empty()
} else {
Optional.of(result.first())
}
}
private fun transform(row: ResultRow): Metadata {
val id = row[MetadataTable.postId]
val data = Json.decodeFromString(row[MetadataTable.data]) as Metadata.Data
return Metadata(id, data)
}
override fun deleteByPostId(postId: Long): Int {
return 0
return MetadataTable.deleteWhere { MetadataTable.postId eq postId }
}
override fun deleteAllByPostId(postIds: List<Long>) {
val conn = TransactionManager.current().connection.connection as Connection
conn.prepareStatement("CREATE LOCAL TEMPORARY TABLE METADATA_DELETE(POST_ID BIGINT PRIMARY KEY)")
.use {
it.execute()
}
conn.prepareStatement("INSERT INTO METADATA_DELETE VALUES ( ? )").use { ps ->
postIds.forEach {
ps.setLong(1, it)
ps.addBatch()
}
ps.executeBatch()
}
conn.prepareStatement("DELETE FROM METADATA WHERE POST_ID IN (SELECT POST_ID FROM METADATA_DELETE)")
.use {
it.execute()
}
conn.prepareStatement("TRUNCATE TABLE METADATA_DELETE")
}
}
@@ -144,14 +144,19 @@ class AppEndpointService(
dataTransaction.deleteAllLogs()
}
fun rename(postId: Long, name: String) {
fun rename(postId: Long, newName: String) {
CompletableFuture.runAsync({
synchronized(postId.toString().intern()) {
dataTransaction.findPostByPostId(postId).ifPresent { post ->
if (Path(post.downloadDirectory, post.folderName).exists()) {
PathUtils.rename(post.downloadDirectory, post.folderName, name)
PathUtils.rename(
dataTransaction.findImagesByPostId(postId),
post.downloadDirectory,
post.folderName,
newName
)
}
post.folderName = name
post.folderName = PathUtils.sanitize(newName)
dataTransaction.updatePost(post)
}
}
@@ -1,5 +1,9 @@
package me.vripper.services
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import me.vripper.entities.*
import me.vripper.entities.domain.Status
import me.vripper.event.*
@@ -22,34 +26,46 @@ class DataTransaction(
private val eventBus: EventBus,
) {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private fun save(posts: List<Post>): List<Post> {
return transaction { postDownloadStateRepository.save(posts) }
}
fun updatePosts(posts: List<Post>) {
transaction { postDownloadStateRepository.update(posts) }
coroutineScope.launch {
eventBus.publishEvent(PostUpdateEvent(posts))
}
}
fun updatePost(post: Post) {
transaction { postDownloadStateRepository.update(post) }
coroutineScope.launch {
eventBus.publishEvent(PostUpdateEvent(listOf(post)))
}
}
fun save(thread: Thread) {
val savedThread = transaction { threadRepository.save(thread) }
coroutineScope.launch {
eventBus.publishEvent(ThreadCreateEvent(savedThread))
}
}
fun updateImages(images: List<Image>) {
transaction { imageRepository.update(images) }
coroutineScope.launch {
eventBus.publishEvent(ImageEvent(images))
}
}
fun updateImage(image: Image) {
transaction { imageRepository.update(image) }
coroutineScope.launch {
eventBus.publishEvent(ImageEvent(listOf(image)))
}
}
fun exists(postId: Long): Boolean {
return transaction { postDownloadStateRepository.existByPostId(postId) }
@@ -104,7 +120,9 @@ class DataTransaction(
}
savedPosts
}
coroutineScope.launch {
eventBus.publishEvent(PostCreateEvent(savedPosts))
}
return savedPosts
}
@@ -146,21 +164,25 @@ class DataTransaction(
private fun remove(postIds: List<Long>) {
transaction {
metadataRepository.deleteAllByPostId(postIds)
imageRepository.deleteAllByPostId(postIds)
postDownloadStateRepository.deleteAll(postIds)
sortPostsByRank()
}
coroutineScope.launch {
eventBus.publishEvent(PostDeleteEvent(postIds = postIds))
}
coroutineScope.launch {
eventBus.publishEvent(ErrorCountEvent(ErrorCount(countImagesInError())))
}
}
fun removeThread(threadId: Long) {
transaction { threadRepository.deleteByThreadId(threadId) }
coroutineScope.launch {
eventBus.publishEvent(ThreadDeleteEvent(threadId))
}
}
fun clearCompleted(): List<Long> {
@@ -181,18 +203,19 @@ class DataTransaction(
transaction { imageRepository.stopByPostIdAndIsNotCompleted(postId) }
}
@Synchronized
fun setMetadata(post: Post, metadata: Metadata) {
if (metadataRepository.findByPostId(post.postId).isEmpty) {
metadata.postIdRef = post.id
metadataRepository.save(metadata)
fun saveMetadata(metadata: Metadata) {
transaction { metadataRepository.save(metadata) }
coroutineScope.launch {
eventBus.publishEvent(MetadataUpdateEvent(metadata))
}
}
fun clearQueueLinks() {
transaction { threadRepository.deleteAll() }
coroutineScope.launch {
eventBus.publishEvent(ThreadClearEvent())
}
}
@Synchronized
fun sortPostsByRank() {
@@ -255,15 +278,21 @@ class DataTransaction(
val deleted = logRepository.deleteOldest()
Pair(saved, deleted)
}
coroutineScope.launch {
eventBus.publishEvent(LogCreateEvent(pair.first))
}
coroutineScope.launch {
eventBus.publishEvent(LogDeleteEvent(pair.second))
}
return pair.first
}
fun updateLog(logEntry: LogEntry) {
transaction { logRepository.update(logEntry) }
coroutineScope.launch {
eventBus.publishEvent(LogUpdateEvent(logEntry))
}
}
fun deleteAllLogs() {
transaction { logRepository.deleteAll() }
@@ -276,4 +305,8 @@ class DataTransaction(
fun findAllNonCompletedPostIds(): List<Long> {
return transaction { postDownloadStateRepository.findAllNonCompletedPostIds() }
}
fun findMetadataByPostId(postId: Long): Optional<Metadata> {
return transaction { metadataRepository.findByPostId(postId) }
}
}
@@ -1,6 +1,7 @@
package me.vripper.services
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filterIsInstance
import me.vripper.event.DownloadSpeedEvent
import me.vripper.event.EventBus
import me.vripper.event.QueueStateEvent
@@ -21,7 +22,8 @@ class DownloadSpeedService(
fun init() {
coroutineScope.launch {
eventBus.events.ofType(QueueStateEvent::class.java).subscribe {
eventBus.events.filterIsInstance(QueueStateEvent::class).collect {
coroutineContext.ensureActive()
if (it.queueState.running + it.queueState.remaining > 0) {
if (job == null || job?.isActive == false) {
job = coroutineScope.launch {
@@ -1,6 +1,7 @@
package me.vripper.services
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filterIsInstance
import me.vripper.event.EventBus
import me.vripper.event.SettingsUpdateEvent
import org.apache.hc.client5.http.config.ConnectionConfig
@@ -17,7 +18,7 @@ import org.apache.hc.core5.util.TimeValue
import org.apache.hc.core5.util.Timeout
class HTTPService(
val eventBus: EventBus,
private val eventBus: EventBus,
settingsService: SettingsService
) {
@@ -43,10 +44,11 @@ class HTTPService(
pcm.closeIdle(TimeValue.ofSeconds(60))
delay(15000)
}
coroutineScope.launch {
eventBus
.events
.ofType(SettingsUpdateEvent::class.java)
.subscribe {
.filterIsInstance(SettingsUpdateEvent::class)
.collect {
if (connectionTimeout != it.settings.connectionSettings.timeout) {
connectionTimeout = it.settings.connectionSettings.timeout
client.close()
@@ -58,6 +60,7 @@ class HTTPService(
}
}
}
}
private fun buildConnectionPool() {
pcm = PoolingHttpClientConnectionManagerBuilder.create()
@@ -0,0 +1,109 @@
package me.vripper.services
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import me.vripper.entities.Metadata
import me.vripper.exception.DownloadException
import me.vripper.exception.VripperException
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.XpathUtils
import org.apache.hc.client5.http.classic.methods.HttpGet
import org.apache.hc.core5.net.URIBuilder
import org.w3c.dom.Node
import java.util.concurrent.atomic.AtomicBoolean
import java.util.stream.Collectors
class MetadataService(
private val httpService: HTTPService,
private val settingsService: SettingsService,
private val dataTransaction: DataTransaction,
private val vgAuthService: VGAuthService,
) {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val requestSemaphore = Semaphore(6)
private val dictionary: List<String> =
mutableListOf("download", "link", "rapidgator", "filefactory", "filefox")
fun init() {
dataTransaction.findAllPosts()
.filter { dataTransaction.findMetadataByPostId(it.postId).isEmpty }.map { it.postId }
.forEach(::fetchMetadata)
}
fun fetchMetadata(postId: Long) {
coroutineScope.launch {
requestSemaphore.withPermit {
val httpGet =
HttpGet(URIBuilder(settingsService.settings.viperSettings.host + "/threads/").also {
it.setParameter(
"p", postId.toString()
)
}.build())
val metadata = httpService.client.execute(httpGet, vgAuthService.context) {
if (it.code / 100 != 2) {
throw DownloadException("Unexpected response code '${it.code}' for $httpGet")
}
val document = HtmlUtils.clean(it.entity.content)
val postNode: Node = XpathUtils.getAsNode(
document,
"//li[@id='post_$postId']/div[contains(@class, 'postdetails')]",
) ?: throw VripperException("Unable to find post #'$postId'")
val postedBy: String = XpathUtils.getAsNode(
postNode,
"./div[contains(@class, 'userinfo')]//a[contains(@class, 'username')]//font"
)?.textContent?.trim()
?: throw VripperException("Unable to find the poster for post #'$postId'")
val node: Node = XpathUtils.getAsNode(
document, java.lang.String.format("//div[@id='post_message_%s']", postId)
) ?: throw VripperException("Unable to locate post content")
val titles = findTitleInContent(node)
Metadata(postId, Metadata.Data(postedBy, titles))
}
dataTransaction.saveMetadata(metadata)
}
}
}
private fun findTitleInContent(node: Node): List<String> {
val altTitle: MutableList<String> = mutableListOf()
findTitle(node, altTitle, AtomicBoolean(true))
return altTitle.stream().distinct().collect(Collectors.toList())
}
private fun findTitle(node: Node, altTitle: MutableList<String>, keepGoing: AtomicBoolean) {
if (!keepGoing.get()) {
return
}
if (node.nodeName == "a" || node.nodeName == "img") {
keepGoing.set(false)
return
}
if (node.nodeType == Node.ELEMENT_NODE) {
for (i in 0 until node.childNodes.length) {
val item = node.childNodes.item(i)
findTitle(item, altTitle, keepGoing)
if (!keepGoing.get()) {
return
}
}
} else if (node.nodeType == Node.TEXT_NODE) {
val text = node.textContent.trim()
if (text.isNotBlank() && dictionary.stream().noneMatch { e ->
text.lowercase().contains(e.lowercase())
}) {
altTitle.add(text)
}
}
}
}
@@ -1,5 +1,10 @@
package me.vripper.services
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 net.jodah.failsafe.RetryPolicy
@@ -11,14 +16,16 @@ class RetryPolicyService(
) {
private val log by me.vripper.delegate.LoggerDelegate()
private var maxAttempts: Int = settingsService.settings.connectionSettings.maxAttempts
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun init() {
eventBus.events.ofType(SettingsUpdateEvent::class.java).subscribe {
coroutineScope.launch {
eventBus.events.filterIsInstance(SettingsUpdateEvent::class).collect {
if (maxAttempts != it.settings.connectionSettings.maxAttempts) {
maxAttempts = it.settings.connectionSettings.maxAttempts
}
}
}
}
fun <T> buildRetryPolicyForDownload(): RetryPolicy<T> {
@@ -1,10 +1,12 @@
package me.vripper.services
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import com.fasterxml.jackson.module.kotlin.readValue
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import me.vripper.event.EventBus
import me.vripper.event.SettingsUpdateEvent
import me.vripper.exception.ValidationException
@@ -13,42 +15,45 @@ import me.vripper.utilities.ApplicationProperties.VRIPPER_DIR
import org.apache.commons.codec.digest.DigestUtils
import java.io.FileWriter
import java.nio.file.*
import kotlin.io.path.readText
class SettingsService(private val eventBus: EventBus) {
private val log by me.vripper.delegate.LoggerDelegate()
private val configPath = VRIPPER_DIR.resolve("config.yml")
private val configPath = VRIPPER_DIR.resolve("config.json")
private val customProxiesPath = VRIPPER_DIR.resolve("proxies.json")
private val om = ObjectMapper(YAMLFactory())
private val proxies: MutableSet<String> = HashSet()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val json = Json {
encodeDefaults = true
prettyPrint = true
}
var settings = Settings()
init {
om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.findAndRegisterModules().registerKotlinModule()
init()
}
private fun init() {
loadViperProxies()
restore()
coroutineScope.launch {
eventBus.publishEvent(SettingsUpdateEvent(settings))
}
}
private fun loadViperProxies() {
try {
SettingsService::class.java.getResourceAsStream("/proxies.json")?.use {
val defaultProxies: List<String> = om.readValue(it)
val defaultProxies: List<String> = json.decodeFromStream(it)
val customProxies: List<String> = if (customProxiesPath.toFile()
.exists() && Files.isRegularFile(customProxiesPath)
) {
try {
om.readValue(
customProxiesPath.toFile()
json.decodeFromString(
customProxiesPath.readText()
)
} catch (e: Exception) {
emptyList()
@@ -81,36 +86,35 @@ class SettingsService(private val eventBus: EventBus) {
}
fun newSettings(settings: Settings) {
if (settings.viperSettings.login) {
check(settings)
val viperSettings = if (settings.viperSettings.login) {
if (this.settings.viperSettings.password != settings.viperSettings.password) {
settings.viperSettings.password =
DigestUtils.md5Hex(settings.viperSettings.password)
settings.viperSettings.copy(password = DigestUtils.md5Hex(settings.viperSettings.password))
} else {
settings.viperSettings
}
} else {
settings.viperSettings.username = ""
settings.viperSettings.password = ""
settings.viperSettings.thanks = false
settings.viperSettings.login = false
settings.viperSettings.copy(username = "", password = "", thanks = false, login = false)
}
check(settings)
this.settings = settings
this.settings = settings.copy(viperSettings = viperSettings)
save()
coroutineScope.launch {
eventBus.publishEvent(SettingsUpdateEvent(settings))
}
}
private fun restore() {
try {
if (configPath.toFile().exists()) {
settings = om.readValue(configPath.toFile())
settings = json.decodeFromString(configPath.readText())
}
} catch (e: Exception) {
log.error("Failed restore user settings", e)
settings = Settings()
}
if (!proxies.contains(settings.viperSettings.host)) {
settings.viperSettings.host = "https://vipergirls.to"
val viperSetting = settings.viperSettings.copy(host = "https://vipergirls.to")
settings = settings.copy(viperSettings = viperSetting)
}
try {
check(settings)
@@ -123,9 +127,9 @@ class SettingsService(private val eventBus: EventBus) {
fun save() {
try {
Files.write(
Files.writeString(
configPath,
om.writeValueAsBytes(settings),
json.encodeToString(settings),
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING,
@@ -2,6 +2,11 @@ package me.vripper.services
import com.github.benmanes.caffeine.cache.Caffeine
import com.github.benmanes.caffeine.cache.LoadingCache
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.model.ThreadItem
@@ -12,14 +17,18 @@ import java.util.concurrent.TimeUnit
class ThreadCacheService(val eventBus: EventBus) {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun init() {
eventBus.events.ofType(SettingsUpdateEvent::class.java).subscribe {
coroutineScope.launch {
eventBus.events.filterIsInstance(SettingsUpdateEvent::class).collect {
cache.invalidateAll()
}
}
}
private val cache: LoadingCache<Long, ThreadItem> =
Caffeine.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES).build { threadId ->
Caffeine.newBuilder().expireAfterWrite(20, TimeUnit.MINUTES).build { threadId ->
ThreadLookupAPIParser(threadId).parse()
}
@@ -1,5 +1,10 @@
package me.vripper.services
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import me.vripper.entities.Post
import me.vripper.event.EventBus
import me.vripper.event.SettingsUpdateEvent
@@ -18,8 +23,11 @@ import org.apache.hc.core5.http.message.BasicNameValuePair
import java.util.concurrent.CompletableFuture
class VGAuthService(
private val cm: HTTPService, private val settingsService: SettingsService, private val eventBus: EventBus
private val cm: HTTPService,
private val settingsService: SettingsService,
private val eventBus: EventBus
) {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val log by me.vripper.delegate.LoggerDelegate()
val context: HttpClientContext = HttpClientContext.create()
var loggedUser = ""
@@ -28,9 +36,11 @@ class VGAuthService(
fun init() {
context.cookieStore = BasicCookieStore()
eventBus.events.ofType(SettingsUpdateEvent::class.java).subscribe {
coroutineScope.launch {
eventBus.events.filterIsInstance(SettingsUpdateEvent::class).collect {
authenticate(it.settings)
}
}
authenticate(settingsService.settings)
}
@@ -40,7 +50,9 @@ class VGAuthService(
log.debug("Authentication option is disabled")
context.cookieStore.clear()
loggedUser = ""
coroutineScope.launch {
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
}
return
}
@@ -51,7 +63,9 @@ class VGAuthService(
context.cookieStore.clear()
loggedUser = ""
coroutineScope.launch {
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
}
return
}
@@ -81,7 +95,8 @@ class VGAuthService(
if (context.cookieStore.cookies.stream().map { obj: Cookie -> obj.name }
.noneMatch { e: String -> e == "vg_userid" }) {
log.error(
"Failed to authenticate user with {}, missing vg_userid cookie", settings.viperSettings.host
"Failed to authenticate user with {}, missing vg_userid cookie",
settings.viperSettings.host
)
return
}
@@ -89,7 +104,9 @@ class VGAuthService(
context.cookieStore.clear()
loggedUser = ""
coroutineScope.launch {
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
}
log.error(
"Failed to authenticate user with " + settings.viperSettings.host, e
@@ -100,11 +117,14 @@ class VGAuthService(
loggedUser = username
log.info(String.format("Authenticated: %s", username))
coroutineScope.launch {
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
}
}
fun leaveThanks(post: Post) {
CompletableFuture.runAsync(LeaveThanksRunnable(post, authenticated, context), GLOBAL_EXECUTOR)
CompletableFuture.runAsync(
LeaveThanksRunnable(post, authenticated, context), GLOBAL_EXECUTOR
)
}
}
@@ -8,6 +8,7 @@ object ImageTable : LongIdTable(name = "IMAGE", columnName = "ID") {
val index = integer("INDEX")
val postId = long("POST_ID")
val status = varchar("STATUS", 15)
val filename = varchar("FILENAME", 260)
val size = long("SIZE")
val url = varchar("URL", 200)
val thumbUrl = varchar("THUMB_URL", 200)
@@ -0,0 +1,8 @@
package me.vripper.tables
import org.jetbrains.exposed.sql.Table
object MetadataTable : Table(name = "METADATA") {
val postId = long("POST_ID")
val data = varchar("DATA", 1_000_000)
}
@@ -28,64 +28,28 @@ class AddPostRunnable(private val items: List<ThreadPostId>) : KoinComponent, Ru
private val retryPolicyService: RetryPolicyService by inject()
private val downloadService: DownloadService by inject()
private val cacheService: ThreadCacheService by inject()
// private val logEntry: LogEntry
init {
// logEntry = dataTransaction.saveLog(
// LogEntry(
// type = LogEntry.Type.POST, status = PENDING, message = "Processing $link"
// )
// )
}
private val metadataService: MetadataService by inject()
override fun run() {
try {
Tasks.increment()
val toProcess = mutableListOf<PostItem>()
for ((threadId, postId) in items) {
// dataTransaction.updateLog(logEntry.copy(status = PROCESSING))
if (dataTransaction.exists(postId)) {
log.warn(String.format("skipping %s, already loaded", postId))
continue
}
val threadItem = cacheService[threadId]
// val postItem: PostItem = try {
val postItem: PostItem =
threadItem.postItemList.find { it.postId == postId } ?: parse(postId, threadId)
// } catch (e: PostParseException) {
// val error = String.format("parsing failed for gallery %s", link)
// log.error(error, e)
// dataTransaction.updateLog(
// logEntry.copy(
// status = ERROR, message = """
// $error
// ${e.formatToString()}
// """.trimIndent()
// )
// )
// return@map
// }
// if (postItem.imageItemList.isEmpty()) {
// val error = "Post $link contains no images to download"
// log.error(error)
// dataTransaction.updateLog(logEntry.copy(status = ERROR, message = error))
// return@map
// }
// dataTransaction.updateLog(
// logEntry.copy(
// status = DONE, message = String.format(
// "Post $link has been successfully added to download queue"
// )
// )
// )
toProcess.add(postItem)
}
val posts = dataTransaction.newPosts(toProcess.toList())
// metadataService.startFetchingMetadata(post)
posts.forEach {
metadataService.fetchMetadata(it.postId)
}
if (settingsService.settings.downloadSettings.autoStart) {
log.debug("Auto start downloads option is enabled")
downloadService.restartAll(posts)
@@ -93,14 +57,6 @@ class AddPostRunnable(private val items: List<ThreadPostId>) : KoinComponent, Ru
} catch (e: Exception) {
val error = String.format("Error when adding galleries")
log.error(error, e)
// dataTransaction.updateLog(
// logEntry.copy(
// status = ERROR, message = """
// $error
// ${e.formatToString()}
// """.trimIndent()
// )
// )
} finally {
Tasks.decrement()
}
@@ -1,5 +1,6 @@
package me.vripper.utilities
import me.vripper.entities.Image
import me.vripper.exception.RenameException
import me.vripper.model.Settings
import java.io.IOException
@@ -7,6 +8,7 @@ import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import kotlin.io.path.Path
import kotlin.io.path.listDirectoryEntries
object PathUtils {
@@ -28,15 +30,32 @@ object PathUtils {
}
@Throws(RenameException::class)
fun rename(downloadDirectory: String, oldFolder: String, newFolder: String) {
fun rename(
imageList: List<Image>,
downloadDirectory: String,
oldFolder: String,
newFolder: String
) {
val currentDownloadDirectory = Path(downloadDirectory, oldFolder)
val newDownloadDirectory = Path(downloadDirectory, sanitize(newFolder))
if (currentDownloadDirectory == newDownloadDirectory) {
return
}
try {
Files.createDirectories(newDownloadDirectory)
imageList.filter { it.filename.isNotBlank() }.forEach {
Files.move(
currentDownloadDirectory,
newDownloadDirectory,
currentDownloadDirectory.resolve(it.filename),
newDownloadDirectory.resolve(it.filename),
StandardCopyOption.ATOMIC_MOVE
)
}
if (currentDownloadDirectory.listDirectoryEntries().isEmpty()) {
try {
Files.delete(currentDownloadDirectory)
} catch (ignored: Exception) {
}
}
} catch (e: IOException) {
throw RenameException(
String.format(
@@ -1,5 +1,9 @@
package me.vripper.utilities
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import me.vripper.event.EventBus
import me.vripper.event.LoadingTasks
import org.koin.core.component.KoinComponent
@@ -9,12 +13,15 @@ object Tasks : KoinComponent {
private val eventBus: EventBus by inject()
private var current = 0
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@Synchronized
fun increment() {
if (current == 0) {
coroutineScope.launch {
eventBus.publishEvent(LoadingTasks(true))
}
}
current += 1
}
@@ -22,7 +29,9 @@ object Tasks : KoinComponent {
fun decrement() {
current -= 1
if (current == 0) {
coroutineScope.launch {
eventBus.publishEvent(LoadingTasks(false))
}
}
}
}
@@ -150,4 +150,19 @@
<column name="FOLDER_NAME" type="VARCHAR(260)" afterColumn="OUTPUT_PATH" defaultValue=""/>
</addColumn>
</changeSet>
<changeSet author="vripper" id="0007">
<createTable tableName="METADATA">
<column name="POST_ID" type="BIGINT">
<constraints unique="true" nullable="false"/>
</column>
<column name="DATA" type="VARCHAR">
<constraints nullable="true"/>
</column>
</createTable>
</changeSet>
<changeSet author="vripper" id="0008">
<addColumn tableName="IMAGE">
<column name="FILENAME" type="VARCHAR(260)" afterColumn="STATUS" defaultValue=""/>
</addColumn>
</changeSet>
</databaseChangeLog>
+15 -9
View File
@@ -28,6 +28,10 @@
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-core-jvm</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-json</artifactId>
</dependency>
<dependency>
<artifactId>javafx-base</artifactId>
<groupId>org.openjfx</groupId>
@@ -119,19 +123,21 @@
<goals>
<goal>compile</goal>
</goals>
<configuration>
<sourceDirs>
<source>src/main/kotlin</source>
<source>target/generated-sources/annotations</source>
</sourceDirs>
</configuration>
</execution>
</executions>
<configuration>
<args>
<arg>-Xjsr305=strict</arg>
</args>
<jvmTarget>1.8</jvmTarget>
<compilerPlugins>
<plugin>kotlinx-serialization</plugin>
</compilerPlugins>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-serialization</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<artifactId>maven-jar-plugin</artifactId>
@@ -2,12 +2,13 @@ package me.vripper.gui.clipboard
import javafx.scene.input.Clipboard
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filterIsInstance
import me.vripper.event.EventBus
import me.vripper.event.SettingsUpdateEvent
import me.vripper.model.Settings
import me.vripper.services.AppEndpointService
import me.vripper.services.SettingsService
import tornadofx.runLater
import tornadofx.*
class ClipboardService(
private val appEndpointService: AppEndpointService,
@@ -19,9 +20,11 @@ class ClipboardService(
private var pollJob: Job? = null
fun init() {
eventBus.events.ofType(SettingsUpdateEvent::class.java).subscribe {
coroutineScope.launch {
eventBus.events.filterIsInstance(SettingsUpdateEvent::class).collect {
run(it.settings)
}
}
run(settingsService.settings)
}
@@ -1,11 +1,14 @@
package me.vripper.gui.controller
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.time.sample
import me.vripper.event.*
import me.vripper.event.EventBus
import me.vripper.gui.model.GlobalStateModel
import me.vripper.services.VGAuthService
import me.vripper.utilities.formatSI
import tornadofx.Controller
import tornadofx.runLater
import tornadofx.*
import java.time.Duration
class GlobalStateController : Controller() {
@@ -13,22 +16,29 @@ class GlobalStateController : Controller() {
private val eventBus: EventBus by di()
private val vgAuthService: VGAuthService by di()
var globalState: GlobalStateModel = GlobalStateModel(0, 0, 0, vgAuthService.loggedUser, 0L.formatSI(), false)
var globalState: GlobalStateModel =
GlobalStateModel(0, 0, 0, vgAuthService.loggedUser, 0L.formatSI(), false)
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@OptIn(FlowPreview::class)
fun init() {
eventBus.events.ofType(DownloadSpeedEvent::class.java).subscribe {
coroutineScope.launch {
eventBus.events.filterIsInstance(DownloadSpeedEvent::class).collect {
runLater {
globalState.downloadSpeed = it.downloadSpeed.speed.formatSI()
}
}
}
eventBus.events.ofType(VGUserLoginEvent::class.java).subscribe {
coroutineScope.launch {
eventBus.events.filterIsInstance(VGUserLoginEvent::class).collect {
runLater {
globalState.loggedUser = it.username
}
}
eventBus.events.ofType(QueueStateEvent::class.java).subscribe {
}
coroutineScope.launch {
eventBus.events.filterIsInstance(QueueStateEvent::class).collect {
runLater {
globalState.apply {
running = it.queueState.running
@@ -36,17 +46,21 @@ class GlobalStateController : Controller() {
}
}
}
eventBus.events.ofType(ErrorCountEvent::class.java).subscribe {
}
coroutineScope.launch {
eventBus.events.filterIsInstance(ErrorCountEvent::class).collect {
runLater {
globalState.error = it.errorCount.count
}
}
eventBus.events.ofType(LoadingTasks::class.java).sample(Duration.ofMillis(500)).subscribe {
}
coroutineScope.launch {
eventBus.events.filterIsInstance(LoadingTasks::class).sample(Duration.ofMillis(500))
.collect {
runLater {
globalState.loading = it.loading
}
}
}
}
}
@@ -3,7 +3,7 @@ package me.vripper.gui.controller
import me.vripper.entities.Image
import me.vripper.gui.model.ImageModel
import me.vripper.services.DataTransaction
import tornadofx.Controller
import tornadofx.*
class ImageController : Controller() {
@@ -21,7 +21,8 @@ class ImageController : Controller() {
progress(it.size, it.downloaded),
it.status.name,
it.size,
it.downloaded
it.downloaded,
it.filename
)
}
@@ -2,11 +2,12 @@ package me.vripper.gui.controller
import kotlinx.coroutines.*
import me.vripper.entities.Image
import me.vripper.entities.Metadata
import me.vripper.gui.model.PostModel
import me.vripper.services.AppEndpointService
import me.vripper.services.DataTransaction
import me.vripper.utilities.formatSI
import tornadofx.Controller
import tornadofx.*
import java.time.format.DateTimeFormatter
class PostController : Controller() {
@@ -62,6 +63,7 @@ class PostController : Controller() {
fun mapper(id: Long): PostModel {
val post = dataTransaction.findPostById(id).orElseThrow()
val metadata = dataTransaction.findMetadataByPostId(post.postId)
return PostModel(
post.postId,
post.postTitle,
@@ -76,7 +78,8 @@ class PostController : Controller() {
post.getDownloadFolder(),
post.folderName,
progressCount(post.total, post.done, post.downloaded),
dataTransaction.findImagesByPostId(post.postId).map(Image::thumbUrl).take(4)
dataTransaction.findImagesByPostId(post.postId).map(Image::thumbUrl).take(4),
metadata.orElse(Metadata(post.postId, Metadata.Data("", emptyList())))
)
}
@@ -6,7 +6,7 @@ import me.vripper.gui.model.settings.SystemSettingsModel
import me.vripper.gui.model.settings.ViperSettingsModel
import me.vripper.model.*
import me.vripper.services.SettingsService
import tornadofx.Controller
import tornadofx.*
class SettingsController : Controller() {
@@ -34,7 +34,8 @@ class SettingsController : Controller() {
viperSettingsModel: ViperSettingsModel,
systemSettingsModel: SystemSettingsModel
) {
settingsService.newSettings(Settings().apply {
settingsService.newSettings(
Settings(
downloadSettings = DownloadSettings(
downloadSettingsModel.downloadPath,
downloadSettingsModel.autoStart,
@@ -44,20 +45,20 @@ class SettingsController : Controller() {
downloadSettingsModel.threadSubLocation,
downloadSettingsModel.clearCompleted,
downloadSettingsModel.appendPostId
)
),
connectionSettings = ConnectionSettings(
connectionSettingsModel.maxThreads,
connectionSettingsModel.maxTotalThreads,
connectionSettingsModel.timeout,
connectionSettingsModel.maxAttempts,
)
),
viperSettings = ViperSettings(
viperSettingsModel.login,
viperSettingsModel.username,
viperSettingsModel.password,
viperSettingsModel.thanks,
viperSettingsModel.host,
)
),
systemSettings =
SystemSettings(
systemSettingsModel.tempPath,
@@ -66,8 +67,8 @@ class SettingsController : Controller() {
if (systemSettingsModel.pollingRate.isBlank()) 500 else systemSettingsModel.pollingRate.toInt(),
systemSettingsModel.logEntries.toInt()
)
})
)
)
}
fun getProxies(): List<String> {
@@ -5,8 +5,7 @@ import javafx.beans.property.SimpleIntegerProperty
import javafx.beans.property.SimpleLongProperty
import javafx.beans.property.SimpleStringProperty
import me.vripper.utilities.formatSI
import tornadofx.getValue
import tornadofx.setValue
import tornadofx.*
class ImageModel(
id: Long,
@@ -15,7 +14,8 @@ class ImageModel(
progress: Double,
status: String,
size: Long,
downloaded: Long
downloaded: Long,
filename: String
) {
val idProperty = SimpleLongProperty(id)
@@ -33,6 +33,9 @@ class ImageModel(
val statusProperty = SimpleStringProperty(status)
var status: String by statusProperty
val filenameProperty = SimpleStringProperty(filename)
var filename: String by filenameProperty
val sizeProperty = SimpleStringProperty(size.formatSI())
var size = size
set(value) {
@@ -1,11 +1,10 @@
package me.vripper.gui.model
import javafx.beans.property.SimpleDoubleProperty
import javafx.beans.property.SimpleIntegerProperty
import javafx.beans.property.SimpleLongProperty
import javafx.beans.property.SimpleStringProperty
import tornadofx.getValue
import tornadofx.setValue
import javafx.beans.property.*
import javafx.collections.FXCollections
import javafx.collections.ObservableList
import me.vripper.entities.Metadata
import tornadofx.*
class PostModel(
postId: Long,
@@ -21,7 +20,8 @@ class PostModel(
path: String,
folderName: String,
progressCount: String,
previewList: List<String>
previewList: List<String>,
metadata: Metadata
) {
val postIdProperty = SimpleLongProperty(postId)
var postId: Long by postIdProperty
@@ -64,4 +64,11 @@ class PostModel(
val previewListProperty = SimpleStringProperty(previewList.joinToString("|"))
var previewList: List<String> = previewListProperty.value.split("|")
val altTitlesProperty =
SimpleListProperty(FXCollections.observableArrayList(metadata.data.resolvedNames))
var altTitles: ObservableList<String> by altTitlesProperty
val postedByProperty = SimpleStringProperty(metadata.data.postedBy)
var postedby: String by postedByProperty
}
@@ -2,9 +2,7 @@ package me.vripper.gui.view.popup
import javafx.beans.property.SimpleStringProperty
import javafx.geometry.Pos
import javafx.scene.control.TextField
import javafx.scene.layout.Priority
import javafx.scene.layout.VBox
import javafx.scene.control.ComboBox
import me.vripper.gui.controller.PostController
import tornadofx.*
@@ -12,29 +10,32 @@ class RenameView : Fragment("Rename download post") {
val postId: Long by param()
val name: String by param()
private val textAreaProperty = SimpleStringProperty()
val altTitles: List<String> by param()
private val textInputProperty = SimpleStringProperty()
private val postController: PostController by inject()
lateinit var input: TextField
private lateinit var comboBox: ComboBox<String>
override fun onDock() {
textAreaProperty.value = name
textInputProperty.value = name
}
override val root = vbox(alignment = Pos.CENTER_RIGHT) {
padding = insets(all = 5)
spacing = 5.0
input = textfield {
VBox.setVgrow(this, Priority.ALWAYS)
bind(textAreaProperty)
form {
fieldset {
field("Name") {
comboBox = combobox(textInputProperty, altTitles.ifEmpty { listOf(name) }) {
useMaxSize = true
isEditable = true
}
button("Ok") {
imageview("search.png") {
fitWidth = 18.0
fitHeight = 18.0
}
disableWhen(textAreaProperty.isEmpty)
}
}
button("Rename") {
disableWhen(comboBox.editor.textProperty().isEmpty)
action {
postController.rename(postId, textAreaProperty.value)
postController.rename(postId, comboBox.editor.text.trim())
close()
}
}
@@ -4,7 +4,7 @@ import me.vripper.gui.controller.SettingsController
import me.vripper.gui.model.settings.ConnectionSettingsModel
import tornadofx.*
class ConnectionSettingsView : View("Connection settings") {
class ConnectionSettingsView : View("Connection Settings") {
private val settingsController: SettingsController by inject()
val connectionSettingsModel = ConnectionSettingsModel()
@@ -9,6 +9,9 @@ import javafx.scene.image.ImageView
import javafx.scene.input.MouseButton
import javafx.util.Callback
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
import me.vripper.entities.Image
import me.vripper.event.EventBus
import me.vripper.event.ImageEvent
@@ -17,14 +20,11 @@ import me.vripper.gui.model.ImageModel
import me.vripper.gui.view.ProgressTableCell
import me.vripper.gui.view.StatusTableCell
import me.vripper.gui.view.openLink
import reactor.core.Disposable
import tornadofx.*
import java.time.Duration
class ImagesTableView : Fragment("Photos") {
private lateinit var tableView: TableView<ImageModel>
private lateinit var eventsSubscription: Disposable
private val imageController: ImageController by inject()
private val eventBus: EventBus by di()
private var items: ObservableList<ImageModel> = FXCollections.observableArrayList()
@@ -47,18 +47,19 @@ class ImagesTableView : Fragment("Photos") {
}
}
eventsSubscription = eventBus.events.ofType(ImageEvent::class.java).map {
coroutineScope.launch {
eventBus.events.filterIsInstance(ImageEvent::class).map {
it.copy(images = it.images.filter { image: Image -> image.postId == postId })
}.filter {
it.images.isNotEmpty()
}.buffer(Duration.ofMillis(175))
.subscribe { imageEvent ->
}.collect { imageEvent ->
runLater {
for (image in imageEvent.map { it.images }.flatten().reversed().distinct()) {
for (image in imageEvent.images) {
val imageModel = items.find { it.id == image.id } ?: continue
imageModel.size = image.size
imageModel.status = image.status.name
imageModel.filename = image.filename
imageModel.downloaded = image.downloaded
imageModel.progress = imageController.progress(
image.size, image.downloaded
@@ -67,6 +68,7 @@ class ImagesTableView : Fragment("Photos") {
}
}
}
}
override val root = vbox(alignment = Pos.CENTER_RIGHT) {
tableView = tableview(items) {
@@ -83,8 +85,8 @@ class ImagesTableView : Fragment("Photos") {
}
val contextMenu = ContextMenu()
contextMenu.items.addAll(urlItem)
tableRow.contextMenuProperty()
.bind(tableRow.emptyProperty().map { empty -> if (empty) null else contextMenu })
tableRow.contextMenuProperty().bind(tableRow.emptyProperty()
.map { empty -> if (empty) null else contextMenu })
tableRow
}
column("Index", ImageModel::indexProperty) {
@@ -120,6 +122,12 @@ class ImagesTableView : Fragment("Photos") {
cell as TableCell<ImageModel, Number>
}
}
column("Filename", ImageModel::filenameProperty) {
prefWidth = 150.0
cellFactory = Callback {
TextFieldTableCell<ImageModel?, String?>().apply { alignment = Pos.CENTER_LEFT }
}
}
column("Status", ImageModel::statusProperty) {
prefWidth = 50.0
cellFactory = Callback {
@@ -143,6 +151,5 @@ class ImagesTableView : Fragment("Photos") {
override fun onUndock() {
coroutineScope.cancel()
eventsSubscription.dispose()
}
}
@@ -2,6 +2,7 @@ package me.vripper.gui.view.tables
import javafx.scene.control.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filterIsInstance
import me.vripper.event.EventBus
import me.vripper.event.LogCreateEvent
import me.vripper.event.LogDeleteEvent
@@ -41,14 +42,16 @@ class LogTableView : View() {
}
}
eventBus.events.ofType(LogCreateEvent::class.java).subscribe {
coroutineScope.launch {
eventBus.events.filterIsInstance(LogCreateEvent::class).collect {
val logModel = logController.mapper(it.logEntry)
runLater {
items.add(logModel)
}
}
eventBus.events.ofType(LogUpdateEvent::class.java).subscribe {
}
coroutineScope.launch {
eventBus.events.filterIsInstance(LogUpdateEvent::class).collect {
val logModel = logController.mapper(it.logEntry)
val find = items.find { threadModel -> threadModel.id == logModel.id }
if (find != null) {
@@ -60,12 +63,16 @@ class LogTableView : View() {
}
}
}
eventBus.events.ofType(LogDeleteEvent::class.java).subscribe { deleteEvent ->
}
coroutineScope.launch {
eventBus.events.filterIsInstance(LogDeleteEvent::class).collect { deleteEvent ->
runLater {
items.items.removeIf { it.id in deleteEvent.deleted }
}
}
}
}
override val root = vbox {
tableView = tableview {
@@ -12,11 +12,10 @@ import javafx.util.Callback
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.*
import me.vripper.event.EventBus
import me.vripper.event.PostCreateEvent
import me.vripper.event.PostDeleteEvent
import me.vripper.event.PostUpdateEvent
import me.vripper.gui.clipboard.ClipboardService
import me.vripper.gui.controller.PostController
import me.vripper.gui.model.PostModel
@@ -57,14 +56,17 @@ class PostsTableView : View() {
}
}
eventBus.events.ofType(PostCreateEvent::class.java).subscribe { postEvents ->
coroutineScope.launch {
eventBus.events.filterIsInstance(PostCreateEvent::class).collect { postEvents ->
val add = postEvents.posts.map { postController.mapper(it.id) }
runLater {
tableView.items.addAll(add)
}
}
}
eventBus.events.ofType(PostUpdateEvent::class.java).subscribe { postEvents ->
coroutineScope.launch {
eventBus.events.filterIsInstance(PostUpdateEvent::class).collect { postEvents ->
runLater {
for (post in postEvents.posts) {
val postModel = items.find { it.postId == post.postId } ?: continue
@@ -83,8 +85,10 @@ class PostsTableView : View() {
}
}
}
}
eventBus.events.ofType(PostDeleteEvent::class.java).subscribe { postEvents ->
coroutineScope.launch {
eventBus.events.filterIsInstance(PostDeleteEvent::class).collect { postEvents ->
runLater {
postEvents.postIds.forEach {
items.removeIf { p -> p.postId == it }
@@ -92,6 +96,21 @@ class PostsTableView : View() {
}
}
}
coroutineScope.launch {
eventBus.events.filterIsInstance(MetadataUpdateEvent::class)
.collect { metadataUpdateEvent ->
runLater {
val postModel =
items.find { it.postId == metadataUpdateEvent.metadata.postId }
?: return@runLater
postModel.altTitles =
FXCollections.observableArrayList(metadataUpdateEvent.metadata.data.resolvedNames)
postModel.postedby = metadataUpdateEvent.metadata.data.postedBy
}
}
}
}
override val root = vbox {
tableView = tableview(items) {
@@ -176,10 +195,17 @@ class PostsTableView : View() {
val contextMenu = ContextMenu()
contextMenu.items.addAll(
startItem, stopItem, renameItem, deleteItem, SeparatorMenuItem(), detailsItem, locationItem, urlItem
startItem,
stopItem,
renameItem,
deleteItem,
SeparatorMenuItem(),
detailsItem,
locationItem,
urlItem
)
tableRow.contextMenuProperty()
.bind(tableRow.emptyProperty().map { empty -> if (empty) null else contextMenu })
tableRow.contextMenuProperty().bind(tableRow.emptyProperty()
.map { empty -> if (empty) null else contextMenu })
tableRow
}
column("Preview", PostModel::previewListProperty) {
@@ -287,6 +313,12 @@ class PostsTableView : View() {
TextFieldTableCell<PostModel?, String?>().apply { alignment = Pos.CENTER_LEFT }
}
}
column("Posted By", PostModel::postedByProperty) {
prefWidth = 100.0
cellFactory = Callback {
TextFieldTableCell<PostModel?, String?>().apply { alignment = Pos.CENTER_LEFT }
}
}
column("Order", PostModel::orderProperty) {
prefWidth = 50.0
sortOrder.add(this)
@@ -298,9 +330,14 @@ class PostsTableView : View() {
}
private fun rename(post: PostModel) {
find<RenameView>(mapOf(RenameView::postId to post.postId, RenameView::name to post.folderName)).openModal()
?.apply {
minWidth = 300.0
find<RenameView>(
mapOf(
RenameView::postId to post.postId,
RenameView::name to post.folderName,
RenameView::altTitles to post.altTitles
)
).openModal()?.apply {
minWidth = 450.0
}
}
@@ -336,8 +373,8 @@ class PostsTableView : View() {
private fun openPhotos(postId: Long) {
find<ImagesTableView>(mapOf(ImagesTableView::postId to postId)).openModal()?.apply {
minWidth = 600.0
minHeight = 400.0
minWidth = 800.0
minHeight = 600.0
}
}
}
@@ -5,6 +5,7 @@ import javafx.collections.ObservableList
import javafx.scene.control.*
import javafx.scene.image.ImageView
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filterIsInstance
import me.vripper.event.EventBus
import me.vripper.event.ThreadClearEvent
import me.vripper.event.ThreadCreateEvent
@@ -44,25 +45,31 @@ class ThreadTableView : View() {
tableView.placeholder = Label("No content in table")
}
}
eventBus.events.ofType(ThreadCreateEvent::class.java).subscribe {
coroutineScope.launch {
eventBus.events.filterIsInstance(ThreadCreateEvent::class).collect {
val threadModelMapper = threadController.threadModelMapper(it.thread)
runLater {
items.add(threadModelMapper)
}
}
}
eventBus.events.ofType(ThreadDeleteEvent::class.java).subscribe { event ->
coroutineScope.launch {
eventBus.events.filterIsInstance(ThreadDeleteEvent::class).collect { event ->
runLater {
tableView.items.removeIf { it.threadId == event.threadId }
}
}
}
eventBus.events.ofType(ThreadClearEvent::class.java).subscribe {
coroutineScope.launch {
eventBus.events.filterIsInstance(ThreadClearEvent::class).collect {
runLater {
tableView.items.clear()
}
}
}
}
override val root = vbox {
tableView = tableview(items) {
@@ -108,8 +115,8 @@ class ThreadTableView : View() {
val contextMenu = ContextMenu()
contextMenu.items.addAll(selectItem, urlItem, SeparatorMenuItem(), deleteItem)
tableRow.contextMenuProperty()
.bind(tableRow.emptyProperty().map { empty -> if (empty) null else contextMenu })
tableRow.contextMenuProperty().bind(tableRow.emptyProperty()
.map { empty -> if (empty) null else contextMenu })
tableRow
}
column("Title", ThreadModel::titleProperty) {
@@ -135,7 +142,8 @@ class ThreadTableView : View() {
}
private fun selectPosts(threadId: Long) {
find<ThreadSelectionTableView>(mapOf(ThreadSelectionTableView::threadId to threadId)).openModal()?.apply {
find<ThreadSelectionTableView>(mapOf(ThreadSelectionTableView::threadId to threadId)).openModal()
?.apply {
minWidth = 600.0
minHeight = 400.0
width = 800.0
@@ -1,6 +1,10 @@
package me.vripper.web.wsendpoints
import jakarta.annotation.PostConstruct
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.time.sample
import me.vripper.event.*
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -14,72 +18,110 @@ class DataBroadcast(
) : KoinComponent {
private val eventBus: EventBus by inject()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@OptIn(FlowPreview::class)
@PostConstruct
private fun run() {
eventBus.events.ofType(PostCreateEvent::class.java).subscribe { events ->
coroutineScope.launch {
coroutineScope.launch {
eventBus.events.filterIsInstance(PostCreateEvent::class).collect { events ->
template.convertAndSend("/topic/posts/new", events.posts)
}
eventBus.events.ofType(PostUpdateEvent::class.java).subscribe { events ->
}
coroutineScope.launch {
eventBus.events.filterIsInstance(PostUpdateEvent::class).collect { events ->
template.convertAndSend("/topic/posts/updated", events.posts)
}
eventBus.events.ofType(PostDeleteEvent::class.java).subscribe { events ->
}
coroutineScope.launch {
eventBus.events.filterIsInstance(PostDeleteEvent::class).collect { events ->
template.convertAndSend("/topic/posts/deleted", events.postIds)
}
}
eventBus.events.ofType(QueueStateEvent::class.java).subscribe {
coroutineScope.launch {
eventBus.events.filterIsInstance(QueueStateEvent::class).collect {
template.convertAndSend("/topic/queue-state", it.queueState)
}
}
eventBus.events.ofType(DownloadSpeedEvent::class.java).subscribe {
coroutineScope.launch {
eventBus.events.filterIsInstance(DownloadSpeedEvent::class).collect {
template.convertAndSend("/topic/download-speed", it.downloadSpeed)
}
}
eventBus.events.ofType(VGUserLoginEvent::class.java).subscribe {
coroutineScope.launch {
eventBus.events.filterIsInstance(VGUserLoginEvent::class).collect {
template.convertAndSend("/topic/vg-username", it.username)
}
}
eventBus.events.ofType(ErrorCountEvent::class.java).subscribe {
coroutineScope.launch {
eventBus.events.filterIsInstance(ErrorCountEvent::class).collect {
template.convertAndSend("/topic/error-count", it.errorCount)
}
eventBus.events.ofType(LoadingTasks::class.java).sample(Duration.ofMillis(500)).subscribe {
template.convertAndSend("/topic/loading", it.loading)
}
eventBus.events.ofType(ImageEvent::class.java).buffer(Duration.ofMillis(500)).subscribe { events ->
events.reversed().map { it.images }.flatten().distinct().groupBy { it.postId }
.forEach {
coroutineScope.launch {
eventBus.events.filterIsInstance(LoadingTasks::class).sample(Duration.ofMillis(500))
.collect {
template.convertAndSend("/topic/loading", it.loading)
}
}
coroutineScope.launch {
eventBus.events.filterIsInstance(ImageEvent::class).collect { events ->
events.images.groupBy { it.postId }.forEach {
template.convertAndSend("/topic/images/${it.key}", it.value)
}
}
}
eventBus.events.ofType(ThreadCreateEvent::class.java).map { listOf(it.thread) }
.subscribe { events ->
coroutineScope.launch {
eventBus.events.filterIsInstance(ThreadCreateEvent::class).map { listOf(it.thread) }
.collect { events ->
template.convertAndSend("/topic/threads", events)
}
}
eventBus.events.ofType(ThreadDeleteEvent::class.java).map { listOf(it.threadId) }
.subscribe {
coroutineScope.launch {
eventBus.events.filterIsInstance(ThreadDeleteEvent::class)
.map { listOf(it.threadId) }
.collect {
template.convertAndSend("/topic/threads/deleted", it)
}
}
eventBus.events.ofType(ThreadClearEvent::class.java).map { listOf(true) }.subscribe {
coroutineScope.launch {
eventBus.events.filterIsInstance(ThreadClearEvent::class).map { listOf(true) }
.collect {
template.convertAndSend("/topic/threads/deletedAll", it)
}
}
eventBus.events.ofType(LogCreateEvent::class.java).map { it.logEntry }
.subscribe { logCreateEvent ->
coroutineScope.launch {
eventBus.events.filterIsInstance(LogCreateEvent::class).map { it.logEntry }
.collect { logCreateEvent ->
template.convertAndSend("/topic/logs/new", listOf(logCreateEvent))
}
eventBus.events.ofType(LogUpdateEvent::class.java).map { it.logEntry }
.subscribe { logUpdateEvent ->
template.convertAndSend("/topic/logs/updated", listOf(logUpdateEvent))
}
eventBus.events.ofType(LogDeleteEvent::class.java).subscribe {
coroutineScope.launch {
eventBus.events.filterIsInstance(LogUpdateEvent::class).map { it.logEntry }
.collect { logUpdateEvent ->
template.convertAndSend("/topic/logs/updated", listOf(logUpdateEvent))
}
}
coroutineScope.launch {
eventBus.events.filterIsInstance(LogDeleteEvent::class).collect {
template.convertAndSend("/topic/logs/deleted", listOf(it.deleted))
}
}
}
}
}