This commit is contained in:
dev-claw
2026-07-07 19:30:24 +01:00
parent 0108b670c5
commit 16f4e27ff0
15 changed files with 423 additions and 16 deletions
@@ -0,0 +1,3 @@
package me.vripper.model
data class DownloadRequest(val imageId: Long)
@@ -0,0 +1,33 @@
package me.vripper.model
data class ImageChunk(
val missing: Boolean,
val imageId: Long,
val offset: Long,
val data: ByteArray,
val isLast: Boolean
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ImageChunk
if (missing != other.missing) return false
if (imageId != other.imageId) return false
if (offset != other.offset) return false
if (isLast != other.isLast) return false
if (!data.contentEquals(other.data)) return false
return true
}
override fun hashCode(): Int {
var result = missing.hashCode()
result = 31 * result + imageId.hashCode()
result = 31 * result + offset.hashCode()
result = 31 * result + isLast.hashCode()
result = 31 * result + data.contentHashCode()
return result
}
}
@@ -17,6 +17,7 @@ import me.vripper.tasks.ThreadLookupTask
import me.vripper.utilities.*
import me.vripper.utilities.ApplicationProperties.VRIPPER_DIR
import org.h2.jdbc.JdbcSQLNonTransientConnectionException
import java.nio.file.Files
import java.sql.DriverManager
import java.time.Duration
import java.util.concurrent.locks.ReentrantLock
@@ -25,6 +26,7 @@ import kotlin.concurrent.withLock
import kotlin.io.path.Path
import kotlin.io.path.exists
import kotlin.jvm.optionals.getOrNull
import kotlin.math.min
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
internal class AppEndpointService(
@@ -173,11 +175,8 @@ internal class AppEndpointService(
override suspend fun renameToFirst(postEntityIds: List<Long>) {
postEntityIds.forEach { postEntityId ->
dataAccessService
.findMetadataByPostEntityId(postEntityId)
.map { it.data.resolvedNames }
.filter { it.isNotEmpty() }
.getOrNull()?.let { rename(postEntityId, it.first()) }
dataAccessService.findMetadataByPostEntityId(postEntityId).map { it.data.resolvedNames }
.filter { it.isNotEmpty() }.getOrNull()?.let { rename(postEntityId, it.first()) }
}
}
@@ -329,11 +328,55 @@ internal class AppEndpointService(
downloadService.move(postEntityId, position)
}
override fun downloadImage(downloadRequest: DownloadRequest): Flow<ImageChunk> {
val imageEntity = dataAccessService.findImageById(downloadRequest.imageId).orElseThrow()
val postEntity = dataAccessService.findPostByEntityId(imageEntity.postEntityId)
val filePath = Path(postEntity.downloadDirectory).resolve(postEntity.folderName).resolve(imageEntity.filename)
return if (filePath.exists()) {
val chunkSize = 256 * 1024
val bytes = Files.readAllBytes(filePath)
flow {
var offset = 0L
while (offset < bytes.size) {
val start = offset.toInt()
val len = min(chunkSize, bytes.size - start)
val part = bytes.copyOfRange(start, start + len)
val isLast = (start + len) >= bytes.size
emit(
ImageChunk(
missing = false,
imageId = downloadRequest.imageId,
offset = offset,
data = part,
isLast = isLast
)
)
offset += len.toLong()
if (isLast) break
}
}
} else {
flow {
emit(
ImageChunk(
missing = true,
imageId = downloadRequest.imageId,
offset = 0,
data = ByteArray(0),
isLast = true
)
)
}
}
}
override suspend fun dbMigration(): String {
val conn = try {
DriverManager
.getConnection("jdbc:h2:file:$VRIPPER_DIR/vripper;DB_CLOSE_DELAY=-1;IFEXISTS=TRUE")
DriverManager.getConnection("jdbc:h2:file:$VRIPPER_DIR/vripper;DB_CLOSE_DELAY=-1;IFEXISTS=TRUE")
} catch (_: JdbcSQLNonTransientConnectionException) {
return "Old database not found, nothing to do"
}
@@ -425,8 +468,7 @@ internal class AppEndpointService(
if (set.next()) {
val data = Json.decodeFromString(set.getString("DATA")) as MetadataEntity.Data
val metadata = MetadataEntity(
postIdRef = savedPost.id,
data = data
postIdRef = savedPost.id, data = data
)
dataAccessService.saveMetadata(metadata)
}
@@ -47,5 +47,6 @@ interface IAppEndpointService {
suspend fun dbMigration(): String
suspend fun initLogger()
suspend fun move(postEntityId: Long, position: MovePosition)
fun downloadImage(downloadRequest: DownloadRequest): Flow<ImageChunk>
fun connectionState(): String
}
@@ -94,6 +94,18 @@ message MovePositionMessage {
MovePositionEnum position = 2;
}
message DownloadRequest {
int64 imageId = 1;
}
message ImageChunk {
bool missing = 1;
int64 imageId = 2;
int64 offset = 3;
bytes data = 4;
bool isLast = 5;
}
service EndpointService {
rpc scanLinks (Links) returns (EmptyResponse) {}
rpc onNewPosts (EmptyRequest) returns (stream Post) {}
@@ -136,5 +148,6 @@ service EndpointService {
rpc getVersion (EmptyRequest) returns (Version) {}
rpc dbMigration (EmptyRequest) returns (DBMigrationResponse) {}
rpc initLogger (EmptyRequest) returns (EmptyResponse) {}
rpc move (MovePositionMessage) returns (EmptyResponse) {}
rpc move(MovePositionMessage) returns (EmptyResponse) {}
rpc downloadImage(DownloadRequest) returns (stream ImageChunk);
}
@@ -0,0 +1,36 @@
package me.vripper.gui.components.fragments
import kotlinx.coroutines.flow.Flow
import me.vripper.model.ImageChunk
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
interface ImageSource {
suspend fun inputStream(): InputStream?
fun fileName(): String
}
data class BytesImageSource(
val fileName: String,
val downloadFunction: () -> Flow<ImageChunk>
) : ImageSource {
override suspend fun inputStream(): InputStream? {
val bos = ByteArrayOutputStream()
var missing = false
downloadFunction().collect {
if (it.missing) {
missing = true
} else {
bos.write(it.data)
}
}
val bytes = bos.toByteArray()
return if (missing) null else ByteArrayInputStream(bytes)
}
override fun fileName(): String {
return fileName
}
}
@@ -0,0 +1,165 @@
package me.vripper.gui.components.fragments
import atlantafx.base.theme.Styles
import javafx.beans.property.SimpleIntegerProperty
import javafx.geometry.Pos
import javafx.scene.control.Button
import javafx.scene.control.Label
import javafx.scene.image.Image
import javafx.scene.image.ImageView
import javafx.scene.layout.BorderPane
import javafx.scene.layout.HBox
import kotlinx.coroutines.*
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
import java.io.ByteArrayInputStream
import java.util.*
import kotlin.math.min
class PhotoViewerFragment : Fragment("Image Viewer") {
val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val sources: List<ImageSource> by param()
val initialIndex: Int by param()
private val indexProperty = SimpleIntegerProperty(initialIndex)
private val imageView = ImageView().apply {
isPreserveRatio = true
isSmooth = true
isCache = true
}
private val cache = object : LinkedHashMap<Int, Image>(8, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int, Image>?): Boolean = size > 3
}
private var bottomBar: HBox
override val root = BorderPane().apply {
center = imageView
BorderPane.setAlignment(imageView, Pos.CENTER)
val prevButton = Button().apply {
graphic = FontIcon.of(Feather.ARROW_LEFT)
addClass(Styles.ACCENT)
setOnAction {
if (sources.isEmpty()) return@setOnAction
coroutineScope.launch {
goTo((indexProperty.get() - 1 + sources.size) % sources.size)
}
}
}
val nextButton = Button().apply {
graphic = FontIcon.of(Feather.ARROW_RIGHT)
addClass(Styles.ACCENT)
setOnAction {
if (sources.isEmpty()) return@setOnAction
coroutineScope.launch {
goTo((indexProperty.get() + 1) % sources.size)
}
}
}
val label = Label().apply {
textProperty().bind(indexProperty.plus(1).asString().concat(" / ${sources.size}"))
}
bottomBar = HBox(10.0, prevButton, label, nextButton).apply {
alignment = Pos.CENTER
paddingBottom = 5.0
}
bottom = bottomBar
fun scheduleResize() {
runLater { resizeNoUpscale() }
}
// Re-evaluate whenever container size or bottom bar size changes
layoutBoundsProperty().addListener { _, _, _ -> scheduleResize() }
bottomBar.layoutBoundsProperty().addListener { _, _, _ -> scheduleResize() }
// Re-evaluate whenever the image changes (so it works on prev/next)
imageView.imageProperty().addListener { _, _, _ -> scheduleResize() }
}
init {
coroutineScope.launch {
if (sources.isEmpty()) {
return@launch
}
val image = loadImageFor(indexProperty.value)
runLater {
imageView.image = image
title = sources[indexProperty.value].fileName()
resizeNoUpscale()
}
}
}
private suspend fun goTo(newIndex: Int) {
if (sources.isEmpty()) return
val image = loadImageFor(newIndex)
runLater {
imageView.image = image
indexProperty.set(newIndex)
title = sources[newIndex].fileName()
}
// preload neighbors only
val prev = (newIndex - 1 + sources.size) % sources.size
val next = (newIndex + 1) % sources.size
preload(prev)
preload(next)
}
private fun resizeNoUpscale() {
val img = imageView.image ?: return
val ih = img.height
if (ih <= 0.0) return
val bottomH = bottomBar.layoutBounds.height
val availableH = (root.height - bottomH - 10.0).coerceAtLeast(0.0)
val scale = min(1.0, availableH / ih) // never upscale
imageView.isPreserveRatio = true
imageView.fitHeight = ih * scale
imageView.fitWidth = 0.0
}
private suspend fun preload(i: Int) {
if (i == indexProperty.get()) return
if (cache.containsKey(i)) return
cache[i] = loadImageFor(i)
}
private suspend fun loadImageFor(i: Int): Image {
cache[i]?.let { return it }
val source = sources[i]
val inputStream = source.inputStream()
val img = if (inputStream == null) {
missingPlaceholder
} else {
Image(inputStream, 0.0, 0.0, true, true)
}
cache[i] = img
return img
}
private val missingPlaceholder: Image by lazy {
// 1x1 transparent PNG
val transparentPngBase64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/xcAAgMBgX7Y1z8AAAAASUVORK5CYII="
val bytes = Base64.getDecoder().decode(transparentPngBase64)
Image(ByteArrayInputStream(bytes), 0.0, 0.0, true, true)
}
override fun onUndock() {
coroutineScope.cancel()
}
}
@@ -6,6 +6,7 @@ import javafx.collections.FXCollections
import javafx.collections.ObservableList
import javafx.event.EventHandler
import javafx.geometry.Pos
import javafx.scene.Cursor
import javafx.scene.control.*
import javafx.scene.control.cell.TextFieldTableCell
import javafx.scene.input.MouseButton
@@ -17,6 +18,7 @@ import me.vripper.entities.Status
import me.vripper.gui.components.cells.PreviewTableCell
import me.vripper.gui.components.cells.ProgressTableCell
import me.vripper.gui.components.cells.StatusTableCell
import me.vripper.gui.components.fragments.PhotoViewerFragment
import me.vripper.gui.controller.ImageController
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.model.ImageModel
@@ -73,6 +75,7 @@ class ImagesTableView : View("Photos") {
cellFactory = Callback {
val cell = PreviewTableCell<ImageModel, String>()
cell.onMouseExited = EventHandler {
cursor = Cursor.DEFAULT
preview.cleanup()
}
cell.onMouseMoved = EventHandler {
@@ -82,6 +85,7 @@ class ImagesTableView : View("Photos") {
}
}
cell.onMouseEntered = EventHandler { mouseEvent ->
cursor = Cursor.HAND
preview.cleanup()
if (cell.tableRow.item != null && cell.tableRow.item.thumbUrl.isNotEmpty()) {
preview.display(cell.tableRow.item.postEntityId, listOf(cell.tableRow.item.thumbUrl))
@@ -91,6 +95,34 @@ class ImagesTableView : View("Photos") {
}
}
}
cell.onLeftClick {
preview.cleanup()
coroutineScope.launch {
val imageSources = imageController.getImageSources(cell.tableRow.item)
if (imageSources.none { it.key.id == cell.tableRow.item.id }) {
return@launch
}
runLater {
find<PhotoViewerFragment>(
mapOf(
PhotoViewerFragment::sources to imageSources.map { it.value },
PhotoViewerFragment::initialIndex to cell.tableRow.item.index - 1,
)
).openModal()?.apply {
val w = 800.0
val h = 600.0
minWidth = 100.0
minHeight = 100.0
width = w
height = h
val x = owner.x + (owner.width - w) / 2
val y = owner.y + (owner.height - h) / 2
this.x = x
this.y = y
}
}
}
}
cell
}
}
@@ -6,6 +6,7 @@ import javafx.collections.FXCollections
import javafx.collections.ObservableList
import javafx.event.EventHandler
import javafx.geometry.Pos
import javafx.scene.Cursor
import javafx.scene.control.*
import javafx.scene.control.cell.TextFieldTableCell
import javafx.scene.input.KeyCode
@@ -20,6 +21,7 @@ import me.vripper.gui.components.cells.PreviewTableCell
import me.vripper.gui.components.cells.ProgressTableCell
import me.vripper.gui.components.cells.StatusTableCell
import me.vripper.gui.components.fragments.AddLinksFragment
import me.vripper.gui.components.fragments.PhotoViewerFragment
import me.vripper.gui.components.fragments.RenameFragment
import me.vripper.gui.controller.PostController
import me.vripper.gui.controller.WidgetsController
@@ -253,6 +255,7 @@ class PostsTableView : View() {
cellFactory = Callback {
val cell = PreviewTableCell<PostModel, ObservableList<String>>()
cell.onMouseExited = EventHandler {
cursor = Cursor.DEFAULT
preview.cleanup()
}
cell.onMouseMoved = EventHandler {
@@ -262,6 +265,7 @@ class PostsTableView : View() {
}
}
cell.onMouseEntered = EventHandler { mouseEvent ->
cursor = Cursor.HAND
preview.cleanup()
if (cell.tableRow.item != null && cell.tableRow.item.previewList.isNotEmpty()) {
preview.display(
@@ -273,6 +277,34 @@ class PostsTableView : View() {
}
}
}
cell.onLeftClick {
preview.cleanup()
coroutineScope.launch {
val imageSources = postController.getImageSources(cell.tableRow.item)
if (imageSources.isEmpty()) {
return@launch
}
runLater {
find<PhotoViewerFragment>(
mapOf(
PhotoViewerFragment::sources to imageSources,
PhotoViewerFragment::initialIndex to 0,
)
).openModal()?.apply {
val w = 800.0
val h = 600.0
minWidth = 100.0
minHeight = 100.0
width = w
height = h
val x = owner.x + (owner.width - w) / 2
val y = owner.y + (owner.height - h) / 2
this.x = x
this.y = y
}
}
}
}
cell
}
}
@@ -1,7 +1,11 @@
package me.vripper.gui.controller
import me.vripper.entities.ImageEntity
import me.vripper.gui.components.fragments.BytesImageSource
import me.vripper.gui.components.fragments.ImageSource
import me.vripper.gui.model.ImageModel
import me.vripper.gui.utils.AppEndpointManager.currentAppEndpointService
import me.vripper.model.DownloadRequest
import me.vripper.model.Image
import tornadofx.Controller
@@ -42,4 +46,13 @@ class ImageController : Controller() {
currentAppEndpointService().onUpdateImagesByPostEntityId(postId)
fun onStopped() = currentAppEndpointService().onStopped()
suspend fun getImageSources(item: ImageModel): Map<ImageEntity, ImageSource> {
val imageEntities = currentAppEndpointService().findImagesByPostEntityId(item.postEntityId)
return imageEntities.associateWith { imageEntity ->
BytesImageSource(imageEntity.filename) {
currentAppEndpointService().downloadImage(DownloadRequest(imageEntity.id))
}
}
}
}
@@ -1,11 +1,14 @@
package me.vripper.gui.controller
import kotlinx.coroutines.flow.map
import me.vripper.gui.components.fragments.BytesImageSource
import me.vripper.gui.components.fragments.ImageSource
import me.vripper.gui.model.PostModel
import me.vripper.gui.utils.AppEndpointManager.currentAppEndpointService
import me.vripper.gui.utils.AppEndpointManager.localAppEndpointService
import me.vripper.gui.utils.AppEndpointManager.remoteAppEndpointService
import me.vripper.gui.utils.ChannelFlowBuilder
import me.vripper.model.DownloadRequest
import me.vripper.model.Post
import me.vripper.model.QueueState
import me.vripper.services.download.MovePosition
@@ -133,4 +136,13 @@ class PostController : Controller() {
fun progress(total: Int, done: Int): Double {
return if (done == 0 && total == 0) 0.0 else (done.toDouble() / total)
}
suspend fun getImageSources(item: PostModel): List<ImageSource> {
val imageEntities = currentAppEndpointService().findImagesByPostEntityId(item.id)
return imageEntities.map { imageEntity ->
BytesImageSource(imageEntity.filename) {
currentAppEndpointService().downloadImage(DownloadRequest(imageEntity.id))
}
}
}
}
@@ -8,8 +8,10 @@ import kotlinx.coroutines.flow.map
import me.vripper.entities.MetadataEntity
import me.vripper.entities.Status
import me.vripper.model.*
import me.vripper.model.DownloadRequest
import me.vripper.model.DownloadSpeed
import me.vripper.model.ErrorCount
import me.vripper.model.ImageChunk
import me.vripper.model.PostSelection
import me.vripper.model.QueueState
import me.vripper.model.Rank
@@ -252,6 +254,14 @@ internal class GrpcEndpointService : IAppEndpointService {
override suspend fun dbMigration(): String =
endpointServiceCoroutineStub!!.dbMigration(EmptyRequest.getDefaultInstance()).message
override fun downloadImage(downloadRequest: DownloadRequest): Flow<ImageChunk> =
endpointServiceCoroutineStub!!.downloadImage(
EndpointServiceOuterClass
.DownloadRequest
.newBuilder()
.setImageId(downloadRequest.imageId).build()
).map { ImageChunk(it.missing, it.imageId, it.offset, it.data.toByteArray(), it.isLast) }
private fun mapper(queueState: EndpointServiceOuterClass.QueueState) =
QueueState(queueState.running, queueState.remaining, queueState.rankList.map {
Rank(it.postEntityId, it.rank)
@@ -29,4 +29,8 @@ object AppEndpointManager : KoinComponent {
else -> throw IllegalStateException("Unknown current state: $current")
}
}
fun currentAppState(): GUIEvent {
return this.current
}
}
@@ -1,5 +1,6 @@
package me.vripper.web.grpc
import com.google.protobuf.ByteString
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import me.vripper.entities.ImageEntity
@@ -213,15 +214,13 @@ class GrpcServerAppEndpointService : EndpointServiceGrpcKt.EndpointServiceCorout
enableClipboardMonitoring = request.systemSettings.enableClipboardMonitoring,
clipboardPollingRate = request.systemSettings.clipboardPollingRate,
maxEventLog = request.systemSettings.maxEventLog,
),
hostSettings = request.hostSettingsMap.entries.associate { hostSettings ->
), hostSettings = request.hostSettingsMap.entries.associate { hostSettings ->
HostName.valueOf(hostSettings.key) to hostSettings.value.settingsMap.entries.associate {
HostSettingKey.valueOf(
it.key
) to it.value
}
}
)
})
)
return EndpointServiceOuterClass.EmptyResponse.getDefaultInstance()
}
@@ -239,6 +238,18 @@ class GrpcServerAppEndpointService : EndpointServiceGrpcKt.EndpointServiceCorout
override suspend fun dbMigration(request: EndpointServiceOuterClass.EmptyRequest): EndpointServiceOuterClass.DBMigrationResponse =
EndpointServiceOuterClass.DBMigrationResponse.newBuilder().setMessage(appEndpointService.dbMigration()).build()
override fun downloadImage(request: EndpointServiceOuterClass.DownloadRequest): Flow<EndpointServiceOuterClass.ImageChunk> {
return appEndpointService.downloadImage(DownloadRequest(request.imageId)).map {
with(EndpointServiceOuterClass.ImageChunk.newBuilder()) {
imageId = it.imageId
offset = it.offset
data = ByteString.copyFrom(it.data)
isLast = it.isLast
build()
}
}
}
private fun mapper(queueState: QueueState): EndpointServiceOuterClass.QueueState {
return with(
@@ -1,5 +1,5 @@
spring.liquibase.enabled=false
server.port=8080
grpc.enabled=false
grpc.passphrase=
grpc.enabled=true
grpc.passphrase=123
grpc.port=30000