Compare commits

...
1 Commits
Author SHA1 Message Date
dev-claw 309a8f60dd fixes #274
fixes #275
fixes #276
2025-10-10 21:03:01 +01:00
92 changed files with 2160 additions and 1829 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM eclipse-temurin:21-jre-ubi9-minimal
FROM eclipse-temurin:25-jre
WORKDIR /opt/vripper-web
ENTRYPOINT ["java", "-jar", "vripper-web.jar"]
ADD vripper-web.jar /opt/vripper-web/vripper-web.jar
+5 -5
View File
@@ -8,18 +8,18 @@
<packaging>pom</packaging>
<version>${revision}</version>
<properties>
<java.version>21</java.version>
<java.version>25</java.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>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<revision>6.5.8</revision>
<kotlin.version>2.0.21</kotlin.version>
<revision>6.6.0-alpha</revision>
<kotlin.version>2.2.20</kotlin.version>
<slf4j.version>2.0.16</slf4j.version>
<logback.version>1.5.12</logback.version>
<kotlinx-coroutines.version>1.9.0</kotlinx-coroutines.version>
<kotlinx-coroutines.version>1.10.2</kotlinx-coroutines.version>
<exposed.version>0.55.0</exposed.version>
<snakeyaml.version>2.0</snakeyaml.version>
<koin.version>4.0.0</koin.version>
@@ -28,7 +28,7 @@
<failsafe.version>3.3.2</failsafe.version>
<caffeine.version>3.1.8</caffeine.version>
<jna-platform.version>5.14.0</jna-platform.version>
<kotlinx-serialization-json.version>1.7.3</kotlinx-serialization-json.version>
<kotlinx-serialization-json.version>1.9.0</kotlinx-serialization-json.version>
<httpclient5.version>5.4</httpclient5.version>
<httpcore5.version>5.3</httpcore5.version>
<liquibase-core.version>4.29.2</liquibase-core.version>
@@ -11,6 +11,8 @@ import me.vripper.data.repositories.impl.ThreadRepositoryImpl
import me.vripper.event.EventBus
import me.vripper.host.*
import me.vripper.services.*
import me.vripper.services.download.DownloadService
import me.vripper.services.download.QueueManager
import org.koin.core.qualifier.named
import org.koin.dsl.bind
import org.koin.dsl.module
@@ -37,8 +39,8 @@ val coreModule = module {
single<ThreadRepository> {
ThreadRepositoryImpl()
}
single<DataTransaction> {
DataTransaction(get(), get(), get(), get(), get(), get())
single<DataAccessService> {
DataAccessService(get(), get(), get(), get(), get(), get())
}
single<RetryPolicyService> {
RetryPolicyService()
@@ -52,6 +54,9 @@ val coreModule = module {
single<ThreadCacheService> {
ThreadCacheService(get())
}
single<QueueManager> {
QueueManager(get(), getAll(), get(), get(), get())
}
single<DownloadService> {
DownloadService(get(), get(), get(), get())
}
@@ -60,11 +65,11 @@ val coreModule = module {
}
single<AppEndpointService> {
AppEndpointService(get(), get(), get(), get(), get(), get())
AppEndpointService(get(), get(), get(), get(), get(), get(), get())
}
single((named("localAppEndpointService"))) {
AppEndpointService(get(), get(), get(), get(), get(), get())
AppEndpointService(get(), get(), get(), get(), get(), get(), get())
} bind IAppEndpointService::class
single<MetadataService> {
@@ -6,15 +6,15 @@ import java.util.*
internal interface ImageRepository {
fun save(imageEntity: ImageEntity): ImageEntity
fun save(imageEntityList: List<ImageEntity>)
fun deleteAllByPostId(postId: Long)
fun findByPostId(postId: Long): List<ImageEntity>
fun deleteAllByPostEntityId(postEntityId: Long)
fun findByPostEntityId(postEntityId: Long): List<ImageEntity>
fun countError(): Int
fun findByPostIdAndIsNotCompleted(postId: Long): List<ImageEntity>
fun stopByPostIdAndIsNotCompleted(postId: Long): Int
fun stopByPostIdAndIsNotCompleted(): Int
fun findByPostIdAndIsError(postId: Long): List<ImageEntity>
fun findByPostEntityIdAndIsNotCompleted(postEntityId: Long): List<ImageEntity>
fun stopByPostEntityIdAndIsNotCompleted(postEntityId: Long): Int
fun stopByPostEntityIdAndIsNotCompleted(): Int
fun findByPostEntityIdAndIsError(postEntityId: Long): List<ImageEntity>
fun findById(id: Long): Optional<ImageEntity>
fun update(imageEntity: ImageEntity)
fun update(imageEntities: List<ImageEntity>)
fun deleteAllByPostId(postIds: List<Long>)
fun deleteAllByPostEntityId(postEntityIds: List<Long>)
}
@@ -5,7 +5,7 @@ import java.util.*
internal interface MetadataRepository {
fun save(metadataEntity: MetadataEntity): MetadataEntity
fun findByPostId(postId: Long): Optional<MetadataEntity>
fun deleteByPostId(postId: Long): Int
fun deleteAllByPostId(postIds: List<Long>)
fun findByPostEntityId(postEntityId: Long): Optional<MetadataEntity>
fun deleteByPostEntityId(postEntityId: Long): Int
fun deleteAllByPostEntityId(postEntityIds: List<Long>)
}
@@ -4,17 +4,16 @@ import me.vripper.entities.PostEntity
internal interface PostRepository {
fun save(postEntities: List<PostEntity>): List<PostEntity>
fun findByPostId(postId: Long): PostEntity?
fun findById(id: Long): PostEntity?
fun findCompleted(): List<Long>
fun findAll(): List<PostEntity>
fun existByPostEntityId(postEntityId: Long): Boolean
fun existByPostId(postId: Long): Boolean
fun setDownloadingToStopped(): Int
fun deleteByPostId(postId: Long): Int
fun deleteByPostEntityId(postEntityId: Long): Int
fun update(postEntity: PostEntity)
fun update(postEntities: List<PostEntity>)
fun findMaxRank(): Int?
fun deleteAll(postIds: List<Long>)
fun deleteAll(postEntityIds: List<Long>)
fun stopAll()
fun findAllNonCompletedPostIds(): List<Long>
fun findAllNonCompletedPostEntityIds(): List<Long>
}
@@ -16,12 +16,11 @@ internal class ImageRepositoryImpl : ImageRepository {
it[downloaded] = imageEntity.downloaded
it[host] = imageEntity.host
it[index] = imageEntity.index
it[postId] = imageEntity.postId
it[status] = imageEntity.status.name
it[size] = imageEntity.size
it[url] = imageEntity.url
it[thumbUrl] = imageEntity.thumbUrl
it[postIdRef] = imageEntity.postIdRef
it[postIdRef] = imageEntity.postEntityId
it[filename] = imageEntity.filename
}.value
return imageEntity.copy(id = id)
@@ -32,23 +31,22 @@ internal class ImageRepositoryImpl : ImageRepository {
this[ImageTable.downloaded] = it.downloaded
this[ImageTable.host] = it.host
this[ImageTable.index] = it.index
this[ImageTable.postId] = it.postId
this[ImageTable.status] = it.status.name
this[ImageTable.size] = it.size
this[ImageTable.url] = it.url
this[ImageTable.thumbUrl] = it.thumbUrl
this[ImageTable.postIdRef] = it.postIdRef
this[ImageTable.postIdRef] = it.postEntityId
this[ImageTable.filename] = it.filename
}
}
override fun deleteAllByPostId(postId: Long) {
ImageTable.deleteWhere { ImageTable.postId eq postId }
override fun deleteAllByPostEntityId(postEntityId: Long) {
ImageTable.deleteWhere { ImageTable.postIdRef eq postEntityId }
}
override fun findByPostId(postId: Long): List<ImageEntity> {
override fun findByPostEntityId(postEntityId: Long): List<ImageEntity> {
return ImageTable.selectAll().where {
ImageTable.postId eq postId
ImageTable.postIdRef eq postEntityId
}.map(this::transform)
}
@@ -59,28 +57,28 @@ internal class ImageRepositoryImpl : ImageRepository {
.count().toInt()
}
override fun findByPostIdAndIsNotCompleted(postId: Long): List<ImageEntity> {
override fun findByPostEntityIdAndIsNotCompleted(postEntityId: Long): List<ImageEntity> {
return ImageTable
.selectAll().where {
(ImageTable.postId eq postId) and (ImageTable.status neq Status.FINISHED.name)
(ImageTable.postIdRef eq postEntityId) and (ImageTable.status neq Status.FINISHED.name)
}.map(this::transform)
}
override fun stopByPostIdAndIsNotCompleted(postId: Long): Int {
return ImageTable.update({ (ImageTable.postId eq postId) and (ImageTable.status neq Status.FINISHED.name) }) {
override fun stopByPostEntityIdAndIsNotCompleted(postEntityId: Long): Int {
return ImageTable.update({ (ImageTable.postIdRef eq postEntityId) and (ImageTable.status neq Status.FINISHED.name) }) {
it[status] = Status.STOPPED.name
}
}
override fun stopByPostIdAndIsNotCompleted(): Int {
override fun stopByPostEntityIdAndIsNotCompleted(): Int {
return ImageTable.update({ (ImageTable.status neq Status.FINISHED.name) }) {
it[status] = Status.STOPPED.name
}
}
override fun findByPostIdAndIsError(postId: Long): List<ImageEntity> {
override fun findByPostEntityIdAndIsError(postEntityId: Long): List<ImageEntity> {
return ImageTable.selectAll().where {
(ImageTable.postId eq postId) and (ImageTable.status eq Status.ERROR.name)
(ImageTable.postIdRef eq postEntityId) and (ImageTable.status eq Status.ERROR.name)
}.map(this::transform)
}
@@ -110,32 +108,31 @@ internal class ImageRepositoryImpl : ImageRepository {
this[ImageTable.downloaded] = it.downloaded
this[ImageTable.host] = it.host
this[ImageTable.index] = it.index
this[ImageTable.postId] = it.postId
this[ImageTable.status] = it.status.name
this[ImageTable.size] = it.size
this[ImageTable.url] = it.url
this[ImageTable.thumbUrl] = it.thumbUrl
this[ImageTable.postIdRef] = it.postIdRef
this[ImageTable.postIdRef] = it.postEntityId
this[ImageTable.filename] = it.filename
}
}
override fun deleteAllByPostId(postIds: List<Long>) {
override fun deleteAllByPostEntityId(postEntityIds: List<Long>) {
val conn = TransactionManager.current().connection.connection as Connection
conn.prepareStatement("CREATE TEMPORARY TABLE IMAGES_DELETE(POST_ID BIGINT PRIMARY KEY)")
conn.prepareStatement("CREATE TEMPORARY TABLE IMAGES_DELETE(POST_ID_REF BIGINT PRIMARY KEY)")
.use {
it.execute()
}
conn.prepareStatement("INSERT INTO IMAGES_DELETE VALUES ( ? )").use { ps ->
postIds.forEach {
postEntityIds.forEach {
ps.setLong(1, it)
ps.addBatch()
}
ps.executeBatch()
}
conn.prepareStatement("DELETE FROM IMAGE WHERE POST_ID IN (SELECT POST_ID FROM IMAGES_DELETE)")
conn.prepareStatement("DELETE FROM IMAGE WHERE POST_ID_REF IN (SELECT POST_ID_REF FROM IMAGES_DELETE)")
.use {
it.execute()
}
@@ -147,7 +144,6 @@ internal class ImageRepositoryImpl : ImageRepository {
private fun transform(resultRow: ResultRow): ImageEntity {
val id = resultRow[ImageTable.id].value
val postId = resultRow[ImageTable.postId]
val url = resultRow[ImageTable.url]
val thumbUrl = resultRow[ImageTable.thumbUrl]
val host = resultRow[ImageTable.host]
@@ -159,7 +155,6 @@ internal class ImageRepositoryImpl : ImageRepository {
val filename = resultRow[ImageTable.filename]
return ImageEntity(
id,
postId,
url,
thumbUrl,
host,
@@ -18,17 +18,17 @@ internal class MetadataRepositoryImpl : MetadataRepository {
override fun save(metadataEntity: MetadataEntity): MetadataEntity {
MetadataTable.insert {
it[postId] = metadataEntity.postId
it[postIdRef] = metadataEntity.postIdRef
it[data] = Json.encodeToString(metadataEntity.data)
}
return metadataEntity
}
override fun findByPostId(postId: Long): Optional<MetadataEntity> {
override fun findByPostEntityId(postEntityId: Long): Optional<MetadataEntity> {
val result = MetadataTable.selectAll().where {
MetadataTable.postId eq postId
MetadataTable.postIdRef eq postEntityId
}.map(::transform)
return if (result.isEmpty()) {
@@ -39,31 +39,31 @@ internal class MetadataRepositoryImpl : MetadataRepository {
}
private fun transform(row: ResultRow): MetadataEntity {
val id = row[MetadataTable.postId]
val postEntityId = row[MetadataTable.postIdRef]
val data = Json.decodeFromString(row[MetadataTable.data]) as MetadataEntity.Data
return MetadataEntity(id, data)
return MetadataEntity(postEntityId, data)
}
override fun deleteByPostId(postId: Long): Int {
return MetadataTable.deleteWhere { MetadataTable.postId eq postId }
override fun deleteByPostEntityId(postEntityId: Long): Int {
return MetadataTable.deleteWhere { MetadataTable.postIdRef eq postEntityId }
}
override fun deleteAllByPostId(postIds: List<Long>) {
override fun deleteAllByPostEntityId(postEntityIds: List<Long>) {
val conn = TransactionManager.current().connection.connection as Connection
conn.prepareStatement("CREATE TEMPORARY TABLE METADATA_DELETE(POST_ID BIGINT PRIMARY KEY)")
conn.prepareStatement("CREATE TEMPORARY TABLE METADATA_DELETE(POST_ID_REF BIGINT PRIMARY KEY)")
.use {
it.execute()
}
conn.prepareStatement("INSERT INTO METADATA_DELETE VALUES ( ? )").use { ps ->
postIds.forEach {
postEntityIds.forEach {
ps.setLong(1, it)
ps.addBatch()
}
ps.executeBatch()
}
conn.prepareStatement("DELETE FROM METADATA WHERE POST_ID IN (SELECT POST_ID FROM METADATA_DELETE)")
conn.prepareStatement("DELETE FROM METADATA WHERE POST_ID_REF IN (SELECT POST_ID_REF FROM METADATA_DELETE)")
.use {
it.execute()
}
@@ -19,12 +19,11 @@ internal class PostRepositoryImpl :
this[PostTable.status] = post.status.name
this[PostTable.done] = post.done
this[PostTable.total] = post.total
this[PostTable.rank] = post.rank
this[PostTable.hosts] = post.hosts.joinToString(delimiter)
this[PostTable.outputPath] = post.downloadDirectory
this[PostTable.folderName] = post.folderName
this[PostTable.postId] = post.postId
this[PostTable.threadId] = post.threadId
this[PostTable.postId] = post.vgPostId
this[PostTable.threadId] = post.vgThreadId
this[PostTable.postTitle] = post.postTitle
this[PostTable.threadTitle] = post.threadTitle
this[PostTable.forum] = post.forum
@@ -36,17 +35,10 @@ internal class PostRepositoryImpl :
}.map(::transform)
}
override fun findByPostId(postId: Long): PostEntity? {
val result = PostTable.selectAll().where {
PostTable.postId eq postId
}.map(::transform)
return result.firstOrNull()
}
override fun findCompleted(): List<Long> {
return PostTable.select(PostTable.postId).where {
return PostTable.select(PostTable.id).where {
(PostTable.status eq Status.FINISHED.name) and (PostTable.done greaterEq PostTable.total)
}.map { it[PostTable.postId] }
}.map { it[PostTable.id].value }
}
override fun findById(id: Long): PostEntity? {
@@ -60,8 +52,12 @@ internal class PostRepositoryImpl :
return PostTable.selectAll().map { transform(it) }
}
override fun existByPostEntityId(postEntityId: Long): Boolean {
return PostTable.select(PostTable.id).where { PostTable.id eq postEntityId }.count() > 0
}
override fun existByPostId(postId: Long): Boolean {
return PostTable.select(PostTable.id).where { PostTable.postId eq postId }.count() > 0
return PostTable.select(PostTable.postId).where { PostTable.postId eq postId }.count() > 0
}
override fun setDownloadingToStopped(): Int {
@@ -70,15 +66,14 @@ internal class PostRepositoryImpl :
}
}
override fun deleteByPostId(postId: Long): Int {
return PostTable.deleteWhere { PostTable.postId eq postId }
override fun deleteByPostEntityId(postEntityId: Long): Int {
return PostTable.deleteWhere { PostTable.id eq postEntityId }
}
override fun update(postEntity: PostEntity) {
PostTable.update({ PostTable.id eq postEntity.id }) {
it[status] = postEntity.status.name
it[done] = postEntity.done
it[rank] = postEntity.rank
it[size] = postEntity.size
it[downloaded] = postEntity.downloaded
it[folderName] = postEntity.folderName
@@ -91,12 +86,11 @@ internal class PostRepositoryImpl :
this[PostTable.status] = post.status.name
this[PostTable.done] = post.done
this[PostTable.total] = post.total
this[PostTable.rank] = post.rank
this[PostTable.hosts] = post.hosts.joinToString(delimiter)
this[PostTable.outputPath] = post.downloadDirectory
this[PostTable.folderName] = post.folderName
this[PostTable.postId] = post.postId
this[PostTable.threadId] = post.threadId
this[PostTable.postId] = post.vgPostId
this[PostTable.threadId] = post.vgThreadId
this[PostTable.postTitle] = post.postTitle
this[PostTable.threadTitle] = post.threadTitle
this[PostTable.forum] = post.forum
@@ -104,31 +98,26 @@ internal class PostRepositoryImpl :
this[PostTable.token] = post.token
this[PostTable.size] = post.size
this[PostTable.downloaded] = post.downloaded
this[PostTable.addedAt] = post.addedOn
}
}
override fun findMaxRank(): Int? {
return PostTable.select(PostTable.rank.max())
.firstOrNull()
?.get(PostTable.rank.max())
}
override fun deleteAll(postIds: List<Long>) {
override fun deleteAll(postEntityIds: List<Long>) {
val conn = TransactionManager.current().connection.connection as Connection
conn.prepareStatement("CREATE TEMPORARY TABLE POSTS_DELETE(POST_ID BIGINT PRIMARY KEY)")
conn.prepareStatement("CREATE TEMPORARY TABLE POSTS_DELETE(ID BIGINT PRIMARY KEY)")
.use {
it.execute()
}
conn.prepareStatement("INSERT INTO POSTS_DELETE VALUES ( ? )").use { ps ->
postIds.forEach {
postEntityIds.forEach {
ps.setLong(1, it)
ps.addBatch()
}
ps.executeBatch()
}
conn.prepareStatement("DELETE FROM POST WHERE POST_ID IN (SELECT POST_ID FROM POSTS_DELETE)")
conn.prepareStatement("DELETE FROM POST WHERE ID IN (SELECT ID FROM POSTS_DELETE)")
.use {
it.execute()
}
@@ -144,10 +133,10 @@ internal class PostRepositoryImpl :
}
}
override fun findAllNonCompletedPostIds(): List<Long> {
return PostTable.select(PostTable.postId).where {
override fun findAllNonCompletedPostEntityIds(): List<Long> {
return PostTable.select(PostTable.id).where {
PostTable.status neq Status.FINISHED.name
}.map { it[PostTable.postId] }
}.map { it[PostTable.id].value }
}
private fun transform(resultRow: ResultRow): PostEntity {
@@ -167,7 +156,6 @@ internal class PostRepositoryImpl :
val downloadDirectory = resultRow[PostTable.outputPath]
val folderName = resultRow[PostTable.folderName]
val addedOn = resultRow[PostTable.addedAt]
val rank = resultRow[PostTable.rank]
val size = resultRow[PostTable.size]
val downloaded = resultRow[PostTable.downloaded]
return PostEntity(
@@ -186,7 +174,6 @@ internal class PostRepositoryImpl :
folderName,
status,
done,
rank,
size,
downloaded
)
@@ -6,7 +6,6 @@ internal object ImageTable : LongIdTable(name = "IMAGE", columnName = "ID") {
val downloaded = long("DOWNLOADED")
val host = byte("HOST")
val index = integer("INDEX")
val postId = long("POST_ID")
val status = varchar("STATUS", 15)
val filename = varchar("FILENAME", 260)
val size = long("SIZE")
@@ -3,6 +3,6 @@ package me.vripper.data.tables
import org.jetbrains.exposed.sql.Table
internal object MetadataTable : Table(name = "METADATA") {
val postId = long("POST_ID")
val postIdRef = long("POST_ID_REF").references(PostTable.id, fkName = "METADATA_POST_ID_REF_POST_ID_FK")
val data = varchar("DATA", 1_000_000)
}
@@ -5,29 +5,13 @@ import kotlinx.serialization.Serializable
@Serializable
data class ImageEntity(
val id: Long = -1,
val postId: Long,
val url: String,
val thumbUrl: String,
val host: Byte,
val index: Int,
val postIdRef: Long = -1,
val postEntityId: Long = -1,
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
if (javaClass != other?.javaClass) return false
other as ImageEntity
if (url != other.url) return false
return true
}
override fun hashCode(): Int {
return url.hashCode()
}
}
)
@@ -3,7 +3,7 @@ package me.vripper.entities
import kotlinx.serialization.Serializable
@Serializable
data class MetadataEntity(val postId: Long, val data: Data) {
data class MetadataEntity(val postIdRef: Long, val data: Data) {
@Serializable
data class Data(
val postedBy: String,
@@ -12,8 +12,8 @@ data class PostEntity(
val forum: String,
val url: String,
val token: String,
val postId: Long,
val threadId: Long,
val vgPostId: Long,
val vgThreadId: Long,
val total: Int,
val hosts: Set<String>,
val downloadDirectory: String,
@@ -21,21 +21,6 @@ data class PostEntity(
var folderName: String,
var status: Status = Status.STOPPED,
var done: Int = 0,
var rank: Int = Int.MAX_VALUE,
var size: Long = -1,
var downloaded: Long = 0,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PostEntity
return postId == other.postId
}
override fun hashCode(): Int {
return postId.hashCode()
}
}
)
@@ -9,17 +9,4 @@ data class ThreadEntity(
val link: String,
val threadId: Long,
var total: Int = 0,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ThreadEntity
return threadId == other.threadId
}
override fun hashCode(): Int {
return threadId.hashCode()
}
}
)
@@ -8,7 +8,7 @@ import me.vripper.model.Settings
data class PostCreateEvent(val postEntities: List<PostEntity>)
data class PostUpdateEvent(val postEntities: List<PostEntity>)
data class PostDeleteEvent(val postIds: List<Long>)
data class PostDeleteEvent(val postEntityIds: List<Long>)
data class ImageEvent(val imageEntities: List<ImageEntity>)
data class ThreadCreateEvent(val threadEntity: ThreadEntity)
data class ThreadUpdateEvent(val threadEntity: ThreadEntity)
@@ -1,10 +1,9 @@
package me.vripper.host
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.HTTPService
import me.vripper.services.download.ImageDownloadRunnable.Context
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
@@ -20,22 +19,22 @@ internal class AcidimgHost(
@Throws(HostException::class)
override fun resolve(
image: ImageEntity, context: ImageDownloadContext
context: Context
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val document = fetchDocument(context.imageEntity.url, context)
try {
log.debug(
String.format(
"Looking for xpath expression %s in %s", CONTINUE_BUTTON_XPATH, image.url
"Looking for xpath expression %s in %s", CONTINUE_BUTTON_XPATH, context.imageEntity.url
)
)
XpathUtils.getAsNode(document, CONTINUE_BUTTON_XPATH)
} catch (e: XpathException) {
throw HostException(e)
}
log.debug(String.format("Click button found for %s", image.url))
val httpPost = HttpPost(image.url).also {
it.addHeader("Referer", image.url)
log.debug(String.format("Click button found for %s", context.imageEntity.url))
val httpPost = HttpPost(context.imageEntity.url).also {
it.addHeader("Referer", context.imageEntity.url)
it.entity = UrlEncodedFormEntity(
listOf(
BasicNameValuePair(
@@ -59,17 +58,17 @@ internal class AcidimgHost(
throw HostException(e)
}
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, context.imageEntity.url))
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
String.format(
"Xpath '%s' cannot be found in '%s'", IMG_XPATH, image.url
"Xpath '%s' cannot be found in '%s'", IMG_XPATH, context.imageEntity.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", image.url))
log.debug(String.format("Resolving name and image url for %s", context.imageEntity.url))
val imgTitle = imgNode.attributes.getNamedItem("alt").textContent.trim()
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim()
Pair(
@@ -1,9 +1,8 @@
package me.vripper.host
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.download.ImageDownloadRunnable
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Node
@@ -14,12 +13,11 @@ internal class DPicMeHost : Host("dpic.me", 1) {
@Throws(HostException::class)
override fun resolve(
image: ImageEntity,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val document = fetchDocument(context.imageEntity.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, context.imageEntity.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -27,11 +25,11 @@ internal class DPicMeHost : Host("dpic.me", 1) {
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
image.url
context.imageEntity.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", image.url))
log.debug(String.format("Resolving name and image url for %s", context.imageEntity.url))
val imgTitle =
Optional.ofNullable(imgNode.attributes.getNamedItem("alt"))
.map { e: Node -> e.textContent.trim() }
@@ -4,13 +4,12 @@ import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.runBlocking
import me.vripper.entities.ImageEntity
import me.vripper.exception.DownloadException
import me.vripper.exception.HostException
import me.vripper.services.DataTransaction
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.DataAccessService
import me.vripper.services.DownloadSpeedService
import me.vripper.services.HTTPService
import me.vripper.services.download.ImageDownloadRunnable.Context
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.PathUtils.getFileNameWithoutExtension
@@ -33,7 +32,7 @@ internal abstract class Host(
private val log by LoggerDelegate()
private val httpService: HTTPService by inject()
private val dataTransaction: DataTransaction by inject()
private val dataAccessService: DataAccessService by inject()
private val downloadSpeedService: DownloadSpeedService by inject()
companion object {
@@ -49,46 +48,43 @@ internal abstract class Host(
hosts[hostName] = hostId
}
@Throws(HostException::class)
abstract fun resolve(
image: ImageEntity,
context: ImageDownloadContext
context: Context
): Pair<String, String>
@Throws(HostException::class)
fun downloadInternal(image: ImageEntity, context: ImageDownloadContext): DownloadedImage {
fun downloadInternal(context: Context): DownloadedImage {
if (hostId == 8.toByte()) {
return downloadByHost(image, context)
return downloadByHost(context)
}
val headers = head(image.url, context)
val headers = head(context)
// is the body of type image ?
val imageMimeType = getImageMimeType(headers)
val downloadedImage = if (imageMimeType != null) {
// a direct link, awesome
val downloadedImage = fetch(image.url, context) {
val downloadedImage = fetch(context.imageEntity.url, context.imageEntity.url, context) {
handleImageDownload(it, context)
}
DownloadedImage(getDefaultImageName(image.url), downloadedImage.first, downloadedImage.second)
DownloadedImage(getDefaultImageName(context.imageEntity.url), downloadedImage.first, downloadedImage.second)
} else {
// linked image ?
val value = headers.find { it.name.contains("content-type", true) }?.value
if (value != null) {
if (value.contains("text/html")) {
downloadByHost(image, context)
downloadByHost(context)
} else {
throw HostException("Unable to download ${image.url}, can't process content type $value")
throw HostException("Unable to download ${context.imageEntity.url}, can't process content type $value")
}
} else {
throw HostException("Unexpected server response for ${image.url}, response have no content type")
throw HostException("Unexpected server response for ${context.imageEntity.url}, response have no content type")
}
}
return downloadedImage
}
private fun downloadByHost(image: ImageEntity, context: ImageDownloadContext): DownloadedImage {
val resolvedImage = resolve(image, context)
private fun downloadByHost(context: Context): DownloadedImage {
val resolvedImage = resolve(context)
val downloadImage: Pair<Path, ImageMimeType> =
fetch(resolvedImage.second, context) {
fetch(resolvedImage.second, context.imageEntity.url, context) {
handleImageDownload(it, context)
}
return DownloadedImage(resolvedImage.first, downloadImage.first, downloadImage.second)
@@ -96,7 +92,7 @@ internal abstract class Host(
private fun handleImageDownload(
response: ClassicHttpResponse,
context: ImageDownloadContext
context: Context
): Pair<Path, ImageMimeType> {
val mimeType = getImageMimeType(response.headers)
?: throw HostException("Unsupported image type ${response.getFirstHeader("content-type")}")
@@ -107,23 +103,22 @@ internal abstract class Host(
".tmp"
)
return BufferedOutputStream(Files.newOutputStream(tempImage)).use { bos ->
val image = context.imageEntity
synchronized(image.postId.toString().intern()) {
val post = dataTransaction.findPostById(context.postId)
val size = if (image.size < 0) {
synchronized(context.imageEntity.postEntityId.toString().intern()) {
val post = dataAccessService.findPostByEntityId(context.imageEntity.postEntityId)
val size = if (context.imageEntity.size < 0) {
response.entity.contentLength
} else {
0
}
image.size = response.entity.contentLength
context.imageEntity.size = response.entity.contentLength
post.size += size
transaction {
dataTransaction.updateImage(image)
dataTransaction.updatePost(post)
dataAccessService.updateImage(context.imageEntity)
dataAccessService.updatePost(post)
}
}
log.debug(
"Length is ${image.size}"
"Length is ${context.imageEntity.size}"
)
log.debug(
"Starting data transfer"
@@ -132,7 +127,7 @@ internal abstract class Host(
var read: Int
val reporterJob = context.launchCoroutine {
while (isActive) {
dataTransaction.updateImage(image, false)
dataAccessService.updateImage(context.imageEntity, false)
delay(100)
}
}
@@ -140,13 +135,13 @@ internal abstract class Host(
.also { read = it } != -1
) {
bos.write(buffer, 0, read)
image.downloaded += read
context.imageEntity.downloaded += read
downloadSpeedService.reportDownloadedBytes(read.toLong())
}
runBlocking {
reporterJob.cancelAndJoin()
}
dataTransaction.updateImage(image)
dataAccessService.updateImage(context.imageEntity)
Pair(tempImage, mimeType)
}
}
@@ -155,9 +150,8 @@ internal abstract class Host(
return url.contains(hostName)
}
@Throws(HostException::class)
fun head(url: String, context: ImageDownloadContext): Array<Header> {
val httpHead = HttpHead(url).also {
fun head(context: Context): Array<Header> {
val httpHead = HttpHead(context.imageEntity.url).also {
it.setAbsoluteRequestUri(true)
context.requests.add(it)
}
@@ -173,15 +167,15 @@ internal abstract class Host(
}
}
@Throws(HostException::class)
fun <T> fetch(
url: String,
context: ImageDownloadContext,
referer: String,
context: Context,
transformer: (ClassicHttpResponse) -> T
): T {
val httpGet =
HttpGet(url).also {
it.addHeader("Referer", context.imageEntity.url)
it.addHeader("Referer", referer)
it.setAbsoluteRequestUri(true)
}.also { context.requests.add(it) }
log.info("{}", httpGet)
@@ -195,13 +189,13 @@ internal abstract class Host(
fun fetchDocument(
url: String,
context: ImageDownloadContext
context: Context
): Document {
return fetch(url, context) {
return fetch(url, url, context) {
HtmlUtils.clean(it.entity.content)
}.also {
if (log.isDebugEnabled) {
log.debug("Cleaning $url response", url)
log.debug("Cleaning {} response", url)
}
}
}
@@ -1,9 +1,8 @@
package me.vripper.host
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.download.ImageDownloadRunnable
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
@@ -19,12 +18,11 @@ internal class ImageBamHost : Host("imagebam.com", 2) {
@Throws(HostException::class)
override fun resolve(
image: ImageEntity,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val document = fetchDocument(context.imageEntity.url, context)
val doc = try {
log.debug(String.format("Looking for xpath expression %s in %s", CONTINUE_XPATH, image.url))
log.debug(String.format("Looking for xpath expression %s in %s", CONTINUE_XPATH, context.imageEntity.url))
if (XpathUtils.getAsNode(document, CONTINUE_XPATH) != null) {
val clientCookie = BasicClientCookie("nsfw_inter", "1")
clientCookie.domain = "www.imagebam.com"
@@ -34,7 +32,7 @@ internal class ImageBamHost : Host("imagebam.com", 2) {
LocalDateTime.now().plusDays(3).atZone(ZoneId.systemDefault()).toInstant()
)
context.httpContext.cookieStore.addCookie(clientCookie)
fetch(image.url, context) {
fetch(context.imageEntity.url, context.imageEntity.url, context) {
HtmlUtils.clean(it.entity.content)
}
} else {
@@ -44,7 +42,7 @@ internal class ImageBamHost : Host("imagebam.com", 2) {
throw HostException(e)
}
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, context.imageEntity.url))
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -52,11 +50,11 @@ internal class ImageBamHost : Host("imagebam.com", 2) {
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
image.url
context.imageEntity.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", image.url))
log.debug(String.format("Resolving name and image url for %s", context.imageEntity.url))
val imgTitle = Optional.ofNullable(imgNode.attributes.getNamedItem("alt"))
.map { e: Node -> e.textContent.trim { it <= ' ' } }
.orElse("")
@@ -1,9 +1,8 @@
package me.vripper.host
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.download.ImageDownloadRunnable
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Node
@@ -14,12 +13,11 @@ internal class ImageTwistHost : Host("imagetwist.com", 3) {
@Throws(HostException::class)
override fun resolve(
image: ImageEntity,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val document = fetchDocument(context.imageEntity.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, context.imageEntity.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -27,11 +25,11 @@ internal class ImageTwistHost : Host("imagetwist.com", 3) {
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
image.url
context.imageEntity.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", image.url))
log.debug(String.format("Resolving name and image url for %s", context.imageEntity.url))
val imgTitle =
Optional.ofNullable(imgNode.attributes.getNamedItem("alt"))
.map { obj: Node -> obj.textContent }
@@ -1,9 +1,8 @@
package me.vripper.host
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.download.ImageDownloadRunnable
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
@@ -14,21 +13,20 @@ internal class ImageVenueHost : Host("imagevenue.com", 4) {
@Throws(HostException::class)
override fun resolve(
image: ImageEntity,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val doc = try {
val document = fetchDocument(image.url, context)
val document = fetchDocument(context.imageEntity.url, context)
log.debug(
String.format(
"Looking for xpath expression %s in %s",
CONTINUE_BUTTON_XPATH,
image.url
context.imageEntity.url
)
)
if (XpathUtils.getAsNode(document, CONTINUE_BUTTON_XPATH) != null) {
// Button detected. No need to actually click it, just make the call again.
fetch(image.url, context) {
fetch(context.imageEntity.url, context.imageEntity.url, context) {
HtmlUtils.clean(it.entity.content)
}
} else {
@@ -38,7 +36,7 @@ internal class ImageVenueHost : Host("imagevenue.com", 4) {
throw HostException(e)
}
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, context.imageEntity.url))
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -46,11 +44,11 @@ internal class ImageVenueHost : Host("imagevenue.com", 4) {
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
image.url
context.imageEntity.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", image.url))
log.debug(String.format("Resolving name and image url for %s", context.imageEntity.url))
val imgTitle = imgNode.attributes.getNamedItem("alt").textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' }
Pair(
@@ -1,9 +1,8 @@
package me.vripper.host
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.download.ImageDownloadRunnable
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
@@ -12,12 +11,11 @@ internal class ImageZillaHost : Host("imagezilla.net", 5) {
@Throws(HostException::class)
override fun resolve(
image: ImageEntity,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val document = fetchDocument(context.imageEntity.url, context)
val titleNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, context.imageEntity.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -25,17 +23,17 @@ internal class ImageZillaHost : Host("imagezilla.net", 5) {
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
image.url
context.imageEntity.url
)
)
log.debug(String.format("Resolving name for %s", image.url))
log.debug(String.format("Resolving name for %s", context.imageEntity.url))
var title = titleNode.attributes.getNamedItem("title").textContent.trim()
titleNode.textContent.trim()
if (title.isEmpty()) {
title = getDefaultImageName(image.url)
title = getDefaultImageName(context.imageEntity.url)
}
return try {
Pair(title, image.url.replace("show", "images"))
Pair(title, context.imageEntity.url.replace("show", "images"))
} catch (e: Exception) {
throw HostException("Unexpected error occurred", e)
}
@@ -1,9 +1,8 @@
package me.vripper.host
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.download.ImageDownloadRunnable
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Node
@@ -13,12 +12,11 @@ internal class ImgSpiceHost : Host("imgspice.com", 7) {
@Throws(HostException::class)
override fun resolve(
image: ImageEntity,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val document = fetchDocument(context.imageEntity.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, context.imageEntity.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -26,11 +24,11 @@ internal class ImgSpiceHost : Host("imgspice.com", 7) {
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
image.url
context.imageEntity.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", image.url))
log.debug(String.format("Resolving name and image url for %s", context.imageEntity.url))
val imgTitle = imgNode.attributes.getNamedItem("alt").textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' }
Pair(
@@ -1,9 +1,8 @@
package me.vripper.host
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.download.ImageDownloadRunnable
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Node
@@ -13,12 +12,11 @@ internal class ImgboxHost : Host("imgbox.com", 6) {
@Throws(HostException::class)
override fun resolve(
image: ImageEntity,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val document = fetchDocument(context.imageEntity.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, context.imageEntity.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -26,11 +24,11 @@ internal class ImgboxHost : Host("imgbox.com", 6) {
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
image.url
context.imageEntity.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", image.url))
log.debug(String.format("Resolving name and image url for %s", context.imageEntity.url))
val imgTitle = imgNode.attributes.getNamedItem("title").textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' }
Pair(imgTitle, imgUrl)
@@ -2,35 +2,21 @@ package me.vripper.host
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.download.ImageDownloadRunnable
import me.vripper.utilities.LoggerDelegate
import org.apache.hc.client5.http.config.ConnectionConfig
import org.apache.hc.client5.http.impl.classic.HttpClients
import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager
import java.util.concurrent.TimeUnit
internal class ImxHost : Host("imx.to", 8) {
private val log by LoggerDelegate()
private val failFastHttpClient = HttpClients.custom().apply {
setConnectionManager(BasicHttpClientConnectionManager().apply {
connectionConfig = ConnectionConfig.custom()
.setConnectTimeout(5000, TimeUnit.MILLISECONDS)
.setSocketTimeout(5000, TimeUnit.MILLISECONDS)
.build()
})
}.build()
@Throws(HostException::class)
override fun resolve(
image: ImageEntity,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
log.debug("Resolving name and image url for ${image.url}")
val imgTitle = String.format("IMG_%04d", image.index + 1)
val imgUrl = findPattern(image)
log.debug("Resolving name and image url for ${context.imageEntity.url}")
val imgTitle = String.format("IMG_%04d", context.imageEntity.index + 1)
val imgUrl = findPattern(context.imageEntity)
return Pair(
imgTitle.ifEmpty { getDefaultImageName(imgUrl) }, imgUrl
)
@@ -1,9 +1,8 @@
package me.vripper.host
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.download.ImageDownloadRunnable
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
@@ -16,19 +15,18 @@ internal class PimpandhostHost : Host("pimpandhost.com", 9) {
@Throws(HostException::class)
override fun resolve(
image: ImageEntity,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val newUrl: String
try {
newUrl = appendUri(
image.url.replace("http://", "https://").replace("-medium(\\.html)?".toRegex(), ""),
context.imageEntity.url.replace("http://", "https://").replace("-medium(\\.html)?".toRegex(), ""),
"size=original"
)
} catch (e: Exception) {
throw HostException(e)
}
val doc = fetch(newUrl, context) {
val doc = fetch(newUrl, context.imageEntity.url, context) {
HtmlUtils.clean(it.entity.content)
}
val imgNode: Node = try {
@@ -40,7 +38,7 @@ internal class PimpandhostHost : Host("pimpandhost.com", 9) {
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
image.url
context.imageEntity.url
)
)
return try {
@@ -1,9 +1,8 @@
package me.vripper.host
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.download.ImageDownloadRunnable
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Node
@@ -13,12 +12,11 @@ internal class PixRouteHost : Host("pixroute.com", 11) {
@Throws(HostException::class)
override fun resolve(
image: ImageEntity,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val document = fetchDocument(context.imageEntity.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, context.imageEntity.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -26,11 +24,11 @@ internal class PixRouteHost : Host("pixroute.com", 11) {
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
image.url
context.imageEntity.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", image.url))
log.debug(String.format("Resolving name and image url for %s", context.imageEntity.url))
Pair(imgNode.attributes.getNamedItem("alt").textContent.trim { it <= ' ' },
imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' })
} catch (e: Exception) {
@@ -1,9 +1,8 @@
package me.vripper.host
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.download.ImageDownloadRunnable
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Node
@@ -13,12 +12,11 @@ internal class PixhostHost : Host("pixhost.to", 10) {
@Throws(HostException::class)
override fun resolve(
image: ImageEntity,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val document = fetchDocument(context.imageEntity.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, context.imageEntity.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -26,11 +24,11 @@ internal class PixhostHost : Host("pixhost.to", 10) {
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
image.url
context.imageEntity.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", image.url))
log.debug(String.format("Resolving name and image url for %s", context.imageEntity.url))
val imgTitle = imgNode.attributes.getNamedItem("alt").textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' }
Pair(imgTitle.substring(imgTitle.indexOf('_') + 1), imgUrl)
@@ -1,9 +1,8 @@
package me.vripper.host
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.download.ImageDownloadRunnable
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
@@ -12,12 +11,11 @@ internal class PixxxelsHost : Host("pixxxels.cc", 12) {
@Throws(HostException::class)
override fun resolve(
image: ImageEntity,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val document = fetchDocument(context.imageEntity.url, context)
val imgNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, context.imageEntity.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -25,11 +23,11 @@ internal class PixxxelsHost : Host("pixxxels.cc", 12) {
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
image.url
context.imageEntity.url
)
)
val titleNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, image.url))
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, context.imageEntity.url))
XpathUtils.getAsNode(document, TITLE_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -37,11 +35,11 @@ internal class PixxxelsHost : Host("pixxxels.cc", 12) {
String.format(
"Xpath '%s' cannot be found in '%s'",
TITLE_XPATH,
image.url
context.imageEntity.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", image.url))
log.debug(String.format("Resolving name and image url for %s", context.imageEntity.url))
val imgTitle = titleNode.textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("href").textContent.trim { it <= ' ' }
Pair(
@@ -1,9 +1,8 @@
package me.vripper.host
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.download.ImageDownloadRunnable
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Node
@@ -14,12 +13,11 @@ internal class PostImgHost : Host("postimg.cc", 13) {
@Throws(HostException::class)
override fun resolve(
image: ImageEntity,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val document = fetchDocument(context.imageEntity.url, context)
val titleNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, image.url))
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, context.imageEntity.url))
XpathUtils.getAsNode(document, TITLE_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -27,11 +25,11 @@ internal class PostImgHost : Host("postimg.cc", 13) {
String.format(
"Xpath '%s' cannot be found in '%s'",
TITLE_XPATH,
image.url
context.imageEntity.url
)
)
val urlNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, context.imageEntity.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -39,14 +37,14 @@ internal class PostImgHost : Host("postimg.cc", 13) {
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
image.url
context.imageEntity.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", image.url))
log.debug(String.format("Resolving name and image url for %s", context.imageEntity.url))
val imgTitle = Optional.ofNullable(titleNode)
.map { node: Node -> node.textContent.trim { it <= ' ' } }
.orElseGet { getDefaultImageName(image.url) }
.orElseGet { getDefaultImageName(context.imageEntity.url) }
Pair(imgTitle, urlNode.attributes.getNamedItem("href").textContent.trim { it <= ' ' })
} catch (e: Exception) {
throw HostException("Unexpected error occurred", e)
@@ -1,9 +1,8 @@
package me.vripper.host
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.download.ImageDownloadRunnable
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Node
@@ -13,28 +12,27 @@ internal class TurboImageHost : Host("turboimagehost.com", 14) {
@Throws(HostException::class)
override fun resolve(
image: ImageEntity,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val document = fetchDocument(context.imageEntity.url, context)
var title: String?
title = try {
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, image.url))
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, context.imageEntity.url))
val titleNode: Node? = XpathUtils.getAsNode(document, TITLE_XPATH)
log.debug(String.format("Resolving name for %s", image.url))
log.debug(String.format("Resolving name for %s", context.imageEntity.url))
titleNode?.textContent?.trim { it <= ' ' }
} catch (e: XpathException) {
throw HostException(e)
}
if (title.isNullOrEmpty()) {
title = getDefaultImageName(image.url)
title = getDefaultImageName(context.imageEntity.url)
}
val urlNode: Node = XpathUtils.getAsNode(document, IMG_XPATH)
?: throw HostException(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
image.url
context.imageEntity.url
)
)
return Pair(title, urlNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' })
@@ -1,9 +1,8 @@
package me.vripper.host
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.download.ImageDownloadRunnable
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Node
@@ -14,12 +13,11 @@ internal class ViprImHost : Host("vipr.im", 15) {
@Throws(HostException::class)
override fun resolve(
image: ImageEntity,
context: ImageDownloadContext
context: ImageDownloadRunnable.Context
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val document = fetchDocument(context.imageEntity.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, context.imageEntity.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -27,11 +25,11 @@ internal class ViprImHost : Host("vipr.im", 15) {
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
image.url
context.imageEntity.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", image.url))
log.debug(String.format("Resolving name and image url for %s", context.imageEntity.url))
val imgTitle =
Optional.ofNullable(imgNode.attributes.getNamedItem("alt"))
.map { obj: Node -> obj.textContent }
@@ -5,12 +5,13 @@ import kotlinx.coroutines.flow.filterIsInstance
import me.vripper.event.EventBus
import me.vripper.event.SettingsUpdateEvent
import me.vripper.services.*
import me.vripper.services.download.DownloadService
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
object AppManager : KoinComponent {
private val eventBus: EventBus by inject()
private val dataTransaction: DataTransaction by inject()
private val dataAccessService: DataAccessService by inject()
private val metadataService: MetadataService by inject()
private val settingsService: SettingsService by inject()
private val vgAuthService: VGAuthService by inject()
@@ -37,8 +38,8 @@ object AppManager : KoinComponent {
threadCacheService.invalidate()
}
}
dataTransaction.setDownloadingToStopped()
dataTransaction.stopImagesByPostIdAndIsNotCompleted()
dataAccessService.setDownloadingToStopped()
dataAccessService.stopImagesByPostEntityIdAndIsNotCompleted()
settingsService.init()
metadataService.fetchExisting()
downloadSpeedService.init()
@@ -20,11 +20,10 @@ data class Post(
val total: Int,
val hosts: Set<String>,
val downloadDirectory: String,
@Contextual val addedOn: LocalDateTime = LocalDateTime.now(),
@Contextual val addedOn: LocalDateTime,
var folderName: String,
var status: Status = Status.STOPPED,
var done: Int = 0,
var rank: Int = Int.MAX_VALUE,
var size: Long = -1,
var downloaded: Long = 0,
val previews: List<String>,
@@ -5,5 +5,6 @@ import kotlinx.serialization.Serializable
@Serializable
data class QueueState(
val running: Int,
val remaining: Int
val remaining: Int,
val rank: List<Rank> = emptyList()
)
@@ -0,0 +1,6 @@
package me.vripper.model
import kotlinx.serialization.Serializable
@Serializable
data class Rank(val postEntityId: Long, val rank: Long)
@@ -1,5 +1,7 @@
package me.vripper.services
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.time.sample
import kotlinx.serialization.json.Json
@@ -7,6 +9,9 @@ import me.vripper.entities.*
import me.vripper.event.*
import me.vripper.exception.PostParseException
import me.vripper.model.*
import me.vripper.services.download.DownloadService
import me.vripper.services.download.MovePosition
import me.vripper.services.download.QueueManager
import me.vripper.tasks.AddPostTask
import me.vripper.tasks.ThreadLookupTask
import me.vripper.utilities.ApplicationProperties
@@ -24,9 +29,11 @@ import kotlin.io.path.Path
import kotlin.io.path.exists
import kotlin.jvm.optionals.getOrNull
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
internal class AppEndpointService(
private val downloadService: DownloadService,
private val dataTransaction: DataTransaction,
private val queueManager: QueueManager,
private val dataAccessService: DataAccessService,
private val threadCacheService: ThreadCacheService,
private val settingsService: SettingsService,
private val vgAuthService: VGAuthService,
@@ -77,10 +84,10 @@ internal class AppEndpointService(
}
}
override suspend fun restartAll(posIds: List<Long>) {
override suspend fun restartAll(postEntityIds: List<Long>) {
lock.withLock {
downloadService.restartAll(posIds.filter { dataTransaction.exists(it) }
.map { dataTransaction.findPostByPostId(it) })
downloadService.restartAll(postEntityIds.filter { dataAccessService.exists(it) }
.map { dataAccessService.findPostByEntityId(it) })
}
}
@@ -88,29 +95,29 @@ internal class AppEndpointService(
taskRunner.submit(AddPostTask(posts))
}
override suspend fun stopAll(postIdList: List<Long>) {
override suspend fun stopAll(postEntityIds: List<Long>) {
lock.withLock {
downloadService.stop(postIdList)
downloadService.stop(postEntityIds)
}
}
override suspend fun remove(postIdList: List<Long>) {
override suspend fun remove(postEntityIds: List<Long>) {
lock.withLock {
downloadService.stop(postIdList)
dataTransaction.removeAll(postIdList)
downloadService.stop(postEntityIds)
dataAccessService.removeAll(postEntityIds)
}
}
override suspend fun clearCompleted(): List<Long> {
lock.withLock {
return dataTransaction.clearCompleted()
return dataAccessService.clearCompleted()
}
}
override suspend fun grab(threadId: Long): List<PostSelection> {
lock.withLock {
return try {
val thread = dataTransaction.findThreadByThreadId(threadId).orElseThrow {
val thread = dataAccessService.findThreadByThreadId(threadId).orElseThrow {
PostParseException(
String.format(
"Unable to find links for threadId = %s", threadId
@@ -148,42 +155,42 @@ internal class AppEndpointService(
override suspend fun threadRemove(threadIdList: List<Long>) {
lock.withLock {
threadIdList.forEach {
dataTransaction.removeThread(it)
dataAccessService.removeThread(it)
}
}
}
override suspend fun threadClear() {
lock.withLock {
dataTransaction.clearQueueLinks()
dataAccessService.clearQueueLinks()
}
}
override suspend fun renameToFirst(postIds: List<Long>) {
postIds.forEach { postId ->
dataTransaction
.findMetadataByPostId(postId)
override suspend fun renameToFirst(postEntityIds: List<Long>) {
postEntityIds.forEach { postEntityId ->
dataAccessService
.findMetadataByPostEntityId(postEntityId)
.map { it.data.resolvedNames }
.filter { it.isNotEmpty() }
.getOrNull()?.let { rename(postId, it.first()) }
.getOrNull()?.let { rename(postEntityId, it.first()) }
}
}
override suspend fun rename(postId: Long, newName: String) {
override suspend fun rename(postEntityId: Long, newName: String) {
taskRunner.submit {
synchronized(postId.toString().intern()) {
if (dataTransaction.exists(postId)) {
dataTransaction.findPostByPostId(postId).let { post ->
synchronized(postEntityId.toString().intern()) {
if (dataAccessService.exists(postEntityId)) {
dataAccessService.findPostByEntityId(postEntityId).let { post ->
if (Path(post.downloadDirectory, post.folderName).exists()) {
PathUtils.rename(
dataTransaction.findImagesByPostId(postId),
dataAccessService.findImagesByPostEntityId(postEntityId),
post.downloadDirectory,
post.folderName,
newName
)
}
post.folderName = PathUtils.sanitize(newName)
dataTransaction.updatePost(post)
dataAccessService.updatePost(post)
}
}
}
@@ -201,7 +208,7 @@ internal class AppEndpointService(
override fun onDeletePosts() =
EventBus.events.filterIsInstance(PostDeleteEvent::class).flatMapConcat { it.postIds.asFlow() }
EventBus.events.filterIsInstance(PostDeleteEvent::class).flatMapConcat { it.postEntityIds.asFlow() }
override fun onUpdateMetadata() =
@@ -209,12 +216,12 @@ internal class AppEndpointService(
override suspend fun findAllPosts(): List<Post> {
return dataTransaction.findAllPosts().map(::mapper)
return dataAccessService.findAllPosts().map(::mapper)
}
private fun mapper(postEntity: PostEntity): Post {
val metadata: Metadata? = dataTransaction.findMetadataByPostId(postEntity.postId).orElse(null)
val images = dataTransaction.findImagesByPostId(postEntity.postId)
val metadata: Metadata? = dataAccessService.findMetadataByPostEntityId(postEntity.id).orElse(null)
val images = dataAccessService.findImagesByPostEntityId(postEntity.id)
return Post(
postEntity.id,
postEntity.postTitle,
@@ -222,8 +229,8 @@ internal class AppEndpointService(
postEntity.forum,
postEntity.url,
postEntity.token,
postEntity.postId,
postEntity.threadId,
postEntity.vgPostId,
postEntity.vgThreadId,
postEntity.total,
postEntity.hosts,
postEntity.downloadDirectory,
@@ -231,7 +238,6 @@ internal class AppEndpointService(
postEntity.folderName,
postEntity.status,
postEntity.done,
postEntity.rank,
postEntity.size,
postEntity.downloaded,
images.take(4).map { it.thumbUrl },
@@ -240,17 +246,17 @@ internal class AppEndpointService(
)
}
override suspend fun findPost(postId: Long): Post {
return mapper(dataTransaction.findPostByPostId(postId))
override suspend fun findPost(postEntityId: Long): Post {
return mapper(dataAccessService.findPostByEntityId(postEntityId))
}
override suspend fun findImagesByPostId(postId: Long): List<Image> {
return dataTransaction.findImagesByPostId(postId)
override suspend fun findImagesByPostEntityId(postEntityId: Long): List<Image> {
return dataAccessService.findImagesByPostEntityId(postEntityId)
}
override fun onUpdateImagesByPostId(postId: Long): Flow<Image> =
override fun onUpdateImagesByPostEntityId(postEntityId: Long): Flow<Image> =
EventBus.events.filterIsInstance(ImageEvent::class).map {
it.imageEntities.filter { imageEntity: Image -> imageEntity.postId == postId }
it.imageEntities.filter { imageEntity: Image -> imageEntity.postEntityId == postEntityId }
}.filter { it.isNotEmpty() }.flatMapConcat { it.asFlow() }
override fun onUpdateImages(): Flow<Image> =
@@ -279,7 +285,7 @@ internal class AppEndpointService(
override suspend fun findAllThreads(): List<Thread> {
return dataTransaction.findAllThreads()
return dataAccessService.findAllThreads()
}
override fun onDownloadSpeed(): Flow<DownloadSpeed> =
@@ -291,6 +297,10 @@ internal class AppEndpointService(
override fun onQueueStateUpdate(): Flow<QueueState> =
EventBus.events.filterIsInstance(QueueStateEvent::class).map { it.queueState }
override suspend fun getQueueState(): QueueState {
return queueManager.getQueueState()
}
override fun onErrorCountUpdate(): Flow<ErrorCount> =
EventBus.events.filterIsInstance(ErrorCountEvent::class).map { it.errorCount }
@@ -310,6 +320,10 @@ internal class AppEndpointService(
override suspend fun getVersion(): String = ApplicationProperties.VERSION
override suspend fun move(postEntityId: Long, position: MovePosition) {
downloadService.move(postEntityId, position)
}
override suspend fun dbMigration(): String {
val conn = try {
@@ -319,31 +333,31 @@ internal class AppEndpointService(
return "Old database not found, nothing to do"
}
var postsCount = 0;
var threadCount = 0;
var postsCount = 0
var threadCount = 0
conn.use { conn ->
conn.prepareStatement("select * from post").use {
it.executeQuery().use {
while (it.next()) {
val done = it.getInt("DONE")
val hosts = it.getString("HOSTS")
val outputPath = it.getString("OUTPUT_PATH")
val postId = it.getLong("POST_ID")
val status = it.getString("STATUS")
val threadId = it.getLong("THREAD_ID")
val postTitle = it.getString("POST_TITLE")
val threadTitle = it.getString("THREAD_TITLE")
val forum = it.getString("FORUM")
val total = it.getInt("TOTAL")
val size = it.getLong("SIZE")
val downloaded = it.getLong("DOWNLOADED")
val url = it.getString("URL")
val token = it.getString("TOKEN")
val addedAt = it.getTimestamp("ADDED_AT")
val folderName = it.getString("FOLDER_NAME") ?: ""
conn.prepareStatement("select * from post").use { statement ->
statement.executeQuery().use { resultSet ->
while (resultSet.next()) {
val done = resultSet.getInt("DONE")
val hosts = resultSet.getString("HOSTS")
val outputPath = resultSet.getString("OUTPUT_PATH")
val postId = resultSet.getLong("POST_ID")
val status = resultSet.getString("STATUS")
val threadId = resultSet.getLong("THREAD_ID")
val postTitle = resultSet.getString("POST_TITLE")
val threadTitle = resultSet.getString("THREAD_TITLE")
val forum = resultSet.getString("FORUM")
val total = resultSet.getInt("TOTAL")
val size = resultSet.getLong("SIZE")
val downloaded = resultSet.getLong("DOWNLOADED")
val url = resultSet.getString("URL")
val token = resultSet.getString("TOKEN")
val addedAt = resultSet.getTimestamp("ADDED_AT")
val folderName = resultSet.getString("FOLDER_NAME") ?: ""
val exists = dataTransaction.exists(postId)
val exists = dataAccessService.existsPostId(postId)
if (exists) {
continue
}
@@ -354,8 +368,8 @@ internal class AppEndpointService(
forum = forum,
url = url,
token = token,
postId = postId,
threadId = threadId,
vgPostId = postId,
vgThreadId = threadId,
total = total,
hosts = hosts.split(";").dropLastWhile { it.isEmpty() }.toSet(),
downloadDirectory = outputPath,
@@ -384,7 +398,6 @@ internal class AppEndpointService(
val fileName = set.getString("FILENAME") ?: ""
ImageEntity(
postId = postId,
url = url,
thumbUrl = thumbUrl,
host = host,
@@ -398,7 +411,7 @@ internal class AppEndpointService(
}
}
dataTransaction.saveAndNotify(post, images)
val savedPost = dataAccessService.saveAndNotify(post, images)
//load meta
conn.prepareStatement("select * from metadata where post_id = ?").use { statement ->
@@ -407,10 +420,10 @@ internal class AppEndpointService(
if (set.next()) {
val data = Json.decodeFromString(set.getString("DATA")) as MetadataEntity.Data
val metadata = MetadataEntity(
postId = postId,
postIdRef = savedPost.id,
data = data
)
dataTransaction.saveMetadata(metadata)
dataAccessService.saveMetadata(metadata)
}
}
}
@@ -427,7 +440,7 @@ internal class AppEndpointService(
val threadId = set.getLong("THREAD_ID")
val title = set.getString("TITLE")
if (dataTransaction.findThreadByThreadId(threadId).isPresent) {
if (dataAccessService.findThreadByThreadId(threadId).isPresent) {
continue
}
@@ -438,7 +451,7 @@ internal class AppEndpointService(
total = total,
)
dataTransaction.save(threadEntity)
dataAccessService.save(threadEntity)
threadCount++
}
}
@@ -15,10 +15,9 @@ 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(
internal class DataAccessService(
private val settingsService: SettingsService,
private val postRepository: PostRepository,
private val imageRepository: ImageRepository,
@@ -27,35 +26,29 @@ internal class DataTransaction(
private val eventBus: EventBus,
) {
private val nextRank = AtomicInteger(transaction { getQueuePosition() }?.plus(1) ?: 0)
private val postEntityIdCache: LoadingCache<Long, PostEntity> =
Caffeine.newBuilder().expireAfterAccess(5, TimeUnit.MINUTES).build { id ->
transaction { postRepository.findById(id) }
}
private val postPostIdCache: LoadingCache<Long, PostEntity> =
Caffeine.newBuilder().expireAfterAccess(5, TimeUnit.MINUTES).build { id ->
transaction { postRepository.findByPostId(id) }
}
private fun save(postEntities: List<PostEntity>): List<PostEntity> {
return transaction { postRepository.save(postEntities) }
}
fun saveAndNotify(postEntity: PostEntity, images: List<ImageEntity>) {
fun saveAndNotify(postEntity: PostEntity, images: List<ImageEntity>): PostEntity {
val savedPost = transaction {
val savedPost =
postRepository.save(listOf(postEntity.copy(rank = nextRank.andIncrement))).first()
save(images.map { it.copy(postIdRef = savedPost.id) })
postRepository.save(listOf(postEntity)).first()
save(images.map { it.copy(postEntityId = savedPost.id) })
savedPost
}
eventBus.publishEvent(PostCreateEvent(listOf(savedPost)))
return savedPost
}
fun updatePosts(postEntities: List<PostEntity>) {
transaction { postRepository.update(postEntities) }
postEntities.forEach { postEntity ->
postPostIdCache.put(postEntity.postId, postEntity)
postEntityIdCache.put(postEntity.id, postEntity)
}
eventBus.publishEvent(PostUpdateEvent(postEntities))
@@ -63,7 +56,6 @@ internal class DataTransaction(
fun updatePost(postEntity: PostEntity) {
transaction { postRepository.update(postEntity) }
postPostIdCache.put(postEntity.postId, postEntity)
postEntityIdCache.put(postEntity.id, postEntity)
eventBus.publishEvent(PostUpdateEvent(listOf(postEntity)))
}
@@ -92,10 +84,14 @@ internal class DataTransaction(
eventBus.publishEvent(ImageEvent(listOf(imageEntity)))
}
fun exists(postId: Long): Boolean {
if (postPostIdCache.getIfPresent(postId) != null) {
fun exists(postEntityId: Long): Boolean {
if (postEntityIdCache.getIfPresent(postEntityId) != null) {
return true
}
return transaction { postRepository.existByPostEntityId(postEntityId) }
}
fun existsPostId(postId: Long): Boolean {
return transaction { postRepository.existByPostId(postId) }
}
@@ -106,13 +102,12 @@ internal class DataTransaction(
postTitle = postItem.title,
url = postItem.url,
token = postItem.securityToken,
postId = postItem.postId,
threadId = postItem.threadId,
vgPostId = postItem.postId,
vgThreadId = postItem.threadId,
total = postItem.imageCount,
hosts = postItem.hosts.map { "${it.first} (${it.second})" }.toSet(),
threadTitle = postItem.threadTitle,
forum = postItem.forum,
rank = nextRank.andIncrement,
downloadDirectory = PathUtils.calculateDownloadPath(
postItem.forum,
postItem.threadTitle,
@@ -124,7 +119,6 @@ internal class DataTransaction(
)
val imageEntities = postItem.imageItemList.mapIndexed { index, imageItem ->
ImageEntity(
postId = postItem.postId,
url = imageItem.mainLink,
thumbUrl = imageItem.thumbLink,
host = imageItem.host.hostId,
@@ -137,10 +131,10 @@ internal class DataTransaction(
val savedPosts = transaction {
val savedPosts = save(posts.keys.toList())
savedPosts.associateWith {
posts[it]!!
savedPosts.associateWith { postEntity ->
posts.entries.find { postItem -> postItem.key.vgPostId == postEntity.vgPostId }?.value ?: emptyList()
}.forEach { (key, value) ->
save(value.map { it.copy(postIdRef = key.id) })
save(value.map { it.copy(postEntityId = key.id) })
}
savedPosts
}
@@ -148,17 +142,13 @@ internal class DataTransaction(
return savedPosts
}
private fun getQueuePosition(): Int? {
return postRepository.findMaxRank()
}
private fun save(imageEntities: List<ImageEntity>) {
transaction { imageRepository.save(imageEntities) }
}
fun finishPost(postId: Long, automatic: Boolean = false) {
val post = findPostByPostId(postId)
val imagesInErrorStatus = findByPostIdAndIsError(post.postId)
fun finishPost(id: Long, automatic: Boolean = false) {
val post = findPostByEntityId(id)
val imagesInErrorStatus = findByIdAndIsError(id)
if (imagesInErrorStatus.isNotEmpty()) {
post.status = Status.ERROR
updatePost(post)
@@ -171,31 +161,29 @@ internal class DataTransaction(
transaction {
updatePost(post)
if (settingsService.settings.downloadSettings.clearCompleted && automatic) {
remove(listOf(post.postId))
remove(listOf(post.id))
}
}
}
}
}
private fun findByPostIdAndIsError(postId: Long): List<ImageEntity> {
return transaction { imageRepository.findByPostIdAndIsError(postId) }
private fun findByIdAndIsError(postEntityId: Long): List<ImageEntity> {
return transaction { imageRepository.findByPostEntityIdAndIsError(postEntityId) }
}
private fun remove(postIds: List<Long>) {
private fun remove(postEntityIds: List<Long>) {
transaction {
metadataRepository.deleteAllByPostId(postIds)
imageRepository.deleteAllByPostId(postIds)
postRepository.deleteAll(postIds)
sortPostsByRank()
metadataRepository.deleteAllByPostEntityId(postEntityIds)
imageRepository.deleteAllByPostEntityId(postEntityIds)
postRepository.deleteAll(postEntityIds)
}
postIds.forEach { postId ->
postPostIdCache.get(postId)?.let { postEntityIdCache.invalidate(it.id) }
postPostIdCache.invalidate(postId)
postEntityIds.forEach { postEntityId ->
postEntityIdCache.get(postEntityId)?.let { postEntityIdCache.invalidate(it.id) }
postEntityIdCache.invalidate(postEntityId)
}
eventBus.publishEvent(PostDeleteEvent(postIds = postIds))
eventBus.publishEvent(PostDeleteEvent(postEntityIds = postEntityIds))
eventBus.publishEvent(ErrorCountEvent(ErrorCount(countImagesInError())))
}
@@ -210,20 +198,20 @@ internal class DataTransaction(
return completed
}
fun removeAll(postIds: List<Long> = emptyList()) {
if (postIds.isNotEmpty()) {
remove(postIds)
fun removeAll(postEntityIds: List<Long> = emptyList()) {
if (postEntityIds.isNotEmpty()) {
remove(postEntityIds)
} else {
remove(findAllPosts().map(PostEntity::postId))
remove(findAllPosts().map(PostEntity::id))
}
}
fun stopImagesByPostIdAndIsNotCompleted(postId: Long) {
transaction { imageRepository.stopByPostIdAndIsNotCompleted(postId) }
fun stopImagesByPostEntityIdAndIsNotCompleted(postEntityId: Long) {
transaction { imageRepository.stopByPostEntityIdAndIsNotCompleted(postEntityId) }
}
fun stopImagesByPostIdAndIsNotCompleted() {
transaction { imageRepository.stopByPostIdAndIsNotCompleted() }
fun stopImagesByPostEntityIdAndIsNotCompleted() {
transaction { imageRepository.stopByPostEntityIdAndIsNotCompleted() }
}
fun saveMetadata(metadataEntity: MetadataEntity) {
@@ -236,19 +224,6 @@ internal class DataTransaction(
eventBus.publishEvent(ThreadClearEvent())
}
@Synchronized
fun sortPostsByRank() {
val postsToUpdate = mutableListOf<PostEntity>()
val postEntities = findAllPosts().sortedWith(Comparator.comparing(PostEntity::rank))
for (i in postEntities.indices) {
if (postEntities[i].rank != i) {
postsToUpdate.add(postEntities[i].copy(rank = i))
}
}
updatePosts(postsToUpdate)
nextRank.set(transaction { getQueuePosition() }?.plus(1) ?: 0)
}
fun setDownloadingToStopped() {
transaction { postRepository.setDownloadingToStopped() }
}
@@ -257,12 +232,12 @@ internal class DataTransaction(
return transaction { postRepository.findAll() }
}
fun findPostById(id: Long): PostEntity {
fun findPostByEntityId(id: Long): PostEntity {
return postEntityIdCache.get(id) ?: throw NoSuchElementException("Post with id = $id does not exist")
}
fun findImagesByPostId(postId: Long): List<ImageEntity> {
return transaction { imageRepository.findByPostId(postId) }
fun findImagesByPostEntityId(postEntityId: Long): List<ImageEntity> {
return transaction { imageRepository.findByPostEntityId(postEntityId) }
}
fun findImageById(id: Long): Optional<ImageEntity> {
@@ -279,27 +254,23 @@ internal class DataTransaction(
}
}
fun findByPostIdAndIsNotCompleted(postId: Long): List<ImageEntity> {
return transaction { imageRepository.findByPostIdAndIsNotCompleted(postId) }
fun findByPostEntityIdAndIsNotCompleted(postEntityId: Long): List<ImageEntity> {
return transaction { imageRepository.findByPostEntityIdAndIsNotCompleted(postEntityId) }
}
fun countImagesInError(): Int {
return transaction { imageRepository.countError() }
}
fun findPostByPostId(postId: Long): PostEntity {
return postPostIdCache.get(postId) ?: throw NoSuchElementException("Post with postId = $postId does not exist")
}
fun findThreadByThreadId(threadId: Long): Optional<ThreadEntity> {
return transaction { threadRepository.findByThreadId(threadId) }
}
fun findAllNonCompletedPostIds(): List<Long> {
return transaction { postRepository.findAllNonCompletedPostIds() }
fun findAllNonCompletedPostEntityIds(): List<Long> {
return transaction { postRepository.findAllNonCompletedPostEntityIds() }
}
fun findMetadataByPostId(postId: Long): Optional<MetadataEntity> {
return transaction { metadataRepository.findByPostId(postId) }
fun findMetadataByPostEntityId(postEntityId: Long): Optional<MetadataEntity> {
return transaction { metadataRepository.findByPostEntityId(postEntityId) }
}
}
@@ -1,499 +0,0 @@
package me.vripper.services
import dev.failsafe.Failsafe
import dev.failsafe.RetryPolicy
import dev.failsafe.function.CheckedRunnable
import kotlinx.coroutines.*
import me.vripper.entities.ImageEntity
import me.vripper.entities.PostEntity
import me.vripper.entities.Status
import me.vripper.event.ErrorCountEvent
import me.vripper.event.EventBus
import me.vripper.event.QueueStateEvent
import me.vripper.event.StoppedEvent
import me.vripper.exception.DownloadException
import me.vripper.exception.HostException
import me.vripper.host.DownloadedImage
import me.vripper.host.Host
import me.vripper.host.ImageMimeType
import me.vripper.model.ErrorCount
import me.vripper.model.QueueState
import me.vripper.model.Settings
import me.vripper.utilities.ApplicationProperties
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.PathUtils.getExtension
import me.vripper.utilities.PathUtils.getFileNameWithoutExtension
import me.vripper.utilities.PathUtils.sanitize
import me.vripper.utilities.downloadRunner
import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase
import org.apache.hc.client5.http.cookie.Cookie
import org.apache.hc.client5.http.impl.cookie.BasicClientCookie
import org.apache.hc.client5.http.protocol.HttpClientContext
import org.jetbrains.exposed.sql.transactions.transaction
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.time.Instant
import java.util.*
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.io.path.Path
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.pathString
import kotlin.io.path.readLines
internal class DownloadService(
private val settingsService: SettingsService,
private val dataTransaction: DataTransaction,
private val retryPolicyService: RetryPolicyService,
private val eventBus: EventBus
) {
private val maxPoolSize: Int = 24
private val log by LoggerDelegate()
private val running: MutableMap<Byte, MutableList<ImageDownloadRunnable>> = mutableMapOf()
private val pending: MutableMap<Byte, MutableList<ImageDownloadRunnable>> = mutableMapOf()
private val lock = ReentrantLock()
private val condition = lock.newCondition()
private var downloadMonitorThread: Thread? = null
internal class ImageDownloadContext(val imageEntity: ImageEntity, val settings: Settings) : KoinComponent {
private val log by LoggerDelegate()
init {
ApplicationProperties.VRIPPER_DIR
.listDirectoryEntries()
.filter { it.fileName.pathString.startsWith("cookies") }
.forEach { cookiesPath ->
loadCookies(cookiesPath).also { cookies ->
cookies.forEach { cookie ->
if (HTTPService.cookieStore.cookies.find { it.name == cookie.name } == null) {
log.info("Applying cookie: ${cookie.name}")
HTTPService.cookieStore.addCookie(cookie)
} else {
log.warn("Cookie already loaded: ${cookie.name}")
}
}
}
}
}
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val jobs = mutableListOf<Job>()
val httpContext: HttpClientContext = HttpClientContext.create().apply {
cookieStore = HTTPService.cookieStore
}
private fun loadCookies(cookiesPath: Path): List<Cookie> {
return cookiesPath.readLines()
.map { it.trim() }
.filter { it.isNotBlank() }
.filter { !it.startsWith("#") }
.map { line ->
val cookieComponents = line.split("\t")
BasicClientCookie(cookieComponents[5], cookieComponents[6]).apply {
domain = cookieComponents[0]
isHttpOnly = cookieComponents[1].toBoolean()
path = cookieComponents[2]
isSecure = cookieComponents[3].toBoolean()
setExpiryDate(Instant.ofEpochSecond(cookieComponents[4].toLong()))
}
}.also {
log.info("Found ${it.size} cookies in $cookiesPath")
}
}
val requests = mutableListOf<HttpUriRequestBase>()
val postId = imageEntity.postIdRef
fun cancelCoroutines() {
runBlocking {
coroutineScope.cancel()
jobs.forEach { job -> job.cancelAndJoin() }
}
}
fun launchCoroutine(block: suspend CoroutineScope.() -> Unit): Job {
return coroutineScope.launch(block = block).also { job -> jobs.add(job) }
}
}
internal class ImageDownloadRunnable(
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 vgauthService: VGAuthService by inject()
private val hosts: List<Host> = getKoin().getAll()
var completed = false
var stopped = false
private lateinit var context: ImageDownloadContext
fun download() {
try {
imageEntity.status = Status.DOWNLOADING
imageEntity.downloaded = 0
dataTransaction.updateImage(imageEntity)
synchronized(imageEntity.postId.toString().intern()) {
val post = dataTransaction.findPostById(context.postId)
if (post.status != Status.DOWNLOADING) {
post.status = Status.DOWNLOADING
dataTransaction.updatePost(post)
vgauthService.leaveThanks(post)
}
}
log.debug("Getting image url and name from ${imageEntity.url} using ${imageEntity.host}")
val host = hosts.first { it.isSupported(imageEntity.url) }
val downloadedImage = host.downloadInternal(imageEntity, context)
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)
val downloadDirectory = Path(post.downloadDirectory, post.folderName).pathString
checkImageTypeAndRename(
downloadDirectory, downloadedImage, imageEntity.index
)
if (imageEntity.downloaded == imageEntity.size && imageEntity.size > 0) {
imageEntity.status = Status.FINISHED
post.done += 1
post.downloaded += imageEntity.size
dataTransaction.updatePost(post)
} else {
imageEntity.status = Status.ERROR
}
dataTransaction.updateImage(imageEntity)
}
} catch (e: Exception) {
if (stopped) {
return
}
imageEntity.status = Status.ERROR
dataTransaction.updateImage(imageEntity)
throw DownloadException(e)
}
}
@Throws(HostException::class)
private fun checkImageTypeAndRename(
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"
ImageMimeType.IMAGE_JPEG -> "JPG"
ImageMimeType.IMAGE_PNG -> "PNG"
ImageMimeType.IMAGE_WEBP -> "WEBP"
}
val filename =
if (existingExtension.isBlank()) "${sanitize(downloadedImage.name)}.$extension" else "${
sanitize(
fileNameWithoutExtension
)
}.$extension"
try {
val downloadDestinationFolder = Path.of(downloadDirectory)
Files.createDirectories(downloadDestinationFolder)
val finalFilename = "${
if (settings.downloadSettings.forceOrder) String.format(
"%03d_", index + 1
) else ""
}$filename"
imageEntity.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 {
try {
Files.delete(downloadedImage.path)
} catch (_: IOException) {
}
}
}
override fun run() {
context = ImageDownloadContext(imageEntity, settings)
try {
if (stopped) {
return
}
download()
} finally {
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
val that = other as ImageDownloadRunnable
return imageEntity.id == that.imageEntity.id
}
override fun hashCode(): Int {
return Objects.hash(imageEntity.id)
}
}
fun init() {
downloadMonitorThread?.interrupt()
downloadMonitorThread = Thread.ofVirtual().name("Download Monitor").unstarted(Runnable {
log.info("Scheduler have been initialized")
val accepted: MutableList<ImageDownloadRunnable> = mutableListOf()
val candidates: MutableList<ImageDownloadRunnable> = mutableListOf()
while (!Thread.currentThread().isInterrupted) {
lock.withLock {
candidates.addAll(getCandidates(candidateCount()))
candidates.forEach {
if (canRun(it.imageEntity.host)) {
accepted.add(it)
running[it.imageEntity.host]!!.add(it)
log.debug("${it.imageEntity.url} accepted to run")
}
}
accepted.forEach {
pending[it.imageEntity.host]?.remove(it)
scheduleForDownload(it)
}
accepted.clear()
candidates.clear()
try {
condition.await()
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
}
}
log.info("Scheduler have been shutdown")
})
downloadMonitorThread?.start()
}
fun halt() {
downloadMonitorThread?.interrupt()
}
fun stop(postIds: List<Long> = emptyList()) {
if (postIds.isNotEmpty()) {
stopInternal(postIds)
eventBus.publishEvent(StoppedEvent(postIds))
} else {
stopAll()
eventBus.publishEvent(StoppedEvent(listOf(-1)))
}
}
fun restartAll(postEntityIds: List<PostEntity> = emptyList()) {
if (postEntityIds.isNotEmpty()) {
restart(postEntityIds.associateWith { dataTransaction.findByPostIdAndIsNotCompleted(it.postId) })
} else {
restart(
dataTransaction.findAllPosts()
.associateWith { dataTransaction.findByPostIdAndIsNotCompleted(it.postId) })
}
}
private fun restart(posts: Map<PostEntity, List<ImageEntity>>) {
lock.withLock {
val toProcess = mutableMapOf<PostEntity, List<ImageEntity>>()
for ((post, images) in posts) {
if (images.isEmpty()) {
continue
}
if (isPending(post.postId)) {
continue
}
toProcess[post] = images
}
toProcess.forEach { (post, images) ->
post.status = Status.PENDING
images.forEach { image ->
with(image) {
this.status = Status.PENDING
this.downloaded = 0
}
}
}
transaction {
dataTransaction.updatePosts(
toProcess.keys.toList()
)
dataTransaction.updateImages(
toProcess.values.flatten()
)
}
toProcess.entries.forEach { (post, images) ->
images.forEach { image ->
log.debug("Enqueuing a job for ${image.url}")
val imageDownloadRunnable = ImageDownloadRunnable(
image, post.rank, settingsService.settings
)
pending.computeIfAbsent(
image.host
) { mutableListOf() }
pending[image.host]!!.add(imageDownloadRunnable)
}
}
condition.signal()
}
}
private fun isPending(postId: Long): Boolean {
return pending.values.flatten().any { it.imageEntity.postId == postId }
}
private fun isRunning(postId: Long): Boolean {
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.completed } > 0) {
Thread.sleep(100)
}
dataTransaction.findAllNonCompletedPostIds().forEach {
dataTransaction.stopImagesByPostIdAndIsNotCompleted(it)
dataTransaction.finishPost(it)
}
}
}
private fun stopInternal(postIds: List<Long>) {
lock.withLock {
for (postId in postIds) {
pending.values.forEach { pending ->
pending.removeIf { it.imageEntity.postId == postId }
}
running.values.flatten()
.filter { p: ImageDownloadRunnable -> p.imageEntity.postId == postId }
.forEach { obj: ImageDownloadRunnable -> obj.stop() }
while (running.values.flatten()
.count { !it.completed && it.imageEntity.postId == postId } > 0
) {
Thread.sleep(100)
}
}
postIds.forEach {
dataTransaction.stopImagesByPostIdAndIsNotCompleted(it)
dataTransaction.finishPost(it)
}
}
}
private fun canRun(host: Byte): Boolean {
val totalRunning = running.values.sumOf { it.size }
return (running[host]!!.size < settingsService.settings.connectionSettings.maxConcurrentPerHost && if (settingsService.settings.connectionSettings.maxGlobalConcurrent == 0) totalRunning < maxPoolSize else totalRunning < settingsService.settings.connectionSettings.maxGlobalConcurrent)
}
private fun candidateCount(): Map<Byte, Int> {
val map: MutableMap<Byte, Int> = mutableMapOf()
Host.Companion.getHosts().values.forEach { host: Byte ->
val imageDownloadRunnableList: List<ImageDownloadRunnable> = running.computeIfAbsent(
host
) { mutableListOf() }
val count: Int =
settingsService.settings.connectionSettings.maxConcurrentPerHost - imageDownloadRunnableList.size
log.debug("Download slots for $host: $count")
map[host] = count
}
return map
}
private fun getCandidates(candidateCount: Map<Byte, Int>): List<ImageDownloadRunnable> {
val hostIntegerMap: MutableMap<Byte, Int> = candidateCount.toMutableMap()
val candidates: MutableList<ImageDownloadRunnable> = mutableListOf()
hosts@ for (host in pending.keys) {
val list: List<ImageDownloadRunnable> =
pending[host]!!.sortedWith(Comparator.comparingInt<ImageDownloadRunnable> { it.postRank }
.thenComparingInt { it.imageEntity.index })
for (imageDownloadRunnable in list) {
val count = hostIntegerMap[host] ?: 0
if (count > 0) {
candidates.add(imageDownloadRunnable)
hostIntegerMap[host] = count - 1
} else {
continue@hosts
}
}
}
return candidates
}
private fun scheduleForDownload(imageDownloadRunnable: ImageDownloadRunnable) {
log.debug("Scheduling a job for ${imageDownloadRunnable.imageEntity.url}")
eventBus.publishEvent(QueueStateEvent(QueueState(runningCount(), pendingCount())))
Failsafe.with<Any, RetryPolicy<Any>>(retryPolicyService.buildRetryPolicy("Failed to download ${imageDownloadRunnable.imageEntity.url}: "))
.with(downloadRunner)
.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.imageEntity
running[image.host]!!.remove(imageDownloadRunnable)
if (!isPending(image.postId) && !isRunning(
image.postId
) && !imageDownloadRunnable.stopped
) {
dataTransaction.finishPost(image.postId, true)
}
condition.signal()
}
}
private fun pendingCount(): Int {
return pending.values.sumOf { it.size }
}
private fun runningCount(): Int {
return running.values.sumOf { it.size }
}
}
@@ -2,22 +2,23 @@ package me.vripper.services
import kotlinx.coroutines.flow.Flow
import me.vripper.model.*
import me.vripper.services.download.MovePosition
interface IAppEndpointService {
suspend fun scanLinks(postLinks: String)
suspend fun restartAll(posIds: List<Long> = listOf())
suspend fun remove(postIdList: List<Long>)
suspend fun stopAll(postIdList: List<Long> = emptyList())
suspend fun restartAll(postEntityIds: List<Long> = listOf())
suspend fun remove(postEntityIds: List<Long>)
suspend fun stopAll(postEntityIds: List<Long> = emptyList())
suspend fun clearCompleted(): List<Long>
suspend fun findPost(postId: Long): Post
suspend fun findPost(postEntityId: Long): Post
suspend fun findAllPosts(): List<Post>
suspend fun rename(postId: Long, newName: String)
suspend fun rename(postEntityId: Long, newName: String)
fun onNewPosts(): Flow<Post>
fun onUpdatePosts(): Flow<Post>
fun onDeletePosts(): Flow<Long>
fun onUpdateMetadata(): Flow<Metadata>
suspend fun findImagesByPostId(postId: Long): List<Image>
fun onUpdateImagesByPostId(postId: Long): Flow<Image>
suspend fun findImagesByPostEntityId(postEntityId: Long): List<Image>
fun onUpdateImagesByPostEntityId(postEntityId: Long): Flow<Image>
fun onUpdateImages(): Flow<Image>
fun onStopped(): Flow<Long>
fun onNewLog(): Flow<LogEntry>
@@ -32,6 +33,7 @@ interface IAppEndpointService {
suspend fun download(posts: List<ThreadPostId>)
fun onDownloadSpeed(): Flow<DownloadSpeed>
fun onVGUserUpdate(): Flow<String>
suspend fun getQueueState(): QueueState
fun onQueueStateUpdate(): Flow<QueueState>
fun onErrorCountUpdate(): Flow<ErrorCount>
fun onTasksRunning(): Flow<Boolean>
@@ -41,8 +43,9 @@ interface IAppEndpointService {
fun onUpdateSettings(): Flow<Settings>
suspend fun loggedInUser(): String
suspend fun getVersion(): String
suspend fun renameToFirst(postIds: List<Long>)
suspend fun renameToFirst(postEntityIds: List<Long>)
suspend fun dbMigration(): String
suspend fun initLogger()
suspend fun move(postEntityId: Long, position: MovePosition)
fun connectionState(): String
}
@@ -1,10 +1,11 @@
package me.vripper.services
import me.vripper.entities.PostEntity
import me.vripper.tasks.FetchMetadataTask
import me.vripper.utilities.taskRunner
internal class MetadataService(
private val dataTransaction: DataTransaction,
private val dataAccessService: DataAccessService,
private val settingsService: SettingsService,
) {
@@ -12,17 +13,17 @@ internal class MetadataService(
if (!settingsService.settings.viperSettings.fetchMetadata) {
return
}
dataTransaction.findAllPosts().filter { dataTransaction.findMetadataByPostId(it.postId).isEmpty }
.map { it.postId }.forEach(::fetchMetadata)
dataAccessService.findAllPosts().filter { dataAccessService.findMetadataByPostEntityId(it.id).isEmpty }
.map { it }.forEach(::fetchMetadata)
}
fun fetchMetadata(postId: Long) {
fun fetchMetadata(post: PostEntity) {
if (!settingsService.settings.viperSettings.fetchMetadata) {
return
}
taskRunner.submit(
FetchMetadataTask(
postId
post
)
)
}
@@ -7,14 +7,14 @@ import me.vripper.vgapi.ThreadLookupAPIParser
import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeUnit
internal class ThreadCacheService(val dataTransaction: DataTransaction) {
internal class ThreadCacheService(val dataAccessService: DataAccessService) {
private val cache: LoadingCache<Long, ThreadItem> =
Caffeine.newBuilder().expireAfterWrite(20, TimeUnit.MINUTES).build { threadId ->
val threadItem = ThreadLookupAPIParser(threadId).parse()
dataTransaction.findThreadByThreadId(threadItem.threadId).ifPresent {
dataAccessService.findThreadByThreadId(threadItem.threadId).ifPresent {
if (threadItem.postItemList.isNotEmpty()) {
dataTransaction.update(it.copy(total = threadItem.postItemList.size))
dataAccessService.update(it.copy(total = threadItem.postItemList.size))
}
}
threadItem
@@ -0,0 +1,167 @@
package me.vripper.services.download
import me.vripper.entities.ImageEntity
import me.vripper.entities.PostEntity
import me.vripper.entities.Status
import me.vripper.event.EventBus
import me.vripper.event.StoppedEvent
import me.vripper.services.DataAccessService
import me.vripper.services.SettingsService
import me.vripper.services.download.SharedLock.downloadManagerCondition
import me.vripper.services.download.SharedLock.downloadManagerLock
import me.vripper.utilities.LoggerDelegate
import org.jetbrains.exposed.sql.transactions.transaction
import kotlin.concurrent.withLock
import kotlin.math.min
internal class DownloadService(
private val settingsService: SettingsService,
private val dataAccessService: DataAccessService,
private val queueManager: QueueManager,
private val eventBus: EventBus
) {
private val maxPoolSize: Int = 24
private val log by LoggerDelegate()
private var downloadMonitorThread: Thread? = null
fun init() {
downloadMonitorThread?.interrupt()
downloadMonitorThread = Thread.ofVirtual().name("Download Monitor").unstarted(Runnable {
log.info("DownloadManager have been initialized")
while (!Thread.currentThread().isInterrupted) {
downloadManagerLock.withLock {
val candidates = getCandidates()
val canRunPerHost = canRun(candidates.keys)
val accepted = candidates.entries.flatMap {
it.value.take(canRunPerHost[it.key] ?: 0)
}
queueManager.accept(accepted)
try {
downloadManagerCondition.await()
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
}
}
log.info("Scheduler have been shutdown")
})
downloadMonitorThread?.start()
}
fun halt() {
downloadMonitorThread?.interrupt()
}
fun stop(postEntityIds: List<Long> = emptyList()) {
if (postEntityIds.isNotEmpty()) {
stopInternal(postEntityIds)
eventBus.publishEvent(StoppedEvent(postEntityIds))
} else {
stopAll()
eventBus.publishEvent(StoppedEvent(listOf(-1)))
}
}
fun restartAll(postEntities: List<PostEntity> = emptyList()) {
if (postEntities.isNotEmpty()) {
restart(postEntities.associateWith { dataAccessService.findByPostEntityIdAndIsNotCompleted(it.id) })
} else {
restart(
dataAccessService.findAllPosts()
.associateWith { dataAccessService.findByPostEntityIdAndIsNotCompleted(it.id) })
}
}
fun move(postEntityId: Long, position: MovePosition) {
queueManager.move(postEntityId, position)
}
private fun restart(posts: Map<PostEntity, List<ImageEntity>>) {
downloadManagerLock.withLock {
val toProcess = mutableMapOf<PostEntity, List<ImageEntity>>()
for ((post, images) in posts) {
if (images.isEmpty()) {
continue
}
if (queueManager.isPending(post.id)) {
continue
}
toProcess[post] = images
}
toProcess.forEach { (post, images) ->
post.status = Status.PENDING
images.forEach { image ->
with(image) {
this.status = Status.PENDING
this.downloaded = 0
}
}
}
transaction {
dataAccessService.updatePosts(
toProcess.keys.toList()
)
dataAccessService.updateImages(
toProcess.values.flatten()
)
}
toProcess.entries.sortedBy { it.key.addedOn }.forEach { (post, images) ->
images.map { image ->
log.debug("Enqueuing a job for ${post.url}")
ImageQueueElement(
image.id, image.postEntityId, image.host
)
}.also { queueManager.addPending(it) }
}
downloadManagerCondition.signal()
}
}
private fun stopAll() {
downloadManagerLock.withLock {
queueManager.clearPending()
queueManager.clearRunning()
dataAccessService.findAllNonCompletedPostEntityIds().forEach {
dataAccessService.stopImagesByPostEntityIdAndIsNotCompleted(it)
dataAccessService.finishPost(it)
}
}
}
private fun stopInternal(postEntityIds: List<Long>) {
downloadManagerLock.withLock {
postEntityIds.forEach {
queueManager.clearPending(it)
queueManager.clearRunning(it)
dataAccessService.stopImagesByPostEntityIdAndIsNotCompleted(it)
dataAccessService.finishPost(it)
}
}
}
private fun canRun(hosts: Set<Byte>): Map<Byte, Int> {
val runningSnapshot = queueManager.runningTotalCount().toMutableMap()
return hosts.associateWith {
val totalRunning = runningSnapshot.values.sum()
val canRunGlobally =
if (settingsService.settings.connectionSettings.maxGlobalConcurrent == 0) maxPoolSize - totalRunning else settingsService.settings.connectionSettings.maxGlobalConcurrent - totalRunning
val canRunPerHost =
settingsService.settings.connectionSettings.maxConcurrentPerHost - (runningSnapshot[it] ?: 0)
val min = min(canRunGlobally, canRunPerHost)
val actual = if (min < 0) 0 else min
runningSnapshot[it] = (runningSnapshot[it] ?: 0) + actual
actual
}
}
private fun getCandidates(): Map<Byte, List<ImageQueueElement>> {
return queueManager.pending().groupBy { it.host }
}
}
@@ -0,0 +1,217 @@
package me.vripper.services.download
import dev.failsafe.function.CheckedRunnable
import kotlinx.coroutines.*
import me.vripper.entities.ImageEntity
import me.vripper.entities.Status
import me.vripper.exception.DownloadException
import me.vripper.exception.HostException
import me.vripper.host.DownloadedImage
import me.vripper.host.Host
import me.vripper.host.ImageMimeType
import me.vripper.model.Settings
import me.vripper.services.DataAccessService
import me.vripper.services.HTTPService
import me.vripper.services.VGAuthService
import me.vripper.utilities.ApplicationProperties
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.PathUtils.getExtension
import me.vripper.utilities.PathUtils.getFileNameWithoutExtension
import me.vripper.utilities.PathUtils.sanitize
import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase
import org.apache.hc.client5.http.cookie.Cookie
import org.apache.hc.client5.http.impl.cookie.BasicClientCookie
import org.apache.hc.client5.http.protocol.HttpClientContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.time.Instant
import java.util.*
import kotlin.io.path.Path
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.pathString
import kotlin.io.path.readLines
internal class ImageDownloadRunnable(
imageEntity: ImageEntity, settings: Settings
) : KoinComponent, CheckedRunnable {
class Context(val imageEntity: ImageEntity, val settings: Settings) {
private val log by LoggerDelegate()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val jobs = mutableListOf<Job>()
val httpContext: HttpClientContext = HttpClientContext.create().apply {
cookieStore = HTTPService.cookieStore
}
val requests = mutableListOf<HttpUriRequestBase>()
init {
ApplicationProperties.VRIPPER_DIR.listDirectoryEntries()
.filter { it.fileName.pathString.startsWith("cookies") }.forEach { cookiesPath ->
loadCookies(cookiesPath).also { cookies ->
cookies.forEach { cookie ->
if (HTTPService.cookieStore.cookies.find { it.name == cookie.name } == null) {
log.info("Applying cookie: ${cookie.name}")
HTTPService.cookieStore.addCookie(cookie)
} else {
log.warn("Cookie already loaded: ${cookie.name}")
}
}
}
}
}
private fun loadCookies(cookiesPath: Path): List<Cookie> {
return cookiesPath.readLines().map { it.trim() }.filter { it.isNotBlank() }.filter { !it.startsWith("#") }
.map { line ->
val cookieComponents = line.split("\t")
BasicClientCookie(cookieComponents[5], cookieComponents[6]).apply {
domain = cookieComponents[0]
isHttpOnly = cookieComponents[1].toBoolean()
path = cookieComponents[2]
isSecure = cookieComponents[3].toBoolean()
setExpiryDate(Instant.ofEpochSecond(cookieComponents[4].toLong()))
}
}.also {
log.info("Found ${it.size} cookies in $cookiesPath")
}
}
fun clear() {
runBlocking {
requests.forEach { it.abort() }
coroutineScope.cancel()
jobs.forEach { job -> job.cancelAndJoin() }
jobs.clear()
}
}
fun launchCoroutine(block: suspend CoroutineScope.() -> Unit): Job {
return coroutineScope.launch(block = block).also { job -> jobs.add(job) }
}
}
private val log by LoggerDelegate()
private val dataAccessService: DataAccessService by inject()
private val vgAuthService: VGAuthService by inject()
private val hosts: List<Host> = getKoin().getAll()
val context = Context(imageEntity, settings)
var completed = false
var stopped = false
fun download() {
try {
context.imageEntity.status = Status.DOWNLOADING
context.imageEntity.downloaded = 0
dataAccessService.updateImage(context.imageEntity)
synchronized(context.imageEntity.postEntityId.toString().intern()) {
val post = dataAccessService.findPostByEntityId(context.imageEntity.postEntityId)
if (post.status != Status.DOWNLOADING) {
post.status = Status.DOWNLOADING
dataAccessService.updatePost(post)
vgAuthService.leaveThanks(post)
}
}
log.debug("Getting image url and name from ${context.imageEntity.url} using ${context.imageEntity.host}")
val host = hosts.first { it.isSupported(context.imageEntity.url) }
val downloadedImage = host.downloadInternal(context)
log.debug("Resolved name for ${context.imageEntity.url}: ${downloadedImage.name}")
log.debug("Downloaded image {} to {}", context.imageEntity.url, downloadedImage.path)
synchronized(context.imageEntity.postEntityId.toString().intern()) {
val post = dataAccessService.findPostByEntityId(context.imageEntity.postEntityId)
val downloadDirectory = Path(post.downloadDirectory, post.folderName).pathString
checkImageTypeAndRename(
downloadDirectory, downloadedImage, context.imageEntity.index
)
if (context.imageEntity.downloaded == context.imageEntity.size && context.imageEntity.size > 0) {
context.imageEntity.status = Status.FINISHED
post.done += 1
post.downloaded += context.imageEntity.size
dataAccessService.updatePost(post)
} else {
context.imageEntity.status = Status.ERROR
}
dataAccessService.updateImage(context.imageEntity)
}
} catch (e: Exception) {
if (stopped) {
return
}
context.imageEntity.status = Status.ERROR
dataAccessService.updateImage(context.imageEntity)
throw DownloadException(e)
}
}
private fun checkImageTypeAndRename(
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"
ImageMimeType.IMAGE_JPEG -> "JPG"
ImageMimeType.IMAGE_PNG -> "PNG"
ImageMimeType.IMAGE_WEBP -> "WEBP"
}
val filename = if (existingExtension.isBlank()) "${sanitize(downloadedImage.name)}.$extension" else "${
sanitize(
fileNameWithoutExtension
)
}.$extension"
try {
val downloadDestinationFolder = Path.of(downloadDirectory)
Files.createDirectories(downloadDestinationFolder)
val finalFilename = "${
if (context.settings.downloadSettings.forceOrder) String.format(
"%03d_", index + 1
) else ""
}$filename"
context.imageEntity.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 {
try {
Files.delete(downloadedImage.path)
} catch (_: IOException) {
}
}
}
override fun run() {
try {
if (stopped) {
return
}
download()
} finally {
completed = true
context.clear()
}
}
fun stop() {
stopped = true
context.clear()
dataAccessService.updateImage(context.imageEntity)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
val that = other as ImageDownloadRunnable
return context.imageEntity.id == that.context.imageEntity.id
}
override fun hashCode(): Int {
return Objects.hash(context.imageEntity.id)
}
}
@@ -0,0 +1,3 @@
package me.vripper.services.download
internal data class ImageQueueElement(val imageEntityId: Long, val postEntityId: Long, val host: Byte)
@@ -0,0 +1,5 @@
package me.vripper.services.download
enum class MovePosition {
UP, DOWN, TOP, BOTTOM
}
@@ -0,0 +1,172 @@
package me.vripper.services.download
import dev.failsafe.Failsafe
import dev.failsafe.RetryPolicy
import me.vripper.entities.Status
import me.vripper.event.ErrorCountEvent
import me.vripper.event.EventBus
import me.vripper.event.QueueStateEvent
import me.vripper.host.Host
import me.vripper.model.ErrorCount
import me.vripper.model.QueueState
import me.vripper.model.Rank
import me.vripper.services.DataAccessService
import me.vripper.services.RetryPolicyService
import me.vripper.services.SettingsService
import me.vripper.services.download.SharedLock.downloadManagerCondition
import me.vripper.services.download.SharedLock.downloadManagerLock
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.downloadRunner
import java.util.*
import kotlin.concurrent.withLock
internal class QueueManager(
private val dataAccessService: DataAccessService,
private val hosts: List<Host>,
private val settingsService: SettingsService,
private val retryPolicyService: RetryPolicyService,
private val eventBus: EventBus
) {
private val log by LoggerDelegate()
private val running: MutableList<ImageDownloadRunnable> = mutableListOf()
private val pending: MutableList<MutableList<ImageQueueElement>> = mutableListOf()
fun addPending(imageQueueElementList: List<ImageQueueElement>) {
pending.add(imageQueueElementList.toMutableList())
reportQueueState()
}
fun clearPending(postEntityId: Long? = null) {
val toProcess = if (postEntityId != null) pending.flatten()
.filter { it.postEntityId == postEntityId } else pending.flatten()
pending.forEach { it.removeAll(toProcess) }
pending.removeIf { it.isEmpty() }
reportQueueState()
}
fun runningTotalCount(): Map<Byte, Int> {
return hosts.associate { host ->
Pair(
host.hostId, running.count { it.context.imageEntity.host == host.hostId })
}
}
fun clearRunning(postEntityId: Long? = null) {
val toProcess =
if (postEntityId != null) running.filter { it.context.imageEntity.postEntityId == postEntityId } else running
toProcess.forEach { it.stop() }
while (toProcess.count { !it.completed } > 0) {
Thread.sleep(100)
}
running.removeAll(toProcess)
}
fun clearRunningRunnable(imageId: Long) {
running.removeIf { it.context.imageEntity.id == imageId }
}
fun accept(accepted: List<ImageQueueElement>) {
pending.forEach {
it.removeAll(accepted)
}
pending.removeIf { it.isEmpty() }
accepted.map {
ImageDownloadRunnable(
dataAccessService.findImageById(it.imageEntityId).orElseThrow(), settingsService.settings.copy()
)
}.forEach {
launch(it)
running.add(it)
}
reportQueueState()
}
fun pending(): List<ImageQueueElement> {
return pending.flatten()
}
fun isPending(postEntityId: Long): Boolean {
return pending.flatten().any { it.postEntityId == postEntityId }
}
fun isRunning(postEntityId: Long): Boolean {
return running.any { it.context.imageEntity.postEntityId == postEntityId }
}
fun move(postEntityId: Long, position: MovePosition) {
downloadManagerLock.withLock {
val index = pending.indexOfFirst { it.first().postEntityId == postEntityId }
when (position) {
MovePosition.UP -> {
val newIndex = if (index > 0) index - 1 else index
Collections.swap(pending, index, newIndex)
}
MovePosition.DOWN -> {
val newIndex = if (index < pending.size - 1) index + 1 else index
Collections.swap(pending, index, newIndex)
}
MovePosition.TOP -> {
if (index > 0) {
val element = pending.removeAt(index)
pending.addFirst(element)
}
}
MovePosition.BOTTOM -> {
if (index < pending.size - 1) {
val element = pending.removeAt(pending.size - 1)
pending.addLast(element)
}
}
}
}
reportQueueState()
}
fun getQueueState(): QueueState {
val ranks = downloadManagerLock.withLock {
pending.mapIndexed { index, elements -> Rank(elements.first().postEntityId, index + 1L) }
}
return QueueState(running.size, pending().size, ranks)
}
private fun launch(runnable: ImageDownloadRunnable) {
log.debug("Scheduling a job for ${runnable.context.imageEntity.url}")
reportQueueState()
Failsafe.with<Any, RetryPolicy<Any>>(retryPolicyService.buildRetryPolicy("Failed to download ${runnable.context.imageEntity.url}: "))
.with(downloadRunner).onFailure {
log.error(
"Failed to download ${runnable.context.imageEntity.url} after ${it.attemptCount} tries",
it.exception
)
val image = runnable.context.imageEntity
image.status = Status.ERROR
dataAccessService.updateImage(image)
}.onComplete {
afterJobFinish(runnable)
reportQueueState()
eventBus.publishEvent(ErrorCountEvent(ErrorCount(dataAccessService.countImagesInError())))
log.debug(
"Finished downloading ${runnable.context.imageEntity.url}"
)
}.runAsync(runnable)
}
private fun afterJobFinish(imageDownloadRunnable: ImageDownloadRunnable) {
downloadManagerLock.withLock {
val image = imageDownloadRunnable.context.imageEntity
clearRunningRunnable(imageDownloadRunnable.context.imageEntity.id)
if (!isPending(image.postEntityId) && !isRunning(image.postEntityId) && !imageDownloadRunnable.stopped) {
dataAccessService.finishPost(image.postEntityId, true)
}
downloadManagerCondition.signal()
}
}
private fun reportQueueState() {
val queueState = getQueueState()
eventBus.publishEvent(QueueStateEvent(queueState))
}
}
@@ -0,0 +1,9 @@
package me.vripper.services.download
import java.util.concurrent.locks.Condition
import java.util.concurrent.locks.ReentrantLock
object SharedLock {
val downloadManagerLock = ReentrantLock()
val downloadManagerCondition: Condition = downloadManagerLock.newCondition()
}
@@ -1,7 +1,11 @@
package me.vripper.tasks
import me.vripper.model.ThreadPostId
import me.vripper.services.*
import me.vripper.services.DataAccessService
import me.vripper.services.MetadataService
import me.vripper.services.SettingsService
import me.vripper.services.ThreadCacheService
import me.vripper.services.download.DownloadService
import me.vripper.utilities.LoggerDelegate
import me.vripper.vgapi.PostItem
import me.vripper.vgapi.PostLookupAPIParser
@@ -10,7 +14,7 @@ import org.koin.core.component.inject
internal class AddPostTask(private val items: List<ThreadPostId>) : KoinComponent, Runnable {
private val log by LoggerDelegate()
private val dataTransaction: DataTransaction by inject()
private val dataAccessService: DataAccessService by inject()
private val settingsService: SettingsService by inject()
private val downloadService: DownloadService by inject()
private val threadCacheService: ThreadCacheService by inject()
@@ -21,7 +25,7 @@ internal class AddPostTask(private val items: List<ThreadPostId>) : KoinComponen
Tasks.increment()
val toProcess = mutableListOf<PostItem>()
for ((threadId, postId) in items) {
if (dataTransaction.exists(postId)) {
if (dataAccessService.existsPostId(postId)) {
log.info("Post $postId already loaded")
continue
}
@@ -62,13 +66,13 @@ internal class AddPostTask(private val items: List<ThreadPostId>) : KoinComponen
}
val posts = try {
dataTransaction.newPosts(toProcess.toList())
dataAccessService.newPosts(toProcess.toList())
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
posts.forEach {
metadataService.fetchMetadata(it.postId)
metadataService.fetchMetadata(it)
}
if (settingsService.settings.downloadSettings.autoStart) {
downloadService.restartAll(posts)
@@ -1,9 +1,10 @@
package me.vripper.tasks
import me.vripper.entities.MetadataEntity
import me.vripper.entities.PostEntity
import me.vripper.exception.DownloadException
import me.vripper.exception.VripperException
import me.vripper.services.DataTransaction
import me.vripper.services.DataAccessService
import me.vripper.services.HTTPService
import me.vripper.services.SettingsService
import me.vripper.services.VGAuthService
@@ -21,14 +22,14 @@ import java.util.concurrent.atomic.AtomicBoolean
import java.util.stream.Collectors
internal class FetchMetadataTask(
private val postId: Long,
private val postEntity: PostEntity,
) : KoinComponent, Runnable {
private val dictionary: List<String> = mutableListOf("download", "link", "rapidgator", "filefactory", "filefox")
private val log by LoggerDelegate()
private val settingsService: SettingsService by inject()
private val vgAuthService: VGAuthService by inject()
private val httpService: HTTPService by inject()
private val dataTransaction: DataTransaction by inject()
private val dataAccessService: DataAccessService by inject()
override fun run() {
try {
@@ -39,7 +40,7 @@ internal class FetchMetadataTask(
Tasks.increment()
val httpGet = HttpGet(URIBuilder(settingsService.settings.viperSettings.host + "/threads/").also {
it.setParameter(
"p", postId.toString()
"p", postEntity.vgPostId.toString()
)
}.build()).also { it.setAbsoluteRequestUri(true) }
@@ -55,22 +56,22 @@ internal class FetchMetadataTask(
val document = HtmlUtils.clean(response)
val postNode: Node = XpathUtils.getAsNode(
document,
"//li[@id='post_$postId']/div[contains(@class, 'postdetails')]",
"//li[@id='post_${postEntity.vgPostId}']/div[contains(@class, 'postdetails')]",
) ?: throw VripperException("Unable to find post #'$postId'")
) ?: throw VripperException("Unable to find post #'${postEntity.vgPostId}'")
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'")
?: throw VripperException("Unable to find the poster for post #'${postEntity.vgPostId}'")
val node: Node = XpathUtils.getAsNode(
document, java.lang.String.format("//div[@id='post_message_%s']", postId)
document, java.lang.String.format("//div[@id='post_message_%s']", postEntity.vgPostId)
) ?: throw VripperException("Unable to locate post content")
val titles = findTitleInContent(node)
val metadataEntity = MetadataEntity(postId, MetadataEntity.Data(postedBy, titles))
dataTransaction.saveMetadata(metadataEntity)
val metadataEntity = MetadataEntity(postEntity.id, MetadataEntity.Data(postedBy, titles))
dataAccessService.saveMetadata(metadataEntity)
} finally {
Tasks.decrement()
}
@@ -36,7 +36,7 @@ internal class LeaveThanksTask(
listOf(
BasicNameValuePair("do", "post_thanks_add"),
BasicNameValuePair("using_ajax", "1"),
BasicNameValuePair("p", postEntity.postId.toString()),
BasicNameValuePair("p", postEntity.vgPostId.toString()),
BasicNameValuePair("securitytoken", postEntity.token)
)
)
@@ -3,7 +3,7 @@ package me.vripper.tasks
import me.vripper.entities.ThreadEntity
import me.vripper.model.Settings
import me.vripper.model.ThreadPostId
import me.vripper.services.DataTransaction
import me.vripper.services.DataAccessService
import me.vripper.services.SettingsService
import me.vripper.services.ThreadCacheService
import me.vripper.utilities.LoggerDelegate
@@ -13,7 +13,7 @@ import org.koin.core.component.inject
internal class ThreadLookupTask(private val threadId: Long, private val settings: Settings) : KoinComponent, Runnable {
private val log by LoggerDelegate()
private val dataTransaction by inject<DataTransaction>()
private val dataAccessService by inject<DataAccessService>()
private val settingsService by inject<SettingsService>()
private val threadCacheService by inject<ThreadCacheService>()
private val link: String = "${settingsService.settings.viperSettings.host}/threads/$threadId"
@@ -21,7 +21,7 @@ internal class ThreadLookupTask(private val threadId: Long, private val settings
override fun run() {
try {
Tasks.increment()
if (dataTransaction.findThreadByThreadId(threadId).isEmpty) {
if (dataAccessService.findThreadByThreadId(threadId).isEmpty) {
val threadLookupResult = threadCacheService[threadId]
if (threadLookupResult.error.isNotBlank()) {
log.error("Error loading $link: ${threadLookupResult.error}")
@@ -41,7 +41,7 @@ internal class ThreadLookupTask(private val threadId: Long, private val settings
})
)
} else {
dataTransaction.save(
dataAccessService.save(
ThreadEntity(
title = threadLookupResult.title,
link = link,
@@ -27,9 +27,14 @@ message DownloadSpeed {
message VGUser {
string user = 1;
}
message Rank {
int64 postEntityId = 1;
int64 rank = 2;
}
message QueueState {
int32 running = 1;
int32 remaining = 2;
repeated Rank rank = 3;
}
message ErrorCount {
int32 count = 1;
@@ -78,6 +83,16 @@ message Version {
message DBMigrationResponse {
string message = 1;
}
enum MovePositionEnum {
UP = 0;
DOWN = 1;
TOP = 2;
BOTTOM = 3;
}
message MovePositionMessage {
int64 postEntityId = 1;
MovePositionEnum position = 2;
}
service EndpointService {
rpc scanLinks (Links) returns (EmptyResponse) {}
@@ -94,6 +109,7 @@ service EndpointService {
rpc onDownloadSpeed (EmptyRequest) returns (stream DownloadSpeed) {}
rpc onVGUserUpdate (EmptyRequest) returns (stream VGUser) {}
rpc onQueueStateUpdate (EmptyRequest) returns (stream QueueState) {}
rpc getQueueState (EmptyRequest) returns (QueueState) {}
rpc onErrorCountUpdate (EmptyRequest) returns (stream ErrorCount) {}
rpc onTasksRunning (EmptyRequest) returns (stream TasksRunning) {}
rpc onUpdateMetadata (EmptyRequest) returns (stream Metadata) {}
@@ -120,4 +136,5 @@ service EndpointService {
rpc getVersion (EmptyRequest) returns (Version) {}
rpc dbMigration (EmptyRequest) returns (DBMigrationResponse) {}
rpc initLogger (EmptyRequest) returns (EmptyResponse) {}
rpc move (MovePositionMessage) returns (EmptyResponse) {}
}
+9 -10
View File
@@ -3,14 +3,13 @@ option java_package = "me.vripper.proto";
message Image {
int64 id = 1;
int64 postId = 2;
string url = 3;
string thumbUrl = 4;
int32 host = 5;
int32 index = 6;
int64 postIdRef = 7;
int64 size = 8;
int64 downloaded = 9;
string status = 10;
string filename = 11;
string url = 2;
string thumbUrl = 3;
int32 host = 4;
int32 index = 5;
int64 postIdRef = 6;
int64 size = 7;
int64 downloaded = 8;
string status = 9;
string filename = 10;
}
+5 -6
View File
@@ -17,10 +17,9 @@ message Post {
string folderName = 13;
string status = 14;
int32 done = 15;
int32 rank = 16;
int64 size = 17;
int64 downloaded = 18;
repeated string previews = 19;
string postedBy = 20;
repeated string resolvedNames = 21;
int64 size = 16;
int64 downloaded = 17;
repeated string previews = 18;
string postedBy = 19;
repeated string resolvedNames = 20;
}
@@ -142,4 +142,15 @@
</column>
</createTable>
</changeSet>
<changeSet author="vripper" id="0007">
<renameColumn oldColumnName="POST_ID" newColumnName="POST_ID_REF" tableName="METADATA"/>
</changeSet>
<changeSet author="vripper" id="0009">
<dropIndex tableName="IMAGE" indexName="IMAGE_POST_ID_IDX"/>
</changeSet>
<changeSet author="vripper" id="0010">
<sql>
ALTER TABLE IMAGE DROP COLUMN POST_ID
</sql>
</changeSet>
</databaseChangeLog>
+7 -2
View File
@@ -16,7 +16,7 @@
<description>vripper-gui</description>
<properties>
<javafx.version>23.0.1</javafx.version>
<javafx.version>25</javafx.version>
</properties>
<dependencies>
@@ -114,7 +114,7 @@
<dependency>
<groupId>io.github.mkpaz</groupId>
<artifactId>atlantafx-base</artifactId>
<version>2.0.1</version>
<version>2.1.0</version>
</dependency>
<dependency>
<groupId>org.kordamp.ikonli</groupId>
@@ -147,6 +147,11 @@
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
</dependency>
<dependency>
<groupId>com.google.jimfs</groupId>
<artifactId>jimfs</artifactId>
<version>1.3.1</version>
</dependency>
</dependencies>
<build>
@@ -1,7 +1,6 @@
package me.vripper.gui
import atlantafx.base.theme.CupertinoDark
import atlantafx.base.theme.CupertinoLight
import atlantafx.base.theme.*
import javafx.application.Application
import javafx.scene.image.Image
import javafx.stage.Stage
@@ -38,13 +37,19 @@ class VripperGuiApplication : App(
}
override fun start(stage: Stage) {
Thread.setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler { t, e ->
Thread.setDefaultUncaughtExceptionHandler { t, e ->
log.error("Thread $t threw an exception: ${e.message}", e)
})
if (widgetsController.currentSettings.darkMode) {
setUserAgentStylesheet(CupertinoDark().userAgentStylesheet)
} else {
setUserAgentStylesheet(CupertinoLight().userAgentStylesheet)
}
when (widgetsController.currentSettings.theme) {
"CupertinoLight" -> setUserAgentStylesheet(CupertinoLight().userAgentStylesheet)
"CupertinoDark" -> setUserAgentStylesheet(CupertinoDark().userAgentStylesheet)
"NordLight" -> setUserAgentStylesheet(NordLight().userAgentStylesheet)
"NordDark" -> setUserAgentStylesheet(NordDark().userAgentStylesheet)
"PrimerLight" -> setUserAgentStylesheet(PrimerLight().userAgentStylesheet)
"PrimerDark" -> setUserAgentStylesheet(PrimerDark().userAgentStylesheet)
"Dracula" -> setUserAgentStylesheet(Dracula().userAgentStylesheet)
else -> setUserAgentStylesheet(CupertinoLight().userAgentStylesheet)
}
with(stage) {
width = widgetsController.currentSettings.width
@@ -1,17 +1,16 @@
package me.vripper.gui.components.cells
import javafx.collections.ObservableList
import javafx.geometry.Pos
import javafx.scene.control.TableCell
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
class PreviewTableCell<T> : TableCell<T, ObservableList<String>>() {
class PreviewTableCell<T, V> : TableCell<T, V>() {
init {
alignment = Pos.CENTER
}
override fun updateItem(item: ObservableList<String>?, empty: Boolean) {
override fun updateItem(item: V, empty: Boolean) {
super.updateItem(item, empty)
graphic = if (!empty) {
FontIcon.of(Feather.IMAGE)
@@ -88,19 +88,6 @@ class AboutFragment : Fragment("About") {
isEditable = false
}
}
field("Previews Path") {
textfield(widgetsController.currentSettings.cachePathProperty) {
isEditable = false
}
button("Browse") {
action {
val directory = chooseDirectory(title = "Select previews folder")
if (directory != null) {
widgetsController.currentSettings.cachePathProperty.set(directory.path)
}
}
}
}
}
}
}
@@ -1,63 +1,51 @@
package me.vripper.gui.components.fragments
import javafx.scene.control.Spinner
import kotlinx.coroutines.*
import me.vripper.gui.controller.SettingsController
import me.vripper.gui.model.settings.ConnectionSettingsModel
import me.vripper.model.ConnectionSettings
import tornadofx.*
class ConnectionSettingsFragment : Fragment("Connection Settings") {
private val settingsController: SettingsController by inject()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var connectionSettings: ConnectionSettings
val connectionSettings: ConnectionSettings by param()
val connectionSettingsModel = ConnectionSettingsModel()
override val root = vbox {}
init {
coroutineScope.launch {
connectionSettings = settingsController.findConnectionSettings()
runLater {
with(root) {
form {
fieldset {
field("Concurrent downloads per host") {
add(Spinner<Int>(1, 4, connectionSettings.maxConcurrentPerHost).apply {
connectionSettingsModel.maxThreadsProperty.bind(valueProperty())
isEditable = true
atlantafx.base.util.IntegerStringConverter.createFor(this)
})
}
field("Global concurrent downloads") {
add(Spinner<Int>(0, 24, connectionSettings.maxGlobalConcurrent).apply {
connectionSettingsModel.maxTotalThreadsProperty.bind(valueProperty())
isEditable = true
atlantafx.base.util.IntegerStringConverter.createFor(this)
})
}
field("Connection timeout (s)") {
add(Spinner<Int>(1, 300, connectionSettings.timeout).apply {
connectionSettingsModel.timeoutProperty.bind(valueProperty())
isEditable = true
atlantafx.base.util.IntegerStringConverter.createFor(this)
})
}
field("Maximum attempts") {
add(Spinner<Int>(1, 10, connectionSettings.maxAttempts).apply {
connectionSettingsModel.maxAttemptsProperty.bind(valueProperty())
isEditable = true
atlantafx.base.util.IntegerStringConverter.createFor(this)
})
}
}
with(root) {
form {
fieldset {
field("Concurrent downloads per host") {
add(Spinner<Int>(1, 4, connectionSettings.maxConcurrentPerHost).apply {
connectionSettingsModel.maxThreadsProperty.bind(valueProperty())
isEditable = true
atlantafx.base.util.IntegerStringConverter.createFor(this)
})
}
field("Global concurrent downloads") {
add(Spinner<Int>(0, 24, connectionSettings.maxGlobalConcurrent).apply {
connectionSettingsModel.maxTotalThreadsProperty.bind(valueProperty())
isEditable = true
atlantafx.base.util.IntegerStringConverter.createFor(this)
})
}
field("Connection timeout (s)") {
add(Spinner<Int>(1, 300, connectionSettings.timeout).apply {
connectionSettingsModel.timeoutProperty.bind(valueProperty())
isEditable = true
atlantafx.base.util.IntegerStringConverter.createFor(this)
})
}
field("Maximum attempts") {
add(Spinner<Int>(1, 10, connectionSettings.maxAttempts).apply {
connectionSettingsModel.maxAttemptsProperty.bind(valueProperty())
isEditable = true
atlantafx.base.util.IntegerStringConverter.createFor(this)
})
}
}
}
}
}
override fun onUndock() {
coroutineScope.cancel()
}
}
@@ -2,8 +2,6 @@ package me.vripper.gui.components.fragments
import atlantafx.base.util.IntegerStringConverter
import javafx.scene.control.Spinner
import kotlinx.coroutines.*
import me.vripper.gui.controller.SettingsController
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.model.settings.DownloadSettingsModel
import me.vripper.model.DownloadSettings
@@ -11,89 +9,76 @@ import tornadofx.*
class DownloadSettingsFragment : Fragment("Download Settings") {
private val settingsController: SettingsController by inject()
val downloadSettings: DownloadSettings by param()
private val widgetsController: WidgetsController by inject()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var downloadSettings: DownloadSettings
val downloadSettingsModel = DownloadSettingsModel()
override val root = vbox {}
init {
coroutineScope.launch {
downloadSettings = settingsController.findDownloadSettings() ?: DownloadSettings()
downloadSettingsModel.downloadPath = downloadSettings.downloadPath
downloadSettingsModel.autoStart = downloadSettings.autoStart
downloadSettingsModel.forceOrder = downloadSettings.forceOrder
downloadSettingsModel.forumSubfolder = downloadSettings.forumSubDirectory
downloadSettingsModel.threadSubLocation = downloadSettings.threadSubLocation
downloadSettingsModel.clearCompleted = downloadSettings.clearCompleted
downloadSettingsModel.appendPostId = downloadSettings.appendPostId
runLater {
with(root) {
form {
fieldset {
field("Download Path") {
textfield(downloadSettingsModel.downloadPathProperty) {
editableWhen(widgetsController.currentSettings.localSessionProperty.not())
}
button("Browse") {
visibleWhen(widgetsController.currentSettings.localSessionProperty)
action {
val directory = chooseDirectory(title = "Select download folder")
if (directory != null) {
downloadSettingsModel.downloadPathProperty.set(directory.path)
}
}
}
}
field("Auto start downloads") {
checkbox {
bind(downloadSettingsModel.autoStartProperty)
}
}
field("Auto queue thread if post count is below or equal to") {
add(Spinner<Int>(1, Int.MAX_VALUE, downloadSettings.autoQueueThreshold).apply {
downloadSettingsModel.autoQueueThresholdProperty.bind(valueProperty())
isEditable = true
IntegerStringConverter.createFor(this)
})
}
field("Organize by category") {
checkbox {
bind(downloadSettingsModel.forumSubfolderProperty)
}
}
field("Organize by thread") {
checkbox {
bind(downloadSettingsModel.threadSubLocationProperty)
}
}
field("Order images") {
checkbox {
bind(downloadSettingsModel.forceOrderProperty)
}
}
field("Append post id to download folder") {
checkbox {
bind(downloadSettingsModel.appendPostIdProperty)
}
}
field("Clear Finished") {
checkbox {
bind(downloadSettingsModel.clearCompletedProperty)
downloadSettingsModel.downloadPath = downloadSettings.downloadPath
downloadSettingsModel.autoStart = downloadSettings.autoStart
downloadSettingsModel.forceOrder = downloadSettings.forceOrder
downloadSettingsModel.forumSubfolder = downloadSettings.forumSubDirectory
downloadSettingsModel.threadSubLocation = downloadSettings.threadSubLocation
downloadSettingsModel.clearCompleted = downloadSettings.clearCompleted
downloadSettingsModel.appendPostId = downloadSettings.appendPostId
with(root) {
form {
fieldset {
field("Download Path") {
textfield(downloadSettingsModel.downloadPathProperty) {
editableWhen(widgetsController.currentSettings.localSessionProperty.not())
}
button("Browse") {
visibleWhen(widgetsController.currentSettings.localSessionProperty)
action {
val directory = chooseDirectory(title = "Select download folder")
if (directory != null) {
downloadSettingsModel.downloadPathProperty.set(directory.path)
}
}
}
}
field("Auto start downloads") {
checkbox {
bind(downloadSettingsModel.autoStartProperty)
}
}
field("Auto queue thread if post count is below or equal to") {
add(Spinner<Int>(1, Int.MAX_VALUE, downloadSettings.autoQueueThreshold).apply {
downloadSettingsModel.autoQueueThresholdProperty.bind(valueProperty())
isEditable = true
IntegerStringConverter.createFor(this)
})
}
field("Organize by category") {
checkbox {
bind(downloadSettingsModel.forumSubfolderProperty)
}
}
field("Organize by thread") {
checkbox {
bind(downloadSettingsModel.threadSubLocationProperty)
}
}
field("Order images") {
checkbox {
bind(downloadSettingsModel.forceOrderProperty)
}
}
field("Append post id to download folder") {
checkbox {
bind(downloadSettingsModel.appendPostIdProperty)
}
}
field("Clear Finished") {
checkbox {
bind(downloadSettingsModel.clearCompletedProperty)
}
}
}
}
}
}
override fun onUndock() {
coroutineScope.cancel()
}
}
@@ -12,7 +12,7 @@ import tornadofx.*
class RenameFragment : Fragment("Rename download post") {
val postId: Long by param()
val id: Long by param()
val name: String by param()
val altTitles: List<String> by param()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -43,7 +43,7 @@ class RenameFragment : Fragment("Rename download post") {
disableWhen(comboBox.editor.textProperty().isEmpty)
action {
coroutineScope.launch {
postController.rename(postId, comboBox.editor.text.trim())
postController.rename(this@RenameFragment.id, comboBox.editor.text.trim())
}
close()
}
@@ -9,11 +9,11 @@ import javafx.scene.control.ToggleGroup
import javafx.scene.layout.VBox
import kotlinx.coroutines.runBlocking
import me.vripper.gui.VripperGuiApplication
import me.vripper.gui.components.views.AppView
import me.vripper.gui.components.views.LoadingView
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.services.GrpcEndpointService
import me.vripper.gui.utils.AppEndpointManager
import me.vripper.listeners.AppManager
import tornadofx.*
class SessionFragment : Fragment("Change Session") {
@@ -32,11 +32,11 @@ class SessionFragment : Fragment("Change Session") {
form {
fieldset("Source") {
radiobutton(text = "Start Local Session", group = toggleGroup) {
id = "localSession"
this.id = "localSession"
isSelected = widgetsController.currentSettings.localSession
}
val remoteRadio = radiobutton(text = "Connect to Remote Session", group = toggleGroup) {
id = "remoteSession"
this.id = "remoteSession"
isSelected = !widgetsController.currentSettings.localSession
}
field("Host") {
@@ -66,36 +66,25 @@ class SessionFragment : Fragment("Change Session") {
isDefaultButton = true
action {
val selectedToggle = toggleGroup.selectedToggle
runBlocking {
GuiEventBus.publishEvent(GuiEventBus.ChangingSession)
AppManager.stop()
grpcEndpointService.disconnect()
}
when ((selectedToggle as RadioButton).id) {
"localSession" -> {
runBlocking {
widgetsController.currentSettings.localSession = true
AppEndpointManager.set(GuiEventBus.LocalSession)
AppManager.start()
GuiEventBus.publishEvent(GuiEventBus.LocalSession)
}
}
"remoteSession" -> {
runBlocking {
widgetsController.currentSettings.localSession = false
AppEndpointManager.set(GuiEventBus.RemoteSession)
grpcEndpointService.connect(
widgetsController.currentSettings.remoteSessionModel.host,
widgetsController.currentSettings.remoteSessionModel.port,
widgetsController.currentSettings.remoteSessionModel.passcode,
)
GuiEventBus.publishEvent(GuiEventBus.RemoteSession)
}
}
else -> VripperGuiApplication.APP_INSTANCE.stop()
runLater {
find<AppView>().replaceWith(find<LoadingView>())
}
runLater {
when ((selectedToggle as RadioButton).id) {
"localSession" -> {
widgetsController.currentSettings.localSession = true
}
"remoteSession" -> {
widgetsController.currentSettings.localSession = false
}
else -> VripperGuiApplication.APP_INSTANCE.stop()
}
runBlocking {
GuiEventBus.publishEvent(GuiEventBus.ChangingSession)
GuiEventBus.publishEvent(GuiEventBus.ApplicationInitialized(emptyList()))
}
close()
}
}
@@ -9,6 +9,10 @@ import javafx.scene.layout.VBox
import kotlinx.coroutines.*
import me.vripper.exception.ValidationException
import me.vripper.gui.controller.SettingsController
import me.vripper.model.ConnectionSettings
import me.vripper.model.DownloadSettings
import me.vripper.model.SystemSettings
import me.vripper.model.ViperSettings
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
@@ -16,12 +20,20 @@ import tornadofx.*
class SettingsFragment : Fragment("Settings") {
val downloadSettings: DownloadSettings by param()
val connectionSettings: ConnectionSettings by param()
val viperSettings: ViperSettings by param()
val systemSettings: SystemSettings by param()
private val settingsController: SettingsController by inject()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val downloadSettingsFragment: DownloadSettingsFragment = find()
private val connectionSettingsFragment: ConnectionSettingsFragment = find()
private val viperSettingsFragment: ViperSettingsFragment = find()
private val systemSettingsFragment: SystemSettingsFragment = find()
private val downloadSettingsFragment: DownloadSettingsFragment =
find(mapOf(DownloadSettingsFragment::downloadSettings to downloadSettings))
private val connectionSettingsFragment: ConnectionSettingsFragment =
find(mapOf(ConnectionSettingsFragment::connectionSettings to connectionSettings))
private val viperSettingsFragment: ViperSettingsFragment =
find(mapOf(ViperSettingsFragment::viperSettings to viperSettings))
private val systemSettingsFragment: SystemSettingsFragment =
find(mapOf(SystemSettingsFragment::systemSettings to systemSettings))
override val root = vbox(alignment = Pos.CENTER_RIGHT) {
spacing = 5.0
@@ -31,6 +43,7 @@ class SettingsFragment : Fragment("Settings") {
minWidth = 100.0
minHeight = 100.0
prefHeight = 400.0
prefWidth = 800.0
tab(downloadSettingsFragment.title) {
add(downloadSettingsFragment)
graphic = FontIcon.of(Feather.FOLDER)
@@ -3,73 +3,74 @@ package me.vripper.gui.components.fragments
import atlantafx.base.controls.ToggleSwitch
import atlantafx.base.util.IntegerStringConverter
import javafx.scene.control.Spinner
import kotlinx.coroutines.*
import me.vripper.gui.controller.SettingsController
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.model.settings.SystemSettingsModel
import me.vripper.model.SystemSettings
import tornadofx.*
class SystemSettingsFragment : Fragment("System Settings") {
private val settingsController: SettingsController by inject()
val systemSettings: SystemSettings by param()
private val widgetsController: WidgetsController by inject()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var systemSettings: SystemSettings
val systemSettingsModel = SystemSettingsModel()
override val root = vbox {}
init {
coroutineScope.launch {
systemSettings = settingsController.findSystemSettings()
systemSettingsModel.tempPath = systemSettings.tempPath
runLater {
with(root) {
form {
systemSettingsModel.tempPath = systemSettings.tempPath
systemSettingsModel.logEntries = systemSettings.maxEventLog
systemSettingsModel.enable = systemSettings.enableClipboardMonitoring
systemSettingsModel.pollingRate = systemSettings.clipboardPollingRate
with(root) {
form {
fieldset {
field("Temporary Path") {
textfield(systemSettingsModel.tempPathProperty) {
editableWhen(widgetsController.currentSettings.localSessionProperty.not())
}
button("Browse") {
visibleWhen(widgetsController.currentSettings.localSessionProperty)
action {
val directory = chooseDirectory(title = "Select temporary folder")
if (directory != null) {
systemSettingsModel.tempPathProperty.set(directory.path)
}
}
}
}
field("Max log entries") {
add(Spinner<Int>(10, 10000, systemSettingsModel.logEntries).apply {
valueProperty().onChange {
systemSettingsModel.logEntries = it!!
}
isEditable = true
IntegerStringConverter.createFor(this)
})
}
fieldset {
field("Clipboard monitoring") {
add(ToggleSwitch().apply {
isSelected = systemSettingsModel.enable
selectedProperty().onChange {
systemSettingsModel.enable = it
}
})
}
fieldset {
field("Temporary Path") {
textfield(systemSettingsModel.tempPathProperty) {
editableWhen(widgetsController.currentSettings.localSessionProperty.not())
}
button("Browse") {
visibleWhen(widgetsController.currentSettings.localSessionProperty)
action {
val directory = chooseDirectory(title = "Select temporary folder")
if (directory != null) {
systemSettingsModel.tempPathProperty.set(directory.path)
visibleWhen(systemSettingsModel.enableProperty)
field("Polling rate (ms)") {
add(
Spinner<Int>(
500,
Int.MAX_VALUE,
systemSettingsModel.pollingRate
).apply {
valueProperty().onChange {
systemSettingsModel.pollingRate = it!!
}
}
}
}
field("Max log entries") {
add(Spinner<Int>(10, 10000, systemSettings.maxEventLog).apply {
systemSettingsModel.logEntriesProperty.bind(valueProperty())
isEditable = true
IntegerStringConverter.createFor(this)
})
}
fieldset {
field("Clipboard monitoring") {
add(ToggleSwitch().apply {
isSelected = systemSettings.enableClipboardMonitoring
systemSettingsModel.enableProperty.bind(selectedProperty())
isEditable = true
IntegerStringConverter.createFor(this)
})
}
fieldset {
visibleWhen(systemSettingsModel.enableProperty)
field("Polling rate (ms)") {
add(
Spinner<Int>(
500,
kotlin.Int.MAX_VALUE,
systemSettings.clipboardPollingRate
).apply {
systemSettingsModel.pollingRateProperty.bind(valueProperty())
isEditable = true
IntegerStringConverter.createFor(this)
})
}
}
}
}
}
@@ -77,8 +78,4 @@ class SystemSettingsFragment : Fragment("System Settings") {
}
}
}
override fun onUndock() {
coroutineScope.cancel()
}
}
@@ -2,6 +2,7 @@ package me.vripper.gui.components.fragments
import atlantafx.base.theme.Styles
import javafx.beans.property.SimpleStringProperty
import javafx.collections.ObservableList
import javafx.event.EventHandler
import javafx.geometry.Insets
import javafx.geometry.Pos
@@ -22,15 +23,15 @@ import me.vripper.gui.utils.openLink
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
import kotlin.io.path.Path
class ThreadSelectionTableFragment : Fragment("Thread") {
private lateinit var tableView: TableView<ThreadSelectionModel>
override val root = vbox(alignment = Pos.CENTER_RIGHT, spacing = 15.0)
private val tableView: TableView<ThreadSelectionModel>
private val threadController: ThreadController by inject()
private val widgetsController: WidgetsController by inject()
private var items = SortedFilteredList<ThreadSelectionModel>()
private var preview: Preview? = null
private val preview: Preview = Preview(currentStage!!)
private val searchInput = SimpleStringProperty()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val threadId: Long by param()
@@ -48,182 +49,185 @@ class ThreadSelectionTableFragment : Fragment("Thread") {
}
override fun onUndock() {
preview.destroy()
coroutineScope.cancel()
}
override val root = vbox(alignment = Pos.CENTER_RIGHT, spacing = 15.0) {
hbox(spacing = 5, alignment = Pos.BASELINE_LEFT) {
padding = Insets(10.0, 5.0, 0.0, 5.0)
textfield(searchInput) {
promptText = "Search"
hgrow = Priority.ALWAYS
init {
with(root) {
hbox(spacing = 5, alignment = Pos.BASELINE_LEFT) {
padding = Insets(10.0, 5.0, 0.0, 5.0)
textfield(searchInput) {
promptText = "Search"
hgrow = Priority.ALWAYS
}
}
}
tableView = tableview(items) {
isTableMenuButtonVisible = true
addClass(Styles.DENSE)
selectionModel.selectionMode = SelectionMode.MULTIPLE
setRowFactory {
val tableRow = TableRow<ThreadSelectionModel>()
tableView = tableview(items) {
isTableMenuButtonVisible = true
addClass(Styles.DENSE)
selectionModel.selectionMode = SelectionMode.MULTIPLE
setRowFactory {
val tableRow = TableRow<ThreadSelectionModel>()
tableRow.setOnMouseClicked {
if (it.button.equals(MouseButton.PRIMARY) && it.clickCount == 2 && tableRow.item != null) {
coroutineScope.launch {
async { threadController.download(listOf(tableRow.item)) }.await()
runLater {
close()
tableRow.setOnMouseClicked {
if (it.button.equals(MouseButton.PRIMARY) && it.clickCount == 2 && tableRow.item != null) {
coroutineScope.launch {
async { threadController.download(listOf(tableRow.item)) }.await()
runLater {
close()
}
}
}
}
}
val urlItem = MenuItem("Open link").apply {
setOnAction {
openLink(tableRow.item.url)
}
graphic = FontIcon.of(Feather.LINK)
}
val contextMenu = ContextMenu()
contextMenu.items.addAll(urlItem)
tableRow.contextMenuProperty().bind(
tableRow.emptyProperty()
.map { empty -> if (empty) null else contextMenu })
tableRow
}
column("Preview", ThreadSelectionModel::previewListProperty) {
isVisible = widgetsController.currentSettings.threadSelectionColumnsModel.previewProperty.get()
visibleProperty().onChange {
widgetsController.currentSettings.threadSelectionColumnsModel.previewProperty.set(
it
)
}
prefWidth = 100.0
cellFactory = Callback {
val cell = PreviewTableCell<ThreadSelectionModel>()
cell.onMouseExited = EventHandler {
preview?.hide()
}
cell.onMouseMoved = EventHandler {
preview?.previewPopup?.apply {
x = it.screenX + 20
y = it.screenY + 10
val urlItem = MenuItem("Open link").apply {
setOnAction {
openLink(tableRow.item.url)
}
graphic = FontIcon.of(Feather.LINK)
}
cell.onMouseEntered = EventHandler { mouseEvent ->
preview?.hide()
if (cell.tableRow.item != null && cell.tableRow.item.previewList.isNotEmpty()) {
preview = Preview(
currentStage!!,
cell.tableRow.item.previewList,
Path(widgetsController.currentSettings.cachePath)
)
preview?.previewPopup?.apply {
x = mouseEvent.screenX + 20
y = mouseEvent.screenY + 10
val contextMenu = ContextMenu()
contextMenu.items.addAll(urlItem)
tableRow.contextMenuProperty().bind(
tableRow.emptyProperty()
.map { empty -> if (empty) null else contextMenu })
tableRow
}
column("Preview", ThreadSelectionModel::previewListProperty) {
isVisible = widgetsController.currentSettings.threadSelectionColumnsModel.previewProperty.get()
visibleProperty().onChange {
widgetsController.currentSettings.threadSelectionColumnsModel.previewProperty.set(
it
)
}
prefWidth = 100.0
cellFactory = Callback {
val cell = PreviewTableCell<ThreadSelectionModel, ObservableList<String>>()
cell.onMouseExited = EventHandler {
preview.cleanup()
}
cell.onMouseMoved = EventHandler {
preview.previewPopup.apply {
x = it.screenX + 20
y = it.screenY + 10
}
}
}
cell
}
}
column("Post Index", ThreadSelectionModel::indexProperty) {
isVisible = widgetsController.currentSettings.threadSelectionColumnsModel.indexProperty.get()
visibleProperty().onChange {
widgetsController.currentSettings.threadSelectionColumnsModel.indexProperty.set(
it
)
}
prefWidth = widgetsController.currentSettings.threadSelectionColumnsWidthModel.index
coroutineScope.launch {
widthProperty().asFlow().debounce(200).collect {
widgetsController.currentSettings.threadSelectionColumnsWidthModel.index = it as Double
cell.onMouseEntered = EventHandler { mouseEvent ->
preview.cleanup()
if (cell.tableRow.item != null && cell.tableRow.item.previewList.isNotEmpty()) {
preview.display(
cell.tableRow.item.threadId,
cell.tableRow.item.previewList
)
preview.previewPopup.apply {
x = mouseEvent.screenX + 20
y = mouseEvent.screenY + 10
}
}
}
cell
}
}
sortOrder.add(this)
cellFactory = Callback {
TextFieldTableCell<ThreadSelectionModel?, Number?>().apply { alignment = Pos.CENTER_LEFT }
}
}
column("Title", ThreadSelectionModel::titleProperty) {
isVisible = widgetsController.currentSettings.threadSelectionColumnsModel.titleProperty.get()
visibleProperty().onChange {
widgetsController.currentSettings.threadSelectionColumnsModel.titleProperty.set(
it
)
}
prefWidth = widgetsController.currentSettings.threadSelectionColumnsWidthModel.title
coroutineScope.launch {
widthProperty().asFlow().debounce(200).collect {
widgetsController.currentSettings.threadSelectionColumnsWidthModel.title = it as Double
column("Post Index", ThreadSelectionModel::indexProperty) {
isVisible = widgetsController.currentSettings.threadSelectionColumnsModel.indexProperty.get()
visibleProperty().onChange {
widgetsController.currentSettings.threadSelectionColumnsModel.indexProperty.set(
it
)
}
prefWidth = widgetsController.currentSettings.threadSelectionColumnsWidthModel.index
coroutineScope.launch {
widthProperty().asFlow().debounce(200).collect {
widgetsController.currentSettings.threadSelectionColumnsWidthModel.index = it as Double
}
}
sortOrder.add(this)
cellFactory = Callback {
TextFieldTableCell<ThreadSelectionModel?, Number?>().apply { alignment = Pos.CENTER_LEFT }
}
}
cellFactory = Callback {
TextFieldTableCell<ThreadSelectionModel?, String?>().apply { alignment = Pos.CENTER_LEFT }
}
}
column("URL", ThreadSelectionModel::urlProperty) {
isVisible = widgetsController.currentSettings.threadSelectionColumnsModel.linkProperty.get()
visibleProperty().onChange {
widgetsController.currentSettings.threadSelectionColumnsModel.linkProperty.set(
it
)
}
prefWidth = widgetsController.currentSettings.threadSelectionColumnsWidthModel.link
coroutineScope.launch {
widthProperty().asFlow().debounce(200).collect {
widgetsController.currentSettings.threadSelectionColumnsWidthModel.link = it as Double
column("Title", ThreadSelectionModel::titleProperty) {
isVisible = widgetsController.currentSettings.threadSelectionColumnsModel.titleProperty.get()
visibleProperty().onChange {
widgetsController.currentSettings.threadSelectionColumnsModel.titleProperty.set(
it
)
}
prefWidth = widgetsController.currentSettings.threadSelectionColumnsWidthModel.title
coroutineScope.launch {
widthProperty().asFlow().debounce(200).collect {
widgetsController.currentSettings.threadSelectionColumnsWidthModel.title = it as Double
}
}
cellFactory = Callback {
TextFieldTableCell<ThreadSelectionModel?, String?>().apply { alignment = Pos.CENTER_LEFT }
}
}
cellFactory = Callback {
TextFieldTableCell<ThreadSelectionModel?, String?>().apply { alignment = Pos.CENTER_LEFT }
}
}
column("Hosts", ThreadSelectionModel::hostsProperty) {
isVisible = widgetsController.currentSettings.threadSelectionColumnsModel.hostsProperty.get()
visibleProperty().onChange {
widgetsController.currentSettings.threadSelectionColumnsModel.hostsProperty.set(
it
)
}
prefWidth = widgetsController.currentSettings.threadSelectionColumnsWidthModel.hosts
coroutineScope.launch {
widthProperty().asFlow().debounce(200).collect {
widgetsController.currentSettings.threadSelectionColumnsWidthModel.hosts = it as Double
column("URL", ThreadSelectionModel::urlProperty) {
isVisible = widgetsController.currentSettings.threadSelectionColumnsModel.linkProperty.get()
visibleProperty().onChange {
widgetsController.currentSettings.threadSelectionColumnsModel.linkProperty.set(
it
)
}
prefWidth = widgetsController.currentSettings.threadSelectionColumnsWidthModel.link
coroutineScope.launch {
widthProperty().asFlow().debounce(200).collect {
widgetsController.currentSettings.threadSelectionColumnsWidthModel.link = it as Double
}
}
cellFactory = Callback {
TextFieldTableCell<ThreadSelectionModel?, String?>().apply { alignment = Pos.CENTER_LEFT }
}
}
cellFactory = Callback {
TextFieldTableCell<ThreadSelectionModel?, String?>().apply { alignment = Pos.CENTER_LEFT }
column("Hosts", ThreadSelectionModel::hostsProperty) {
isVisible = widgetsController.currentSettings.threadSelectionColumnsModel.hostsProperty.get()
visibleProperty().onChange {
widgetsController.currentSettings.threadSelectionColumnsModel.hostsProperty.set(
it
)
}
prefWidth = widgetsController.currentSettings.threadSelectionColumnsWidthModel.hosts
coroutineScope.launch {
widthProperty().asFlow().debounce(200).collect {
widgetsController.currentSettings.threadSelectionColumnsWidthModel.hosts = it as Double
}
}
cellFactory = Callback {
TextFieldTableCell<ThreadSelectionModel?, String?>().apply { alignment = Pos.CENTER_LEFT }
}
}
}
}
borderpane {
right {
padding = insets(top = 0, right = 5, bottom = 5, left = 5)
button("Download") {
graphic = FontIcon.of(Feather.DOWNLOAD)
addClass(Styles.ACCENT)
isDefaultButton = true
tooltip("Download selected posts")
enableWhen { tableView.selectionModel.selectedItems.sizeProperty.greaterThan(0) }
action {
coroutineScope.launch {
async { threadController.download(tableView.selectionModel.selectedItems) }.await()
runLater {
close()
borderpane {
right {
padding = insets(top = 0, right = 5, bottom = 5, left = 5)
button("Download") {
graphic = FontIcon.of(Feather.DOWNLOAD)
addClass(Styles.ACCENT)
isDefaultButton = true
tooltip("Download selected posts")
enableWhen { tableView.selectionModel.selectedItems.sizeProperty.greaterThan(0) }
action {
coroutineScope.launch {
async { threadController.download(tableView.selectionModel.selectedItems) }.await()
runLater {
close()
}
}
}
}
}
}
}
searchInput.onChange { search ->
if (search != null) {
items.predicate = { it.title.contains(search, true) }
searchInput.onChange { search ->
if (search != null) {
items.predicate = { it.title.contains(search, true) }
}
}
items.bindTo(tableView)
}
items.bindTo(tableView)
}
}
@@ -3,69 +3,66 @@ package me.vripper.gui.components.fragments
import atlantafx.base.controls.ToggleSwitch
import javafx.collections.FXCollections
import javafx.scene.control.Spinner
import kotlinx.coroutines.*
import kotlinx.coroutines.runBlocking
import me.vripper.gui.controller.SettingsController
import me.vripper.gui.model.settings.ViperSettingsModel
import me.vripper.model.ViperSettings
import tornadofx.*
class ViperSettingsFragment : Fragment("Viper Settings") {
val viperSettings: ViperSettings by param()
private val settingsController: SettingsController by inject()
private val proxies = FXCollections.observableArrayList<String>()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var viperGirlsSettings: ViperSettings
val viperSettingsModel = ViperSettingsModel()
override val root = vbox {}
init {
coroutineScope.launch {
viperGirlsSettings = settingsController.findViperGirlsSettings()
viperSettingsModel.username = viperGirlsSettings.username
viperSettingsModel.password = viperGirlsSettings.password
viperSettingsModel.thanks = viperGirlsSettings.thanks
viperSettingsModel.host = viperGirlsSettings.host
viperSettingsModel.requestLimit = viperGirlsSettings.requestLimit
viperSettingsModel.fetchMetadata = viperGirlsSettings.fetchMetadata
proxies.addAll(settingsController.getProxies())
runLater {
with(root) {
form {
fieldset {
field("Viper domain") {
combobox(viperSettingsModel.hostProperty, proxies)
viperSettingsModel.username = viperSettings.username
viperSettingsModel.password = viperSettings.password
viperSettingsModel.thanks = viperSettings.thanks
viperSettingsModel.host = viperSettings.host
viperSettingsModel.requestLimit = viperSettings.requestLimit
viperSettingsModel.fetchMetadata = viperSettings.fetchMetadata
proxies.addAll(runBlocking { settingsController.getProxies() })
with(root) {
form {
fieldset {
field("Viper domain") {
combobox(viperSettingsModel.hostProperty, proxies)
}
field("Rate limit (requests/s)") {
add(Spinner<Int>(1, 5, viperSettingsModel.requestLimit.toInt()).apply {
valueProperty().onChange {
viperSettingsModel.requestLimit = it!!.toLong()
}
field("Rate limit (requests/s)") {
add(Spinner<Int>(1, 5, viperGirlsSettings.requestLimit.toInt()).apply {
viperSettingsModel.requestLimitProperty.bind(valueProperty())
isEditable = true
atlantafx.base.util.IntegerStringConverter.createFor(this)
})
}
field("Fetch Metadata") {
checkbox {
bind(viperSettingsModel.fetchMetadataProperty)
}
}
field("Authentication") {
add(ToggleSwitch().apply {
isSelected = viperGirlsSettings.login
viperSettingsModel.loginProperty.bind(selectedProperty())
})
}
fieldset {
visibleWhen(viperSettingsModel.loginProperty)
field("Username") {
textfield(viperSettingsModel.usernameProperty)
}
field("Password") {
passwordfield(viperSettingsModel.passwordProperty)
}
field("Leave likes") {
checkbox {
bind(viperSettingsModel.thanksProperty)
}
}
isEditable = true
atlantafx.base.util.IntegerStringConverter.createFor(this)
})
}
field("Fetch Metadata") {
checkbox {
bind(viperSettingsModel.fetchMetadataProperty)
}
}
field("Authentication") {
add(ToggleSwitch().apply {
isSelected = viperSettings.login
viperSettingsModel.loginProperty.bind(selectedProperty())
})
}
fieldset {
visibleWhen(viperSettingsModel.loginProperty)
field("Username") {
textfield(viperSettingsModel.usernameProperty)
}
field("Password") {
passwordfield(viperSettingsModel.passwordProperty)
}
field("Leave likes") {
checkbox {
bind(viperSettingsModel.thanksProperty)
}
}
}
@@ -73,8 +70,4 @@ class ViperSettingsFragment : Fragment("Viper Settings") {
}
}
}
override fun onUndock() {
coroutineScope.cancel()
}
}
@@ -12,6 +12,7 @@ import me.vripper.gui.components.fragments.AddLinksFragment
import me.vripper.gui.components.fragments.SettingsFragment
import me.vripper.gui.controller.ActionBarController
import me.vripper.gui.controller.PostController
import me.vripper.gui.controller.SettingsController
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
@@ -21,6 +22,7 @@ class ActionBarView : View() {
private val postController: PostController by inject()
private val postsTableView: PostsTableView by inject()
private val actionBarController: ActionBarController by inject()
private val settingsController: SettingsController by inject()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val running = SimpleIntegerProperty(0)
@@ -28,7 +30,7 @@ class ActionBarView : View() {
init {
with(root) {
id = "action_toolbar"
this.id = "action_toolbar"
padding = insets(all = 5)
button("Add links", FontIcon.of(Feather.PLUS)) {
contentDisplay = ContentDisplay.GRAPHIC_ONLY
@@ -79,7 +81,7 @@ class ActionBarView : View() {
coroutineScope.launch {
val clearPosts = async { postController.clearPosts() }.await()
runLater {
postsTableView.tableView.items.removeIf { clearPosts.contains(it.postId) }
postsTableView.tableView.items.removeIf { clearPosts.contains(it.vgPostId) }
}
}
}
@@ -90,10 +92,23 @@ class ActionBarView : View() {
contentDisplay = ContentDisplay.GRAPHIC_ONLY
tooltip("Open settings menu [Ctrl+P]")
action {
find<SettingsFragment>().openModal()?.apply {
minWidth = 100.0
minHeight = 100.0
coroutineScope.launch {
val downloadSettings = settingsController.findDownloadSettings()
val connectionSettings = settingsController.findConnectionSettings()
val viperGirlsSettings = settingsController.findViperGirlsSettings()
val systemSettings = settingsController.findSystemSettings()
runLater {
find<SettingsFragment>(
mapOf(
SettingsFragment::downloadSettings to downloadSettings,
SettingsFragment::connectionSettings to connectionSettings,
SettingsFragment::viperSettings to viperGirlsSettings,
SettingsFragment::systemSettings to systemSettings,
)
).openModal()
}
}
}
}
separator(Orientation.VERTICAL)
@@ -1,8 +1,7 @@
package me.vripper.gui.components.views
import atlantafx.base.theme.CupertinoDark
import atlantafx.base.theme.CupertinoLight
import javafx.application.Application
import atlantafx.base.theme.*
import javafx.application.Application.setUserAgentStylesheet
import kotlinx.coroutines.runBlocking
import me.vripper.gui.controller.MainController
import me.vripper.gui.controller.WidgetsController
@@ -52,11 +51,16 @@ class AppView : View() {
}
}
widgetsController.currentSettings.darkModeProperty.onChange {
if (it) {
Application.setUserAgentStylesheet(CupertinoDark().userAgentStylesheet)
} else {
Application.setUserAgentStylesheet(CupertinoLight().userAgentStylesheet)
widgetsController.currentSettings.themeProperty.onChange {
when (it) {
"CupertinoLight" -> setUserAgentStylesheet(CupertinoLight().userAgentStylesheet)
"CupertinoDark" -> setUserAgentStylesheet(CupertinoDark().userAgentStylesheet)
"NordLight" -> setUserAgentStylesheet(NordLight().userAgentStylesheet)
"NordDark" -> setUserAgentStylesheet(NordDark().userAgentStylesheet)
"PrimerLight" -> setUserAgentStylesheet(PrimerLight().userAgentStylesheet)
"PrimerDark" -> setUserAgentStylesheet(PrimerDark().userAgentStylesheet)
"Dracula" -> setUserAgentStylesheet(Dracula().userAgentStylesheet)
else -> setUserAgentStylesheet(CupertinoLight().userAgentStylesheet)
}
}
@@ -25,19 +25,17 @@ import me.vripper.gui.utils.openLink
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
import kotlin.io.path.Path
class ImagesTableView : View("Photos") {
override val root = vbox(alignment = Pos.CENTER_RIGHT) {}
private val tableView: TableView<ImageModel>
private val imageController: ImageController by inject()
private val widgetsController: WidgetsController by inject()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val items: ObservableList<ImageModel> = FXCollections.observableArrayList()
private var preview: Preview? = null
val jobs = mutableListOf<Job>()
override val root = vbox(alignment = Pos.CENTER_RIGHT) {}
private val preview: Preview = Preview(currentStage!!)
init {
with(root) {
@@ -73,25 +71,21 @@ class ImagesTableView : View("Photos") {
}
}
cellFactory = Callback {
val cell = PreviewTableCell<ImageModel>()
val cell = PreviewTableCell<ImageModel, String>()
cell.onMouseExited = EventHandler {
preview?.hide()
preview.cleanup()
}
cell.onMouseMoved = EventHandler {
preview?.previewPopup?.apply {
preview.previewPopup.apply {
x = it.screenX + 20
y = it.screenY + 10
}
}
cell.onMouseEntered = EventHandler { mouseEvent ->
preview?.hide()
preview.cleanup()
if (cell.tableRow.item != null && cell.tableRow.item.thumbUrl.isNotEmpty()) {
preview = Preview(
currentStage!!,
cell.tableRow.item.thumbUrl,
Path(widgetsController.currentSettings.cachePath)
)
preview?.previewPopup?.apply {
preview.display(cell.tableRow.item.postEntityId, listOf(cell.tableRow.item.thumbUrl))
preview.previewPopup.apply {
x = mouseEvent.screenX + 20
y = mouseEvent.screenY + 10
}
@@ -241,7 +235,7 @@ class ImagesTableView : View("Photos") {
modalStage?.width = 550.0
}
fun setPostId(postId: Long?) {
fun setPostId(id: Long?) {
runBlocking {
jobs.forEach { it.cancelAndJoin() }
jobs.clear()
@@ -249,11 +243,11 @@ class ImagesTableView : View("Photos") {
runLater {
items.clear()
}
if (postId == null) {
if (id == null) {
return
}
coroutineScope.launch {
val list = imageController.findImages(postId)
val list = imageController.findImages(id)
runLater {
items.addAll(list)
tableView.sort()
@@ -262,7 +256,7 @@ class ImagesTableView : View("Photos") {
}
coroutineScope.launch {
imageController.onUpdateImages(postId).collect { image ->
imageController.onUpdateImages(id).collect { image ->
runLater {
val imageModel = items.find { it.id == image.id } ?: return@runLater
@@ -1,9 +1,18 @@
package me.vripper.gui.components.views
import javafx.beans.property.SimpleStringProperty
import javafx.scene.control.ProgressIndicator.INDETERMINATE_PROGRESS
import javafx.scene.effect.DropShadow
import kotlinx.coroutines.*
import javafx.scene.input.KeyCode
import javafx.scene.input.KeyCodeCombination
import javafx.scene.input.KeyCombination
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import me.vripper.gui.VripperGuiApplication
import me.vripper.gui.components.fragments.SessionFragment
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.services.GrpcEndpointService
@@ -12,6 +21,8 @@ import me.vripper.gui.utils.ClipboardManager
import me.vripper.gui.utils.Watcher
import me.vripper.listeners.AppManager
import me.vripper.utilities.DatabaseManager
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
class LoadingView : View("VRipper") {
@@ -19,6 +30,7 @@ class LoadingView : View("VRipper") {
private val widgetsController: WidgetsController by inject()
private val grpcEndpointService: GrpcEndpointService by di("remoteAppEndpointService")
private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val message = SimpleStringProperty("")
override val root = borderpane {}
@@ -27,16 +39,31 @@ class LoadingView : View("VRipper") {
ClipboardManager.init()
coroutineScope.launch {
GuiEventBus.events.filterIsInstance(GuiEventBus.ApplicationInitialized::class).collect {
message.set("")
AppManager.stop()
grpcEndpointService.disconnect()
if (widgetsController.currentSettings.localSession) {
AppEndpointManager.set(GuiEventBus.LocalSession)
GuiEventBus.publishEvent(GuiEventBus.LocalSession)
AppManager.start()
} else {
AppEndpointManager.set(GuiEventBus.RemoteSession)
GuiEventBus.publishEvent(GuiEventBus.RemoteSession)
grpcEndpointService.connect(
widgetsController.currentSettings.remoteSessionModel.host,
widgetsController.currentSettings.remoteSessionModel.port,
widgetsController.currentSettings.remoteSessionModel.passcode,
)
val check = grpcEndpointService.versionCheck()
if (check == null) {
message.set("Unable to connect to ${widgetsController.currentSettings.remoteSessionModel.host}:${widgetsController.currentSettings.remoteSessionModel.port}")
return@collect
} else if (!check) {
println("Version Mismatch")
message.set("Version Mismatch, client must be >= 6.6.0")
return@collect
}
}
runLater {
replaceWith(find<AppView>())
@@ -49,16 +76,40 @@ class LoadingView : View("VRipper") {
with(root) {
padding = insets(all = 5)
top {
menubar {
menu("File") {
item("Change session", KeyCodeCombination(KeyCode.S, KeyCombination.SHIFT_DOWN)) {
graphic = FontIcon.of(Feather.LINK_2)
action {
find<SessionFragment>().openModal()?.apply {
minWidth = 100.0
minHeight = 100.0
}
}
}
separator()
item("Exit", KeyCodeCombination(KeyCode.X, KeyCombination.CONTROL_DOWN)).apply {
graphic = FontIcon.of(Feather.X_SQUARE)
action {
VripperGuiApplication.APP_INSTANCE.stop()
}
}
}
}.visibleWhen { message.isNotEmpty }
}
center {
progressindicator {
progress = INDETERMINATE_PROGRESS
}
}.visibleWhen { message.isEmpty }
text(message).visibleWhen { message.isNotEmpty }
}
effect = DropShadow()
}
}
override fun onUndock() {
coroutineScope.cancel()
}
// override fun onUndock() {
// println("Undowking")
// coroutineScope.cancel()
// }
}
@@ -3,6 +3,7 @@ package me.vripper.gui.components.views
import javafx.beans.property.SimpleBooleanProperty
import javafx.beans.property.SimpleIntegerProperty
import javafx.scene.control.ButtonType
import javafx.scene.control.ToggleGroup
import javafx.scene.input.KeyCode
import javafx.scene.input.KeyCodeCombination
import javafx.scene.input.KeyCombination
@@ -14,6 +15,7 @@ import me.vripper.gui.components.fragments.SessionFragment
import me.vripper.gui.components.fragments.SettingsFragment
import me.vripper.gui.controller.ActionBarController
import me.vripper.gui.controller.PostController
import me.vripper.gui.controller.SettingsController
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.utils.openLink
import me.vripper.services.IAppEndpointService
@@ -29,6 +31,7 @@ class MenuBarView : View() {
private val widgetsController: WidgetsController by inject()
private val postController: PostController by inject()
private val actionBarController: ActionBarController by inject()
private val settingsController: SettingsController by inject()
private lateinit var appEndpointService: IAppEndpointService
private val running = SimpleIntegerProperty(0)
@@ -81,7 +84,7 @@ class MenuBarView : View() {
coroutineScope.launch {
val clearPosts = async { postController.clearPosts() }.await()
runLater {
postsTableView.tableView.items.removeIf { clearPosts.contains(it.postId) }
postsTableView.tableView.items.removeIf { clearPosts.contains(it.vgPostId) }
}
}
}
@@ -91,9 +94,21 @@ class MenuBarView : View() {
item("Settings", KeyCodeCombination(KeyCode.P, KeyCombination.CONTROL_DOWN)).apply {
graphic = FontIcon.of(Feather.SETTINGS)
action {
find<SettingsFragment>().openModal()?.apply {
minWidth = 100.0
minHeight = 100.0
coroutineScope.launch {
val downloadSettings = settingsController.findDownloadSettings()
val connectionSettings = settingsController.findConnectionSettings()
val viperGirlsSettings = settingsController.findViperGirlsSettings()
val systemSettings = settingsController.findSystemSettings()
runLater {
find<SettingsFragment>(
mapOf(
SettingsFragment::downloadSettings to downloadSettings,
SettingsFragment::connectionSettings to connectionSettings,
SettingsFragment::viperSettings to viperGirlsSettings,
SettingsFragment::systemSettings to systemSettings,
)
).openModal()
}
}
}
}
@@ -125,9 +140,64 @@ class MenuBarView : View() {
checkmenuitem(
"Status Bar", KeyCodeCombination(KeyCode.F8)
).bind(widgetsController.currentSettings.visibleStatusBarPanelProperty)
checkmenuitem(
"Dark mode"
).bind(widgetsController.currentSettings.darkModeProperty)
menu("Theme") {
val toggleGroup = ToggleGroup()
val cupertinoLight = radiomenuitem("Cupertino Light", toggleGroup).apply {
this.selectedProperty().onChange {
widgetsController.currentSettings.theme = "CupertinoLight"
widgetsController.currentSettings.darkMode = false
}
}
val cupertinoDark = radiomenuitem("Cupertino Dark", toggleGroup).apply {
this.selectedProperty().onChange {
widgetsController.currentSettings.theme = "CupertinoDark"
widgetsController.currentSettings.darkMode = true
}
}
val nordLight = radiomenuitem("Nord Light", toggleGroup).apply {
this.selectedProperty().onChange {
widgetsController.currentSettings.theme = "NordLight"
widgetsController.currentSettings.darkMode = false
}
}
val nordDark = radiomenuitem("Nord Dark", toggleGroup).apply {
this.selectedProperty().onChange {
widgetsController.currentSettings.theme = "NordDark"
widgetsController.currentSettings.darkMode = true
}
}
val primerLight = radiomenuitem("Primer Light", toggleGroup).apply {
this.selectedProperty().onChange {
widgetsController.currentSettings.theme = "PrimerLight"
widgetsController.currentSettings.darkMode = false
}
}
val primerDark = radiomenuitem("Primer Dark", toggleGroup).apply {
this.selectedProperty().onChange {
widgetsController.currentSettings.theme = "PrimerDark"
widgetsController.currentSettings.darkMode = true
}
}
val dracula = radiomenuitem("Dracula", toggleGroup).apply {
this.selectedProperty().onChange {
widgetsController.currentSettings.theme = "Dracula"
widgetsController.currentSettings.darkMode = true
}
}
when (widgetsController.currentSettings.theme) {
"CupertinoLight" -> cupertinoLight.isSelected = true
"CupertinoDark" -> cupertinoDark.isSelected = true
"NordLight" -> nordLight.isSelected = true
"NordDark" -> nordDark.isSelected = true
"PrimerLight" -> primerLight.isSelected = true
"PrimerDark" -> primerDark.isSelected = true
"Dracula" -> dracula.isSelected = true
else -> cupertinoLight.isSelected = true
}
}
}
menu("Help") {
item("Database migration").apply {
@@ -18,14 +18,14 @@ class PostInfoView : View() {
private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val imagesTableView: ImagesTableView by inject()
private val postModel: PostModel = PostModel(
-1, "", 0.0, "", "", 0, 0, "", "", -1, "", "", "", emptyList(), emptyList(), "", 0
-1, -1, "", 0.0, "", "", 0, 0, "", "", "*", "", "", "", emptyList(), emptyList(), "", 0
)
override val root = tabpane()
init {
with(root) {
id = "postinfo_panel"
this.id = "postinfo_panel"
tabClosingPolicy = TabPane.TabClosingPolicy.UNAVAILABLE
tab("General") {
graphic = FontIcon(Feather.INFO)
@@ -77,11 +77,10 @@ class PostInfoView : View() {
}
}
fun setPostId(postId: Long?) {
imagesTableView.setPostId(postId)
if (postId == null) {
fun setPostId(id: Long?) {
imagesTableView.setPostId(id)
if (id == null) {
postModel.apply {
this.postId = -1
this.title = ""
this.progress = 0.0
this.status = ""
@@ -90,7 +89,7 @@ class PostInfoView : View() {
this.total = 0
this.hosts = ""
this.addedOn = ""
this.order = -1
this.order = "*"
this.path = ""
this.folderName = ""
this.progressCount = ""
@@ -101,13 +100,15 @@ class PostInfoView : View() {
return
}
coroutineScope.launch {
val model: PostModel? = postController.find(postId)
val model: PostModel? = postController.find(id)
if (model == null) {
return@launch
}
runLater {
postModel.apply {
this.postId = model.postId
this.id = model.id
this.vgPostId = model.vgPostId
this.vgThreadId = model.vgThreadId
this.title = model.title
this.progress = model.progress
this.status = model.status.lowercase().replaceFirstChar { it.uppercase() }
@@ -128,14 +129,13 @@ class PostInfoView : View() {
}
coroutineScope.launch {
postController.updatePostsFlow.filter {
it.postId == postModel.postId
it.id == postModel.id
}.collect { post ->
runLater {
postModel.status = post.status.stringValue.lowercase().replaceFirstChar { it.uppercase() }
postModel.progressCount = postController.progressCount(
post.total, post.done, post.downloaded
)
postModel.order = post.rank + 1
postModel.done = post.done
postModel.progress = postController.progress(
post.total, post.done
@@ -148,7 +148,7 @@ class PostInfoView : View() {
coroutineScope.launch {
postController.updateMetadataFlow.filter {
it.postId == postModel.postId
it.postIdRef == postModel.id
}.collect {
runLater {
postModel.altTitles = FXCollections.observableArrayList(it.data.resolvedNames)
@@ -36,13 +36,13 @@ class PostsTabView : View() {
})
postsTableView.tableView.selectionModel.selectedItemProperty().onChange {
if (widgetsController.currentSettings.visibleInfoPanel) {
postInfoView.setPostId(it?.postId)
postInfoView.setPostId(it?.id)
}
}
widgetsController.currentSettings.visibleInfoPanelProperty.onChange { visible ->
if (visible) {
val selectedItem = postsTableView.tableView.selectedItem
postInfoView.setPostId(selectedItem?.postId)
postInfoView.setPostId(selectedItem?.id)
root.add(postInfoView)
root.setDividerPositions(widgetsController.currentSettings.infoPanelDividerPosition)
} else {
@@ -3,6 +3,7 @@ package me.vripper.gui.components.views
import atlantafx.base.theme.Styles
import atlantafx.base.theme.Tweaks
import javafx.collections.FXCollections
import javafx.collections.ObservableList
import javafx.event.EventHandler
import javafx.geometry.Pos
import javafx.scene.control.*
@@ -11,13 +12,10 @@ import javafx.scene.input.KeyCode
import javafx.scene.input.KeyEvent
import javafx.scene.input.MouseButton
import javafx.util.Callback
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.javafx.asFlow
import kotlinx.coroutines.launch
import me.vripper.gui.components.Shared
import me.vripper.gui.components.cells.PreviewTableCell
import me.vripper.gui.components.cells.ProgressTableCell
@@ -31,12 +29,16 @@ import me.vripper.gui.model.PostModel
import me.vripper.gui.utils.Preview
import me.vripper.gui.utils.openFileDirectory
import me.vripper.gui.utils.openLink
import me.vripper.model.QueueState
import me.vripper.services.download.MovePosition
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
import kotlin.io.path.Path
@OptIn(ExperimentalCoroutinesApi::class)
class PostsTableView : View() {
override val root = vbox {}
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val postController: PostController by inject()
private val widgetsController: WidgetsController by inject()
@@ -44,15 +46,14 @@ class PostsTableView : View() {
val tableView: TableView<PostModel>
var items: SortedFilteredList<PostModel> = SortedFilteredList()
private var preview: Preview? = null
override val root = vbox {}
private val preview: Preview = Preview(currentStage!!)
init {
items.filterWhen(Shared.searchInput) { query, item ->
item.title.contains(query, ignoreCase = true)
|| item.postId.toString().contains(query)
|| item.threadId.toString().contains(query)
|| item.vgPostId.toString().contains(query)
|| item.vgThreadId.toString().contains(query)
|| item.hosts.contains(query, ignoreCase = true)
|| item.status.contains(query, ignoreCase = true)
|| item.path.contains(query, ignoreCase = true)
@@ -123,6 +124,42 @@ class PostsTableView : View() {
graphic = FontIcon.of(Feather.LINK)
}
val moveToTop = MenuItem("Move to top").apply {
setOnAction {
coroutineScope.launch {
postController.moveTo(tableRow.item.id, MovePosition.TOP)
}
}
graphic = FontIcon.of(Feather.CHEVRONS_UP)
}
val moveUp = MenuItem("Move up").apply {
setOnAction {
coroutineScope.launch {
postController.moveTo(tableRow.item.id, MovePosition.UP)
}
}
graphic = FontIcon.of(Feather.CHEVRON_UP)
}
val moveDown = MenuItem("Move down").apply {
setOnAction {
coroutineScope.launch {
postController.moveTo(tableRow.item.id, MovePosition.DOWN)
}
}
graphic = FontIcon.of(Feather.CHEVRON_DOWN)
}
val moveToBottom = MenuItem("Move to bottom").apply {
setOnAction {
coroutineScope.launch {
postController.moveTo(tableRow.item.id, MovePosition.BOTTOM)
}
}
graphic = FontIcon.of(Feather.CHEVRONS_DOWN)
}
val contextMenu = ContextMenu()
contextMenu.items.addAll(
startItem,
@@ -132,7 +169,12 @@ class PostsTableView : View() {
deleteItem,
SeparatorMenuItem(),
locationItem,
urlItem
urlItem,
SeparatorMenuItem(),
moveToTop,
moveUp,
moveDown,
moveToBottom
)
tableRow.contextMenuProperty()
.bind(tableRow.emptyProperty().map { empty -> if (empty) null else contextMenu })
@@ -150,6 +192,23 @@ class PostsTableView : View() {
}
}
})
column("#", PostModel::orderProperty) {
isVisible = widgetsController.currentSettings.postsColumnsModel.orderProperty.get()
visibleProperty().onChange {
widgetsController.currentSettings.postsColumnsModel.orderProperty.set(
it
)
}
prefWidth = widgetsController.currentSettings.postsColumnsWidthModel.order
coroutineScope.launch {
widthProperty().asFlow().debounce(200).collect {
widgetsController.currentSettings.postsColumnsWidthModel.order = it as Double
}
}
cellFactory = Callback {
TextFieldTableCell<PostModel?, String?>().apply { alignment = Pos.CENTER_LEFT }
}
}
column("Preview", PostModel::previewListProperty) {
isVisible = widgetsController.currentSettings.postsColumnsModel.previewProperty.get()
visibleProperty().onChange {
@@ -164,25 +223,24 @@ class PostsTableView : View() {
}
}
cellFactory = Callback {
val cell = PreviewTableCell<PostModel>()
val cell = PreviewTableCell<PostModel, ObservableList<String>>()
cell.onMouseExited = EventHandler {
preview?.hide()
preview.cleanup()
}
cell.onMouseMoved = EventHandler {
preview?.previewPopup?.apply {
preview.previewPopup.apply {
x = it.screenX + 20
y = it.screenY + 10
}
}
cell.onMouseEntered = EventHandler { mouseEvent ->
preview?.hide()
preview.cleanup()
if (cell.tableRow.item != null && cell.tableRow.item.previewList.isNotEmpty()) {
preview = Preview(
currentStage!!,
cell.tableRow.item.previewList,
Path(widgetsController.currentSettings.cachePath)
preview.display(
cell.tableRow.item.vgThreadId,
cell.tableRow.item.previewList
)
preview?.previewPopup?.apply {
preview.previewPopup.apply {
x = mouseEvent.screenX + 20
y = mouseEvent.screenY + 10
}
@@ -337,27 +395,10 @@ class PostsTableView : View() {
widgetsController.currentSettings.postsColumnsWidthModel.addedOn = it as Double
}
}
cellFactory = Callback {
TextFieldTableCell<PostModel?, String?>().apply { alignment = Pos.CENTER_LEFT }
}
}
column("Order", PostModel::orderProperty) {
isVisible = widgetsController.currentSettings.postsColumnsModel.orderProperty.get()
visibleProperty().onChange {
widgetsController.currentSettings.postsColumnsModel.orderProperty.set(
it
)
}
prefWidth = widgetsController.currentSettings.postsColumnsWidthModel.order
coroutineScope.launch {
widthProperty().asFlow().debounce(200).collect {
widgetsController.currentSettings.postsColumnsWidthModel.order = it as Double
}
}
sortType = TableColumn.SortType.DESCENDING
sortOrder.add(this)
cellFactory = Callback {
TextFieldTableCell<PostModel?, Number?>().apply { alignment = Pos.CENTER_LEFT }
TextFieldTableCell<PostModel?, String?>().apply { alignment = Pos.CENTER_LEFT }
}
}
}
@@ -371,10 +412,12 @@ class PostsTableView : View() {
when (it) {
GuiEventBus.LocalSession, GuiEventBus.RemoteSession -> {
val postModelList = postController.findAllPosts().toList()
val queueState = postController.getQueueState()
runLater {
items.addAll(postModelList)
tableView.sort()
tableView.placeholder = Label("No content in table")
updateQueueState(queueState)
}
}
@@ -389,12 +432,12 @@ class PostsTableView : View() {
}
launch {
postController.updateMetadataFlow.collect {
postController.updateMetadataFlow.collect { metadataEntity ->
runLater {
val postModel = items.find { it.postId == it.postId } ?: return@runLater
val postModel = items.find { it.id == metadataEntity.postIdRef } ?: return@runLater
postModel.altTitles = FXCollections.observableArrayList(it.data.resolvedNames)
postModel.postedBy = it.data.postedBy
postModel.altTitles = FXCollections.observableArrayList(metadataEntity.data.resolvedNames)
postModel.postedBy = metadataEntity.data.postedBy
}
}
}
@@ -402,7 +445,7 @@ class PostsTableView : View() {
launch {
postController.deletedPostsFlow.collect {
runLater {
items.items.removeIf { p -> p.postId == it }
items.items.removeIf { p -> p.id == it }
tableView.sort()
}
}
@@ -411,13 +454,12 @@ class PostsTableView : View() {
launch {
postController.updatePostsFlow.collect { post ->
runLater {
val postModel = items.find { it.postId == post.postId } ?: return@runLater
val postModel = items.find { it.id == post.id } ?: return@runLater
postModel.status = post.status.name
postModel.progressCount = postController.progressCount(
post.total, post.done, post.downloaded
)
postModel.order = post.rank + 1
postModel.done = post.done
postModel.progress = postController.progress(
post.total, post.done
@@ -428,6 +470,12 @@ class PostsTableView : View() {
}
}
launch {
postController.queueStateUpdate.collect { queueState ->
updateQueueState(queueState)
}
}
launch {
postController.newPostsFlow.collect {
runLater {
@@ -439,12 +487,30 @@ class PostsTableView : View() {
}
}
private fun updateQueueState(queueState: QueueState) {
items.forEach { post ->
val rank = queueState.rank.find { it.postEntityId == post.id }
if (rank != null) {
runLater {
post.order = rank.rank.toString()
}
} else if (post.order != "*") {
runLater {
post.order = "*"
}
}
}
runLater {
tableView.sort()
}
}
private fun isCurrentTab(): Boolean = mainView.root.selectionModel.selectedItem.id == "download-tab"
private fun rename(post: PostModel) {
find<RenameFragment>(
mapOf(
RenameFragment::postId to post.postId,
RenameFragment::id to post.id,
RenameFragment::name to post.folderName,
RenameFragment::altTitles to post.altTitles
)
@@ -457,40 +523,40 @@ class PostsTableView : View() {
fun bulkRenameSelected() {
val selectedItems = tableView.selectionModel.selectedItems
coroutineScope.launch {
postController.renameToFirst(selectedItems.map { it.postId })
postController.renameToFirst(selectedItems.map { it.id })
}
}
fun deleteSelected() {
val postIdList = tableView.selectionModel.selectedItems.map { it.postId }
val postEntityIdList = tableView.selectionModel.selectedItems.map { it.id }
confirm(
"",
"Confirm removal of ${postIdList.size} post${if (postIdList.size > 1) "s" else ""}?",
"Confirm removal of ${postEntityIdList.size} post${if (postEntityIdList.size > 1) "s" else ""}?",
ButtonType.YES,
ButtonType.NO,
owner = primaryStage,
title = "Remove posts"
) {
coroutineScope.launch {
postController.delete(postIdList)
postController.delete(postEntityIdList)
runLater {
items.items.removeIf { postIdList.contains(it.postId) }
items.items.removeIf { postEntityIdList.contains(it.id) }
}
}
}
}
fun stopSelected() {
val postIdList = tableView.selectionModel.selectedItems.map { it.postId }
val postEntityIdList = tableView.selectionModel.selectedItems.map { it.id }
coroutineScope.launch {
postController.stop(postIdList)
postController.stop(postEntityIdList)
}
}
fun startSelected() {
val postIdList = tableView.selectionModel.selectedItems.map { it.postId }
val postEntityIdList = tableView.selectionModel.selectedItems.map { it.id }
coroutineScope.launch {
postController.start(postIdList)
postController.start(postEntityIdList)
}
}
}
@@ -107,7 +107,7 @@ class StatusBarView : View("Status bar") {
}
override val root = borderpane {
id = "statusbar"
this.id = "statusbar"
left {
hbox {
label(remoteText) {
@@ -9,8 +9,10 @@ import tornadofx.Controller
class ImageController : Controller() {
suspend fun findImages(postId: Long): List<ImageModel> {
return runCatching { currentAppEndpointService().findImagesByPostId(postId).map(::mapper) }.getOrDefault(
suspend fun findImages(postEntityId: Long): List<ImageModel> {
return runCatching {
currentAppEndpointService().findImagesByPostEntityId(postEntityId).map(::mapper)
}.getOrDefault(
emptyList()
)
}
@@ -25,7 +27,8 @@ class ImageController : Controller() {
it.size,
it.downloaded,
it.filename,
it.thumbUrl
it.thumbUrl,
it.postEntityId
)
}
@@ -38,7 +41,7 @@ class ImageController : Controller() {
}
fun onUpdateImages(postId: Long) =
currentAppEndpointService().onUpdateImagesByPostId(postId).retryWhen { _, _ -> delay(1000);true }
currentAppEndpointService().onUpdateImagesByPostEntityId(postId).retryWhen { _, _ -> delay(1000); true }
fun onStopped() = currentAppEndpointService().onStopped().retryWhen { _, _ -> delay(1000);true }
}
@@ -9,13 +9,15 @@ import me.vripper.gui.utils.AppEndpointManager.remoteAppEndpointService
import me.vripper.gui.utils.ChannelFlowBuilder
import me.vripper.gui.utils.ChannelFlowBuilder.toFlow
import me.vripper.model.Post
import me.vripper.model.QueueState
import me.vripper.services.download.MovePosition
import me.vripper.utilities.formatSI
import tornadofx.Controller
import java.time.format.DateTimeFormatter
class PostController : Controller() {
private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss")
private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
val updatePostsFlow =
ChannelFlowBuilder.build(
@@ -46,24 +48,29 @@ class PostController : Controller() {
remoteAppEndpointService::onUpdateMetadata,
)
val queueStateUpdate = ChannelFlowBuilder.build(
localAppEndpointService::onQueueStateUpdate,
remoteAppEndpointService::onQueueStateUpdate,
)
suspend fun scan(postLinks: String) {
runCatching { currentAppEndpointService().scanLinks(postLinks) }
}
suspend fun start(postIdList: List<Long>) {
runCatching { currentAppEndpointService().restartAll(postIdList) }
suspend fun start(postEntityIdList: List<Long>) {
runCatching { currentAppEndpointService().restartAll(postEntityIdList) }
}
suspend fun startAll() {
runCatching { currentAppEndpointService().restartAll() }
}
suspend fun delete(postIdList: List<Long>) {
runCatching { currentAppEndpointService().remove(postIdList) }
suspend fun delete(postEntityIdList: List<Long>) {
runCatching { currentAppEndpointService().remove(postEntityIdList) }
}
suspend fun stop(postIdList: List<Long>) {
runCatching { currentAppEndpointService().stopAll(postIdList) }
suspend fun stop(postEntityIdList: List<Long>) {
runCatching { currentAppEndpointService().stopAll(postEntityIdList) }
}
suspend fun clearPosts(): List<Long> {
@@ -74,24 +81,33 @@ class PostController : Controller() {
runCatching { currentAppEndpointService().stopAll() }
}
suspend fun find(postId: Long): PostModel? {
return runCatching { mapper(currentAppEndpointService().findPost(postId)) }.getOrNull()
suspend fun find(postEntityId: Long): PostModel? {
return runCatching { mapper(currentAppEndpointService().findPost(postEntityId)) }.getOrNull()
}
fun findAllPosts(): Flow<PostModel> {
return toFlow { currentAppEndpointService().findAllPosts().map(::mapper) }
}
suspend fun rename(postId: Long, value: String) {
runCatching { currentAppEndpointService().rename(postId, value) }
suspend fun getQueueState(): QueueState {
return runCatching { currentAppEndpointService().getQueueState() }.getOrDefault(QueueState(0, 0))
}
suspend fun renameToFirst(postIds: List<Long>) {
runCatching { currentAppEndpointService().renameToFirst(postIds) }
suspend fun rename(postEntityId: Long, value: String) {
runCatching { currentAppEndpointService().rename(postEntityId, value) }
}
suspend fun renameToFirst(postEntityIds: List<Long>) {
runCatching { currentAppEndpointService().renameToFirst(postEntityIds) }
}
suspend fun moveTo(postEntityId: Long, position: MovePosition) {
runCatching { currentAppEndpointService().move(postEntityId, position) }
}
private fun mapper(post: Post): PostModel {
return PostModel(
post.id,
post.postId,
post.postTitle,
progress(post.total, post.done),
@@ -101,7 +117,7 @@ class PostController : Controller() {
post.total,
post.hosts.joinToString(separator = ", "),
post.addedOn.format(dateTimeFormatter),
post.rank + 1,
"*",
post.getDownloadFolder(),
post.folderName,
progressCount(post.total, post.done, post.downloaded),
@@ -15,6 +15,7 @@ class WidgetsController : Controller() {
it.visibleToolbarPanel,
it.visibleStatusBarPanel,
it.darkMode,
it.theme,
it.infoPanelDividerPosition,
it.width,
it.height,
@@ -49,6 +50,9 @@ class WidgetsController : Controller() {
currentSettings.darkModeProperty.onChange {
WidgetSettings.update(currentSettings)
}
currentSettings.themeProperty.onChange {
WidgetSettings.update(currentSettings)
}
currentSettings.postsColumnsModel.previewProperty.onChange {
WidgetSettings.update(currentSettings)
}
@@ -1,8 +1,9 @@
package me.vripper.gui.model
import javafx.beans.property.*
import javafx.collections.FXCollections
import javafx.collections.ObservableList
import javafx.beans.property.SimpleDoubleProperty
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
@@ -17,6 +18,7 @@ class ImageModel(
downloaded: Long,
filename: String,
thumbUrl: String,
val postEntityId: Long
) {
val idProperty = SimpleLongProperty(id)
@@ -37,8 +39,8 @@ class ImageModel(
val filenameProperty = SimpleStringProperty(filename)
var filename: String by filenameProperty
val thumbUrlProperty = SimpleListProperty(FXCollections.observableArrayList(thumbUrl))
var thumbUrl: ObservableList<String> by thumbUrlProperty
val thumbUrlProperty = SimpleStringProperty(thumbUrl)
var thumbUrl: String by thumbUrlProperty
val sizeProperty = SimpleStringProperty(size.formatSI())
var size = size
@@ -1,13 +1,17 @@
package me.vripper.gui.model
import javafx.beans.property.*
import javafx.beans.property.SimpleDoubleProperty
import javafx.beans.property.SimpleIntegerProperty
import javafx.beans.property.SimpleListProperty
import javafx.beans.property.SimpleStringProperty
import javafx.collections.FXCollections
import javafx.collections.ObservableList
import tornadofx.getValue
import tornadofx.setValue
class PostModel(
postId: Long,
var id: Long,
var vgPostId: Long,
title: String,
progress: Double,
status: String,
@@ -16,17 +20,15 @@ class PostModel(
total: Int,
hosts: String,
addedOn: String,
order: Int,
order: String,
path: String,
folderName: String,
progressCount: String,
previewList: List<String>,
altTitles: List<String>,
postedBy: String,
val threadId: Long
var vgThreadId: Long
) {
val postIdProperty = SimpleLongProperty(postId)
var postId: Long by postIdProperty
val titleProperty = SimpleStringProperty(title)
var title: String by titleProperty
@@ -52,8 +54,8 @@ class PostModel(
val addedOnProperty = SimpleStringProperty(addedOn)
var addedOn: String by addedOnProperty
val orderProperty = SimpleIntegerProperty(order)
var order: Int by orderProperty
val orderProperty = SimpleStringProperty(order)
var order: String by orderProperty
val pathProperty = SimpleStringProperty(path)
var path: String by pathProperty
@@ -23,6 +23,7 @@ class WidgetsViewModel(
visibleToolbarPanel: Boolean,
visibleStatusBarPanel: Boolean,
darkMode: Boolean,
theme: String,
infoPanelDividerPosition: Double,
width: Double,
height: Double,
@@ -58,6 +59,9 @@ class WidgetsViewModel(
val darkModeProperty = SimpleBooleanProperty(darkMode)
var darkMode: Boolean by darkModeProperty
val themeProperty = SimpleStringProperty(theme)
var theme: String by themeProperty
val widthProperty = SimpleDoubleProperty(width)
var width: Double by widthProperty
@@ -12,11 +12,13 @@ import me.vripper.model.DownloadSpeed
import me.vripper.model.ErrorCount
import me.vripper.model.PostSelection
import me.vripper.model.QueueState
import me.vripper.model.Rank
import me.vripper.model.ThreadPostId
import me.vripper.proto.*
import me.vripper.proto.EndpointServiceOuterClass.*
import me.vripper.proto.LogOuterClass.Log
import me.vripper.services.IAppEndpointService
import me.vripper.services.download.MovePosition
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@@ -29,36 +31,36 @@ internal class GrpcEndpointService : IAppEndpointService {
endpointServiceCoroutineStub!!.scanLinks(Links.newBuilder().setLinks(postLinks).build())
}
override suspend fun restartAll(posIds: List<Long>) {
endpointServiceCoroutineStub!!.restartAll(IdList.newBuilder().addAllIds(posIds).build())
override suspend fun restartAll(postEntityIds: List<Long>) {
endpointServiceCoroutineStub!!.restartAll(IdList.newBuilder().addAllIds(postEntityIds).build())
}
override suspend fun remove(postIdList: List<Long>) {
endpointServiceCoroutineStub!!.remove(IdList.newBuilder().addAllIds(postIdList).build())
override suspend fun remove(postEntityIds: List<Long>) {
endpointServiceCoroutineStub!!.remove(IdList.newBuilder().addAllIds(postEntityIds).build())
}
override suspend fun stopAll(postIdList: List<Long>) {
endpointServiceCoroutineStub!!.stopAll(IdList.newBuilder().addAllIds(postIdList).build())
override suspend fun stopAll(postEntityIds: List<Long>) {
endpointServiceCoroutineStub!!.stopAll(IdList.newBuilder().addAllIds(postEntityIds).build())
}
override suspend fun clearCompleted(): List<Long> {
return endpointServiceCoroutineStub!!.clearCompleted(EmptyRequest.getDefaultInstance()).idsList
}
override suspend fun findPost(postId: Long): Post =
mapper(endpointServiceCoroutineStub!!.findPost(Id.newBuilder().setId(postId).build()))
override suspend fun findPost(postEntityId: Long): Post =
mapper(endpointServiceCoroutineStub!!.findPost(Id.newBuilder().setId(postEntityId).build()))
override suspend fun findAllPosts(): List<Post> =
endpointServiceCoroutineStub!!.findAllPosts(EmptyRequest.getDefaultInstance()).postsList.map(::mapper)
override suspend fun rename(postId: Long, newName: String) {
override suspend fun rename(postEntityId: Long, newName: String) {
endpointServiceCoroutineStub!!.rename(
Rename.newBuilder().setPostId(postId).setName(newName).build()
Rename.newBuilder().setPostId(postEntityId).setName(newName).build()
)
}
override suspend fun renameToFirst(postIds: List<Long>) {
endpointServiceCoroutineStub!!.renameToFirst(RenameToFirst.newBuilder().addAllPostIds(postIds).build())
override suspend fun renameToFirst(postEntityIds: List<Long>) {
endpointServiceCoroutineStub!!.renameToFirst(RenameToFirst.newBuilder().addAllPostIds(postEntityIds).build())
}
override fun onNewPosts(): Flow<Post> =
@@ -71,24 +73,23 @@ internal class GrpcEndpointService : IAppEndpointService {
}
override fun onDeletePosts() =
endpointServiceCoroutineStub!!.onDeletePosts(EmptyRequest.getDefaultInstance()).map {
it.id
}
override fun onDeletePosts() = endpointServiceCoroutineStub!!.onDeletePosts(EmptyRequest.getDefaultInstance()).map {
it.id
}
override fun onUpdateMetadata(): Flow<Metadata> =
endpointServiceCoroutineStub!!.onUpdateMetadata(EmptyRequest.getDefaultInstance()).map { mapper(it) }
override suspend fun findImagesByPostId(postId: Long): List<Image> =
override suspend fun findImagesByPostEntityId(postEntityId: Long): List<Image> =
endpointServiceCoroutineStub!!.findImagesByPostId(
Id.newBuilder().setId(postId).build()
Id.newBuilder().setId(postEntityId).build()
).imagesList.map(::mapper)
override fun onUpdateImagesByPostId(postId: Long): Flow<Image> =
endpointServiceCoroutineStub!!.onUpdateImagesByPostId(Id.newBuilder().setId(postId).build()).map(::mapper)
override fun onUpdateImagesByPostEntityId(postEntityId: Long): Flow<Image> =
endpointServiceCoroutineStub!!.onUpdateImagesByPostId(Id.newBuilder().setId(postEntityId).build()).map(::mapper)
override fun onUpdateImages(): Flow<Image> =
endpointServiceCoroutineStub!!.onUpdateImages(EmptyRequest.getDefaultInstance()).map(::mapper)
@@ -125,6 +126,13 @@ internal class GrpcEndpointService : IAppEndpointService {
endpointServiceCoroutineStub!!.threadRemove(IdList.newBuilder().addAllIds(threadIdList).build())
}
override suspend fun move(postEntityId: Long, position: MovePosition) {
endpointServiceCoroutineStub!!.move(
MovePositionMessage.newBuilder().setPostEntityId(postEntityId)
.setPosition(MovePositionEnum.valueOf(position.name)).build()
)
}
override suspend fun threadClear() {
endpointServiceCoroutineStub!!.threadClear(EmptyRequest.getDefaultInstance())
}
@@ -154,8 +162,11 @@ internal class GrpcEndpointService : IAppEndpointService {
endpointServiceCoroutineStub!!.onVGUserUpdate(EmptyRequest.getDefaultInstance()).map { it.user }
override fun onQueueStateUpdate(): Flow<QueueState> =
endpointServiceCoroutineStub!!.onQueueStateUpdate(EmptyRequest.getDefaultInstance())
.map { QueueState(it.running, it.remaining) }
endpointServiceCoroutineStub!!.onQueueStateUpdate(EmptyRequest.getDefaultInstance()).map(::mapper)
override suspend fun getQueueState(): QueueState =
mapper(endpointServiceCoroutineStub!!.getQueueState(EmptyRequest.getDefaultInstance()))
override fun onErrorCountUpdate(): Flow<ErrorCount> =
endpointServiceCoroutineStub!!.onErrorCountUpdate(EmptyRequest.getDefaultInstance())
@@ -232,40 +243,44 @@ internal class GrpcEndpointService : IAppEndpointService {
override suspend fun dbMigration(): String =
endpointServiceCoroutineStub!!.dbMigration(EmptyRequest.getDefaultInstance()).message
private fun mapper(queueState: EndpointServiceOuterClass.QueueState) =
QueueState(queueState.running, queueState.remaining, queueState.rankList.map {
Rank(it.postEntityId, it.rank)
})
private fun mapper(settings: SettingsOuterClass.Settings): Settings =
Settings(
viperSettings = ViperSettings(
login = settings.viperSettings.login,
username = settings.viperSettings.username,
password = settings.viperSettings.password,
thanks = settings.viperSettings.thanks,
host = settings.viperSettings.host,
requestLimit = settings.viperSettings.requestLimit,
),
downloadSettings = DownloadSettings(
downloadPath = settings.downloadSettings.downloadPath,
autoStart = settings.downloadSettings.autoStart,
autoQueueThreshold = settings.downloadSettings.autoQueueThreshold,
forceOrder = settings.downloadSettings.forceOrder,
forumSubDirectory = settings.downloadSettings.forumSubDirectory,
threadSubLocation = settings.downloadSettings.threadSubLocation,
clearCompleted = settings.downloadSettings.clearCompleted,
appendPostId = settings.downloadSettings.appendPostId,
),
connectionSettings = ConnectionSettings(
maxConcurrentPerHost = settings.connectionSettings.maxConcurrentPerHost,
maxGlobalConcurrent = settings.connectionSettings.maxGlobalConcurrent,
timeout = settings.connectionSettings.timeout,
maxAttempts = settings.connectionSettings.maxAttempts,
),
systemSettings = SystemSettings(
tempPath = settings.systemSettings.tempPath,
enableClipboardMonitoring = settings.systemSettings.enableClipboardMonitoring,
clipboardPollingRate = settings.systemSettings.clipboardPollingRate,
maxEventLog = settings.systemSettings.maxEventLog,
),
)
private fun mapper(settings: SettingsOuterClass.Settings): Settings = Settings(
viperSettings = ViperSettings(
login = settings.viperSettings.login,
username = settings.viperSettings.username,
password = settings.viperSettings.password,
thanks = settings.viperSettings.thanks,
host = settings.viperSettings.host,
requestLimit = settings.viperSettings.requestLimit,
fetchMetadata = settings.viperSettings.fetchMetadata,
),
downloadSettings = DownloadSettings(
downloadPath = settings.downloadSettings.downloadPath,
autoStart = settings.downloadSettings.autoStart,
autoQueueThreshold = settings.downloadSettings.autoQueueThreshold,
forceOrder = settings.downloadSettings.forceOrder,
forumSubDirectory = settings.downloadSettings.forumSubDirectory,
threadSubLocation = settings.downloadSettings.threadSubLocation,
clearCompleted = settings.downloadSettings.clearCompleted,
appendPostId = settings.downloadSettings.appendPostId,
),
connectionSettings = ConnectionSettings(
maxConcurrentPerHost = settings.connectionSettings.maxConcurrentPerHost,
maxGlobalConcurrent = settings.connectionSettings.maxGlobalConcurrent,
timeout = settings.connectionSettings.timeout,
maxAttempts = settings.connectionSettings.maxAttempts,
),
systemSettings = SystemSettings(
tempPath = settings.systemSettings.tempPath,
enableClipboardMonitoring = settings.systemSettings.enableClipboardMonitoring,
clipboardPollingRate = settings.systemSettings.clipboardPollingRate,
maxEventLog = settings.systemSettings.maxEventLog,
),
)
private fun mapper(postEntity: PostOuterClass.Post): Post {
@@ -285,7 +300,6 @@ internal class GrpcEndpointService : IAppEndpointService {
folderName = postEntity.folderName,
status = Status.valueOf(postEntity.status),
done = postEntity.done,
rank = postEntity.rank,
size = postEntity.size,
downloaded = postEntity.downloaded,
previews = postEntity.previewsList.toList(),
@@ -297,12 +311,11 @@ internal class GrpcEndpointService : IAppEndpointService {
private fun mapper(image: ImageOuterClass.Image): Image {
return Image(
id = image.id,
postId = image.postId,
url = image.url,
thumbUrl = image.thumbUrl,
host = image.host.toByte(),
index = image.index,
postIdRef = image.postIdRef,
postEntityId = image.postIdRef,
size = image.size,
downloaded = image.downloaded,
status = Status.valueOf(image.status),
@@ -312,8 +325,7 @@ internal class GrpcEndpointService : IAppEndpointService {
private fun mapper(metadata: MetadataOuterClass.Metadata): Metadata {
return Metadata(
metadata.postId,
MetadataEntity.Data(metadata.postedBy, metadata.resolvedNamesList)
metadata.postId, MetadataEntity.Data(metadata.postedBy, metadata.resolvedNamesList)
)
}
@@ -356,12 +368,8 @@ internal class GrpcEndpointService : IAppEndpointService {
fun connect(host: String, port: Int, passPhrase: String) {
disconnect()
channel = ManagedChannelBuilder
.forAddress(host, port)
.maxInboundMessageSize(Integer.MAX_VALUE)
.usePlaintext()
.intercept(ClientE2eEncryptingInterceptor(passPhrase))
.build()
channel = ManagedChannelBuilder.forAddress(host, port).maxInboundMessageSize(Integer.MAX_VALUE).usePlaintext()
.intercept(ClientE2eEncryptingInterceptor(passPhrase)).build()
endpointServiceCoroutineStub = EndpointServiceGrpcKt.EndpointServiceCoroutineStub(channel!!)
}
@@ -373,5 +381,19 @@ internal class GrpcEndpointService : IAppEndpointService {
}
}
suspend fun versionCheck(): Boolean? {
val result = runCatching {
val version = getVersion()
version >= "6.6.0"
}
return if (result.isSuccess) {
result.getOrNull()
} else {
null
}
}
override fun connectionState(): String = channel?.getState(true)?.name ?: ConnectivityState.SHUTDOWN.name
}
@@ -1,9 +1,5 @@
package me.vripper.gui.utils
import com.github.benmanes.caffeine.cache.Caffeine
import com.github.benmanes.caffeine.cache.LoadingCache
import javafx.collections.FXCollections
import javafx.collections.ObservableList
import javafx.geometry.Pos
import javafx.scene.image.Image
import javafx.scene.image.ImageView
@@ -11,87 +7,76 @@ import javafx.scene.layout.HBox
import javafx.stage.Popup
import javafx.stage.Stage
import kotlinx.coroutines.*
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.hash256
import tornadofx.bind
import tornadofx.clear
import tornadofx.runLater
import tornadofx.sortWith
import java.io.ByteArrayInputStream
import java.net.URI
import java.nio.file.Files
import java.nio.file.Path
class Preview(owner: Stage, private val images: List<String>, private val cachePath: Path) {
class Preview(val owner: Stage) {
private var hBox: HBox = HBox().apply { spacing = 5.0; alignment = Pos.BOTTOM_CENTER }
private val log by LoggerDelegate()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val previewPopup = Popup()
private val cache: LoadingCache<String, ByteArray> = Caffeine
.newBuilder()
.weigher { _: String, value: ByteArray -> value.size }
.maximumWeight(1024 * 1024 * 100)
.build(::load)
private fun load(url: String): ByteArray {
val path = cachePath.resolve(url.hash256())
if (Files.exists(path) && Files.isRegularFile(path)) {
return Files.readAllBytes(path)
}
return URI.create(url).toURL().openStream().use { `is` ->
val bytes = `is`.readAllBytes()
Files.write(path, bytes)
bytes
}
val previewPopup = Popup().apply {
content.add(hBox)
}
init {
Files.createDirectories(cachePath)
val loaded: ObservableList<Pair<ImageView, Int>> = FXCollections.observableArrayList()
hBox.children.bind(loaded) {
it.first
}
previewPopup.content.add(hBox)
val jobs = mutableListOf<Job>()
fun display(postEntityId: Long, images: List<String>) {
cleanup()
previewPopup.show(owner)
coroutineScope.launch {
images.forEachIndexed { index, url ->
coroutineScope.launch {
val imageView = previewLoading(url).await()
if (imageView != null) {
val indexedResults = mutableMapOf<ImageView, Int>()
images.forEachIndexed { index, url ->
coroutineScope.launch {
val imageView = previewLoading(postEntityId, url)
if (imageView != null) {
if (isActive) {
runLater {
loaded.add(Pair(imageView, index))
loaded.sortWith { o1, o2 -> o1.second.compareTo(o2.second) }
indexedResults[imageView] = index
hBox.children.add(imageView)
hBox.children.sortWith { o1, o2 ->
val index1 = indexedResults[o1] ?: 0
val index2 = indexedResults[o2] ?: 0
index1.compareTo(index2)
}
}
}
}
}
}.also { synchronized(this) { jobs.add(it) } }
}
}
fun hide() {
coroutineScope.cancel()
fun cleanup() {
synchronized(this) {
jobs.forEach { it.cancel() }
jobs.clear()
}
previewPopup.hide()
hBox.clear()
}
private fun previewLoading(url: String): Deferred<ImageView?> {
return coroutineScope.async {
try {
ByteArrayInputStream(cache[url]).use {
ImageView(Image(it)).apply {
isPreserveRatio = true
fun destroy() {
cleanup()
coroutineScope.cancel()
}
fitWidth = if (image.width > 200.0) {
200.0
} else {
image.width
}
private fun previewLoading(postEntityId: Long, url: String): ImageView? {
return try {
ByteArrayInputStream(PreviewCacheManager.load(postEntityId, url)).use {
ImageView(Image(it)).apply {
isPreserveRatio = true
fitWidth = if (image.width > 200.0) {
200.0
} else {
image.width
}
}
} catch (e: Exception) {
e.printStackTrace()
null
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}
@@ -0,0 +1,113 @@
package me.vripper.gui.utils
import com.google.common.jimfs.Configuration
import com.google.common.jimfs.Jimfs
import kotlinx.coroutines.*
import me.vripper.utilities.hash256
import org.koin.core.component.KoinComponent
import java.net.URI
import java.nio.file.Files
import java.time.LocalDateTime
import java.util.*
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.io.path.Path
import kotlin.io.path.createDirectories
import kotlin.io.path.deleteIfExists
import kotlin.io.path.pathString
object PreviewCacheManager : KoinComponent {
data class Entry(
val postEntityId: Long,
val path: String,
val size: Int,
val date: LocalDateTime = LocalDateTime.now()
) {
override fun hashCode(): Int {
return Objects.hash(postEntityId, path, size)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Entry
if (postEntityId != other.postEntityId) return false
if (path != other.path) return false
if (size != other.size) return false
return true
}
}
private const val MAX_ENTRIES = 10000
private const val THRESHOLD = 1024 * 1024L * 200
private val entries = TreeSet<Entry>(Comparator.comparing { it.date })
private val fs = Jimfs.newFileSystem(Configuration.unix())
private val rootPath = Path("/previews")
private val lock = ReentrantLock()
private var cacheSize = AtomicLong(0)
private val coroutineScope = CoroutineScope(SupervisorJob())
init {
coroutineScope.launch {
while (isActive) {
// println("Cache state check")
// println("Cache entries = ${entries.size}")
// println("Cache entries limit = $MAX_ENTRIES")
// println("Cache size = ${cacheSize.get().formatSI()}")
// println("Cache size LIMIT = ${THRESHOLD.formatSI()}")
val entriesDelta = entries.size - MAX_ENTRIES
if (entriesDelta > 0) {
val deleted = mutableListOf<Entry>()
(1..entriesDelta).forEach { _ ->
val element = lock.withLock {
entries.removeFirst()
}
deleted.add(element)
}
deleted.forEach {
fs.getPath(it.path).deleteIfExists()
}
}
val sizeDelta = cacheSize.get() - THRESHOLD
if (sizeDelta > 0) {
do {
val element = lock.withLock {
entries.removeFirst()
}
cacheSize.addAndGet(element.size * -1L)
} while (cacheSize.get() - THRESHOLD > 0)
}
// println("Cache state check completed")
delay(30_000)
}
}
}
fun load(postEntityId: Long, url: String): ByteArray {
val fileName = url.hash256()
val path = fs.getPath(rootPath.resolve(postEntityId.toString()).resolve(fileName).pathString)
if (Files.exists(path)) {
return Files.readAllBytes(path)
}
path.parent.createDirectories()
return URI.create(url).toURL().openStream().use { `is` ->
val bytes = `is`.readAllBytes()
Files.write(path, bytes)
cacheSize.addAndGet(bytes.size.toLong())
val entry = Entry(postEntityId, path.pathString, bytes.size)
lock.withLock {
entries.add(entry)
}
bytes
}
}
}
@@ -133,6 +133,7 @@ object WidgetSettings {
val visibleToolbarPanel: Boolean = true,
val visibleStatusBarPanel: Boolean = true,
val darkMode: Boolean = false,
val theme: String = "CupertinoLight",
val infoPanelDividerPosition: Double = 0.7,
val width: Double = 1366.0,
val height: Double = 768.0,
@@ -167,6 +168,7 @@ object WidgetSettings {
currentSettings.visibleToolbarPanel,
currentSettings.visibleStatusBarPanel,
currentSettings.darkMode,
currentSettings.theme,
currentSettings.infoPanelDividerPosition,
currentSettings.width,
currentSettings.height,
+1 -1
View File
@@ -14,7 +14,7 @@
</parent>
<properties>
<spring-boot.version>3.4.0</spring-boot.version>
<spring-boot.version>3.5.6</spring-boot.version>
</properties>
<dependencies>
@@ -5,16 +5,14 @@ import kotlinx.coroutines.flow.map
import me.vripper.entities.ImageEntity
import me.vripper.model.*
import me.vripper.proto.*
import me.vripper.proto.EndpointServiceOuterClass.*
import me.vripper.proto.EndpointServiceOuterClass.DownloadSpeed
import me.vripper.proto.EndpointServiceOuterClass.ErrorCount
import me.vripper.proto.EndpointServiceOuterClass.PostSelection
import me.vripper.proto.EndpointServiceOuterClass.QueueState
import me.vripper.proto.ImageOuterClass.Image
import me.vripper.proto.ImagesOuterClass.Images
import me.vripper.proto.LogOuterClass.Log
import me.vripper.proto.ThreadsOuterClass.Threads
import me.vripper.services.IAppEndpointService
import me.vripper.services.download.MovePosition
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.core.qualifier.named
@@ -26,154 +24,166 @@ class GrpcServerAppEndpointService : EndpointServiceGrpcKt.EndpointServiceCorout
private val appEndpointService: IAppEndpointService by inject(named("localAppEndpointService"))
override suspend fun scanLinks(request: Links): EmptyResponse {
override suspend fun scanLinks(request: EndpointServiceOuterClass.Links): EndpointServiceOuterClass.EmptyResponse {
appEndpointService.scanLinks(request.links)
return EmptyResponse.getDefaultInstance()
return EndpointServiceOuterClass.EmptyResponse.getDefaultInstance()
}
override fun onNewPosts(request: EmptyRequest): Flow<PostOuterClass.Post> =
override fun onNewPosts(request: EndpointServiceOuterClass.EmptyRequest): Flow<PostOuterClass.Post> =
appEndpointService.onNewPosts().map(::mapper)
override fun onUpdatePosts(request: EmptyRequest): Flow<PostOuterClass.Post> =
override fun onUpdatePosts(request: EndpointServiceOuterClass.EmptyRequest): Flow<PostOuterClass.Post> =
appEndpointService.onUpdatePosts().map(::mapper)
override fun onDeletePosts(request: EmptyRequest): Flow<Id> =
appEndpointService.onDeletePosts().map { Id.newBuilder().setId(it).build() }
override fun onDeletePosts(request: EndpointServiceOuterClass.EmptyRequest): Flow<EndpointServiceOuterClass.Id> =
appEndpointService.onDeletePosts().map { EndpointServiceOuterClass.Id.newBuilder().setId(it).build() }
override suspend fun findAllPosts(request: EmptyRequest): PostsOuterClass.Posts =
override suspend fun findAllPosts(request: EndpointServiceOuterClass.EmptyRequest): PostsOuterClass.Posts =
PostsOuterClass.Posts.newBuilder().addAllPosts(appEndpointService.findAllPosts().map(::mapper)).build()
override suspend fun restartAll(request: IdList): EmptyResponse {
override suspend fun restartAll(request: EndpointServiceOuterClass.IdList): EndpointServiceOuterClass.EmptyResponse {
appEndpointService.restartAll(request.idsList)
return EmptyResponse.getDefaultInstance()
return EndpointServiceOuterClass.EmptyResponse.getDefaultInstance()
}
override suspend fun stopAll(request: IdList): EmptyResponse {
override suspend fun stopAll(request: EndpointServiceOuterClass.IdList): EndpointServiceOuterClass.EmptyResponse {
appEndpointService.stopAll(request.idsList)
return EmptyResponse.getDefaultInstance()
return EndpointServiceOuterClass.EmptyResponse.getDefaultInstance()
}
override suspend fun findPost(request: Id): PostOuterClass.Post {
override suspend fun findPost(request: EndpointServiceOuterClass.Id): PostOuterClass.Post {
return mapper(appEndpointService.findPost(request.id))
}
override suspend fun findImagesByPostId(request: Id): Images =
Images.newBuilder().addAllImages(appEndpointService.findImagesByPostId(request.id).map(::mapper)).build()
override suspend fun findImagesByPostId(request: EndpointServiceOuterClass.Id): Images =
Images.newBuilder().addAllImages(appEndpointService.findImagesByPostEntityId(request.id).map(::mapper)).build()
override fun onUpdateImages(request: EmptyRequest): Flow<Image> = appEndpointService.onUpdateImages().map(::mapper)
override fun onUpdateImagesByPostId(request: Id): Flow<Image> =
appEndpointService.onUpdateImagesByPostId(request.id).map(::mapper)
override fun onUpdateImages(request: EndpointServiceOuterClass.EmptyRequest): Flow<Image> =
appEndpointService.onUpdateImages().map(::mapper)
override fun onDownloadSpeed(request: EmptyRequest): Flow<DownloadSpeed> =
override fun onUpdateImagesByPostId(request: EndpointServiceOuterClass.Id): Flow<Image> =
appEndpointService.onUpdateImagesByPostEntityId(request.id).map(::mapper)
override fun onDownloadSpeed(request: EndpointServiceOuterClass.EmptyRequest): Flow<DownloadSpeed> =
appEndpointService.onDownloadSpeed().map { DownloadSpeed.newBuilder().setSpeed(it.speed).build() }
override fun onVGUserUpdate(request: EmptyRequest): Flow<VGUser> =
appEndpointService.onVGUserUpdate().map { VGUser.newBuilder().setUser(it).build() }
override fun onVGUserUpdate(request: EndpointServiceOuterClass.EmptyRequest): Flow<EndpointServiceOuterClass.VGUser> =
appEndpointService.onVGUserUpdate().map { EndpointServiceOuterClass.VGUser.newBuilder().setUser(it).build() }
override fun onQueueStateUpdate(request: EmptyRequest): Flow<QueueState> = appEndpointService.onQueueStateUpdate()
.map { QueueState.newBuilder().setRunning(it.running).setRemaining(it.remaining).build() }
override fun onQueueStateUpdate(request: EndpointServiceOuterClass.EmptyRequest): Flow<EndpointServiceOuterClass.QueueState> =
appEndpointService.onQueueStateUpdate().map(::mapper)
override fun onErrorCountUpdate(request: EmptyRequest): Flow<ErrorCount> =
override suspend fun getQueueState(request: EndpointServiceOuterClass.EmptyRequest): EndpointServiceOuterClass.QueueState {
return mapper(appEndpointService.getQueueState())
}
override fun onErrorCountUpdate(request: EndpointServiceOuterClass.EmptyRequest): Flow<ErrorCount> =
appEndpointService.onErrorCountUpdate().map { ErrorCount.newBuilder().setCount(it.count).build() }
override fun onTasksRunning(request: EmptyRequest): Flow<TasksRunning> =
appEndpointService.onTasksRunning().map { TasksRunning.newBuilder().setRunning(it).build() }
override fun onTasksRunning(request: EndpointServiceOuterClass.EmptyRequest): Flow<EndpointServiceOuterClass.TasksRunning> =
appEndpointService.onTasksRunning()
.map { EndpointServiceOuterClass.TasksRunning.newBuilder().setRunning(it).build() }
override fun onUpdateMetadata(request: EmptyRequest): Flow<MetadataOuterClass.Metadata> {
override fun onUpdateMetadata(request: EndpointServiceOuterClass.EmptyRequest): Flow<MetadataOuterClass.Metadata> {
return appEndpointService.onUpdateMetadata().map { mapper(it) }
}
override suspend fun remove(request: IdList): EmptyResponse {
override suspend fun remove(request: EndpointServiceOuterClass.IdList): EndpointServiceOuterClass.EmptyResponse {
appEndpointService.remove(request.idsList)
return EmptyResponse.getDefaultInstance()
return EndpointServiceOuterClass.EmptyResponse.getDefaultInstance()
}
override suspend fun clearCompleted(request: EmptyRequest): IdList {
return IdList.newBuilder().addAllIds(appEndpointService.clearCompleted()).build()
override suspend fun move(request: EndpointServiceOuterClass.MovePositionMessage): EndpointServiceOuterClass.EmptyResponse {
appEndpointService.move(request.postEntityId, MovePosition.valueOf(request.position.name))
return EndpointServiceOuterClass.EmptyResponse.getDefaultInstance()
}
override suspend fun rename(request: Rename): EmptyResponse {
override suspend fun clearCompleted(request: EndpointServiceOuterClass.EmptyRequest): EndpointServiceOuterClass.IdList {
return EndpointServiceOuterClass.IdList.newBuilder().addAllIds(appEndpointService.clearCompleted()).build()
}
override suspend fun rename(request: EndpointServiceOuterClass.Rename): EndpointServiceOuterClass.EmptyResponse {
appEndpointService.rename(request.postId, request.name)
return EmptyResponse.getDefaultInstance()
return EndpointServiceOuterClass.EmptyResponse.getDefaultInstance()
}
override suspend fun renameToFirst(request: RenameToFirst): EmptyResponse {
override suspend fun renameToFirst(request: EndpointServiceOuterClass.RenameToFirst): EndpointServiceOuterClass.EmptyResponse {
appEndpointService.renameToFirst(request.postIdsList)
return EmptyResponse.getDefaultInstance()
return EndpointServiceOuterClass.EmptyResponse.getDefaultInstance()
}
override fun onStopped(request: EmptyRequest): Flow<Id> {
return appEndpointService.onStopped().map { Id.newBuilder().setId(it).build() }
override fun onStopped(request: EndpointServiceOuterClass.EmptyRequest): Flow<EndpointServiceOuterClass.Id> {
return appEndpointService.onStopped().map { EndpointServiceOuterClass.Id.newBuilder().setId(it).build() }
}
override fun onNewLog(request: EmptyRequest): Flow<Log> {
override fun onNewLog(request: EndpointServiceOuterClass.EmptyRequest): Flow<Log> {
return appEndpointService.onNewLog().map(::mapper)
}
override suspend fun initLogger(request: EmptyRequest): EmptyResponse {
override suspend fun initLogger(request: EndpointServiceOuterClass.EmptyRequest): EndpointServiceOuterClass.EmptyResponse {
appEndpointService.initLogger()
return EmptyResponse.getDefaultInstance()
return EndpointServiceOuterClass.EmptyResponse.getDefaultInstance()
}
override fun onNewThread(request: EmptyRequest): Flow<ThreadOuterClass.Thread> {
override fun onNewThread(request: EndpointServiceOuterClass.EmptyRequest): Flow<ThreadOuterClass.Thread> {
return appEndpointService.onNewThread().map(::mapper)
}
override fun onUpdateThread(request: EmptyRequest): Flow<ThreadOuterClass.Thread> {
override fun onUpdateThread(request: EndpointServiceOuterClass.EmptyRequest): Flow<ThreadOuterClass.Thread> {
return appEndpointService.onUpdateThread().map(::mapper)
}
override fun onDeleteThread(request: EmptyRequest): Flow<Id> {
return appEndpointService.onDeleteThread().map { Id.newBuilder().setId(it).build() }
override fun onDeleteThread(request: EndpointServiceOuterClass.EmptyRequest): Flow<EndpointServiceOuterClass.Id> {
return appEndpointService.onDeleteThread().map { EndpointServiceOuterClass.Id.newBuilder().setId(it).build() }
}
override fun onClearThreads(request: EmptyRequest): Flow<EmptyResponse> {
return appEndpointService.onClearThreads().map { EmptyResponse.getDefaultInstance() }
override fun onClearThreads(request: EndpointServiceOuterClass.EmptyRequest): Flow<EndpointServiceOuterClass.EmptyResponse> {
return appEndpointService.onClearThreads().map { EndpointServiceOuterClass.EmptyResponse.getDefaultInstance() }
}
override suspend fun findAllThreads(request: EmptyRequest): Threads {
override suspend fun findAllThreads(request: EndpointServiceOuterClass.EmptyRequest): Threads {
return Threads.newBuilder().addAllThreads(appEndpointService.findAllThreads().map { mapper(it) }).build()
}
override suspend fun threadRemove(request: IdList): EmptyResponse {
override suspend fun threadRemove(request: EndpointServiceOuterClass.IdList): EndpointServiceOuterClass.EmptyResponse {
appEndpointService.threadRemove(request.idsList)
return EmptyResponse.getDefaultInstance()
return EndpointServiceOuterClass.EmptyResponse.getDefaultInstance()
}
override suspend fun threadClear(request: EmptyRequest): EmptyResponse {
override suspend fun threadClear(request: EndpointServiceOuterClass.EmptyRequest): EndpointServiceOuterClass.EmptyResponse {
appEndpointService.threadClear()
return EmptyResponse.getDefaultInstance()
return EndpointServiceOuterClass.EmptyResponse.getDefaultInstance()
}
override suspend fun grab(request: Id): PostSelectionList {
return PostSelectionList.newBuilder().addAllPostSelectionList(appEndpointService.grab(request.id).map(::mapper))
.build()
override suspend fun grab(request: EndpointServiceOuterClass.Id): EndpointServiceOuterClass.PostSelectionList {
return EndpointServiceOuterClass.PostSelectionList.newBuilder()
.addAllPostSelectionList(appEndpointService.grab(request.id).map(::mapper)).build()
}
override suspend fun download(request: ThreadPostIdList): EmptyResponse {
override suspend fun download(request: EndpointServiceOuterClass.ThreadPostIdList): EndpointServiceOuterClass.EmptyResponse {
appEndpointService.download(request.threadPostIdListList.map {
me.vripper.model.ThreadPostId(
ThreadPostId(
it.threadId, it.postId
)
})
return EmptyResponse.getDefaultInstance()
return EndpointServiceOuterClass.EmptyResponse.getDefaultInstance()
}
override suspend fun getSettings(request: EmptyRequest): SettingsOuterClass.Settings {
override suspend fun getSettings(request: EndpointServiceOuterClass.EmptyRequest): SettingsOuterClass.Settings {
return mapper(appEndpointService.getSettings())
}
override fun onUpdateSettings(request: EmptyRequest): Flow<SettingsOuterClass.Settings> {
override fun onUpdateSettings(request: EndpointServiceOuterClass.EmptyRequest): Flow<SettingsOuterClass.Settings> {
return appEndpointService.onUpdateSettings().map(::mapper)
}
override suspend fun saveSettings(request: SettingsOuterClass.Settings): EmptyResponse {
override suspend fun saveSettings(request: SettingsOuterClass.Settings): EndpointServiceOuterClass.EmptyResponse {
appEndpointService.saveSettings(
Settings(
connectionSettings = ConnectionSettings(
@@ -206,23 +216,36 @@ class GrpcServerAppEndpointService : EndpointServiceGrpcKt.EndpointServiceCorout
)
)
)
return EmptyResponse.getDefaultInstance()
return EndpointServiceOuterClass.EmptyResponse.getDefaultInstance()
}
override suspend fun getProxies(request: EmptyRequest): ProxyList {
return ProxyList.newBuilder().addAllProxies(appEndpointService.getProxies()).build()
override suspend fun getProxies(request: EndpointServiceOuterClass.EmptyRequest): EndpointServiceOuterClass.ProxyList {
return EndpointServiceOuterClass.ProxyList.newBuilder().addAllProxies(appEndpointService.getProxies()).build()
}
override suspend fun loggedInUser(request: EmptyRequest): LoggedInUser =
LoggedInUser.newBuilder().setUser(appEndpointService.loggedInUser()).build()
override suspend fun loggedInUser(request: EndpointServiceOuterClass.EmptyRequest): EndpointServiceOuterClass.LoggedInUser =
EndpointServiceOuterClass.LoggedInUser.newBuilder().setUser(appEndpointService.loggedInUser()).build()
override suspend fun getVersion(request: EmptyRequest): Version =
Version.newBuilder().setVersion(appEndpointService.getVersion()).build()
override suspend fun getVersion(request: EndpointServiceOuterClass.EmptyRequest): EndpointServiceOuterClass.Version =
EndpointServiceOuterClass.Version.newBuilder().setVersion(appEndpointService.getVersion()).build()
override suspend fun dbMigration(request: EmptyRequest): DBMigrationResponse =
DBMigrationResponse.newBuilder().setMessage(appEndpointService.dbMigration()).build()
override suspend fun dbMigration(request: EndpointServiceOuterClass.EmptyRequest): EndpointServiceOuterClass.DBMigrationResponse =
EndpointServiceOuterClass.DBMigrationResponse.newBuilder().setMessage(appEndpointService.dbMigration()).build()
private fun mapper(queueState: QueueState): EndpointServiceOuterClass.QueueState {
return with(
EndpointServiceOuterClass.QueueState.newBuilder()
) {
setRunning(queueState.running)
setRemaining(queueState.remaining)
addAllRank(queueState.rank.map { rank ->
EndpointServiceOuterClass.Rank.newBuilder().setPostEntityId(rank.postEntityId).setRank(rank.rank)
.build()
}).build()
}
}
private fun mapper(settings: Settings): SettingsOuterClass.Settings {
val viperSettings = with(SettingsOuterClass.ViperSettings.newBuilder()) {
login = settings.viperSettings.login
@@ -289,7 +312,6 @@ class GrpcServerAppEndpointService : EndpointServiceGrpcKt.EndpointServiceCorout
folderName = post.folderName
status = post.status.name
done = post.done
rank = post.rank
size = post.size
downloaded = post.downloaded
addAllPreviews(post.previews)
@@ -303,12 +325,11 @@ class GrpcServerAppEndpointService : EndpointServiceGrpcKt.EndpointServiceCorout
private fun mapper(image: ImageEntity): Image {
return with(Image.newBuilder()) {
id = image.id
postId = image.postId
url = image.url
thumbUrl = image.thumbUrl
host = image.host.toInt()
index = image.index
postIdRef = image.postIdRef
postIdRef = image.postEntityId
size = image.size
downloaded = image.downloaded
status = image.status.name
@@ -320,7 +341,7 @@ class GrpcServerAppEndpointService : EndpointServiceGrpcKt.EndpointServiceCorout
private fun mapper(metadata: Metadata): MetadataOuterClass.Metadata {
return with(MetadataOuterClass.Metadata.newBuilder()) {
postId = metadata.postId
postId = metadata.postIdRef
postedBy = metadata.data.postedBy
addAllResolvedNames(metadata.data.resolvedNames)
@@ -351,8 +372,8 @@ class GrpcServerAppEndpointService : EndpointServiceGrpcKt.EndpointServiceCorout
}
}
private fun mapper(postSelection: me.vripper.model.PostSelection): PostSelection {
return with(PostSelection.newBuilder()) {
private fun mapper(postSelection: PostSelection): EndpointServiceOuterClass.PostSelection {
return with(EndpointServiceOuterClass.PostSelection.newBuilder()) {
threadId = postSelection.threadId
threadTitle = postSelection.threadTitle
postId = postSelection.postId
@@ -73,7 +73,7 @@ class DataBroadcast(
coroutineScope.launch {
appEndpointService.onUpdateImages().collect {
template.convertAndSend("/topic/images/${it.postId}", listOf(it))
template.convertAndSend("/topic/images/${it.postEntityId}", listOf(it))
}
}
@@ -41,7 +41,7 @@ class DataController : KoinComponent {
@SubscribeMapping("/images/{postId}")
fun postsDetails(@DestinationVariable("postId") postId: Long): List<Image> {
return runBlocking { appEndpointService.findImagesByPostId(postId) }
return runBlocking { appEndpointService.findImagesByPostEntityId(postId) }
}
@SubscribeMapping("/threads")