Compare commits

...
16 Commits
Author SHA1 Message Date
dev-claw ca77074ef6 version bump 2025-08-19 15:42:07 +01:00
dev-clawandGitHub 3cb251cb36 fixes #250 (#252) 2025-08-19 15:38:08 +01:00
dev-clawandGitHub 73f478c1bc fixes #248 (#249) 2025-08-18 19:46:05 +01:00
dev-claw bb1dfbfee1 version bump 2025-08-18 19:36:51 +01:00
dev-clawandGitHub a89fe8571c fixes #243 (#246) 2025-08-18 19:34:21 +01:00
dev-clawandGitHub 44c8e443f4 fixes #240 (#241) 2025-08-08 15:41:43 +01:00
dev-claw 7ddc5d00b9 fix counter 2025-08-07 15:19:41 +01:00
dev-claw 00039e230b version bump 2025-08-07 14:56:30 +01:00
dev-clawandGitHub f4821aafac fixes #237 (#238) 2025-08-07 14:55:03 +01:00
dev-clawandGitHub d7c8377a71 fixes #231 (#232) 2025-04-15 00:05:27 +01:00
dev-claw 1194faec28 version bump 2025-04-13 19:44:40 +01:00
dev-clawandGitHub 4b789bac8f fixes #228 (#229) 2025-04-13 19:43:28 +01:00
dev-claw 899e6d2893 version bump 2025-03-22 22:52:14 +01:00
dev-clawandGitHub 4c96e79802 fixes #225 (#226) 2025-03-22 22:18:59 +01:00
dev-claw 4a001efa9f version bump 2025-03-21 16:41:49 +01:00
dev-clawandGitHub 7035b1cce0 fixes #223 (#224) 2025-03-21 16:40:38 +01:00
100 changed files with 1715 additions and 1648 deletions
+12 -12
View File
@@ -57,9 +57,9 @@ jobs:
name: Package for Linux
run: |
cd jpackage
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-app-image.cfg" --icon icon.png
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-linux.cfg" --type deb
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-linux.cfg" --type rpm
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-app-image.cfg" --icon resources/VRipper.png
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-linux.cfg" --resource-dir resources --type deb
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-linux.cfg" --resource-dir resources --type rpm
mv dist/vripper-${{ github.event.release.tag_name }}-1.x86_64.rpm dist/vripper-linux-${{ github.event.release.tag_name }}.x86_64.rpm
mv dist/vripper_${{ github.event.release.tag_name }}-1_amd64.deb dist/vripper-linux-${{ github.event.release.tag_name }}_amd64.deb
@@ -67,9 +67,9 @@ jobs:
name: Package for Windows
run: |
cd jpackage
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-app-image.cfg" --icon icon.ico
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-windows.cfg" --type msi
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-windows.cfg" --type exe
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-app-image.cfg" --icon resources/VRipper.ico
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-windows.cfg" --resource-dir resources --type msi
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-windows.cfg" --resource-dir resources --type exe
cd dist
ren VRipper-${{ github.event.release.tag_name }}.msi vripper-windows-installer-${{ github.event.release.tag_name }}.msi
ren VRipper-${{ github.event.release.tag_name }}.exe vripper-windows-installer-${{ github.event.release.tag_name }}.exe
@@ -78,9 +78,9 @@ jobs:
name: Package for macOS(arm64)
run: |
cd jpackage
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-app-image.cfg" --icon icon.icns
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-macos.cfg" --type pkg
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-macos.cfg" --type dmg
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-app-image.cfg" --icon resources/VRipper.icns
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-macos.cfg" --resource-dir resources --type pkg
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-macos.cfg" --resource-dir resources --type dmg
mv dist/VRipper-${{ github.event.release.tag_name }}.pkg dist/vripper-macos-${{ github.event.release.tag_name }}.arm64.pkg
mv dist/VRipper-${{ github.event.release.tag_name }}.dmg dist/vripper-macos-${{ github.event.release.tag_name }}.arm64.dmg
@@ -88,9 +88,9 @@ jobs:
name: Package for macOS(x86_64)
run: |
cd jpackage
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-app-image.cfg" --icon icon.icns
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-macos.cfg" --type pkg
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-macos.cfg" --type dmg
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-app-image.cfg" --icon resources/VRipper.icns
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-macos.cfg" --resource-dir resources --type pkg
jpackage --app-version ${{ github.event.release.tag_name }} "@jpackage.cfg" "@jpackage-macos.cfg" --resource-dir resources --type dmg
mv dist/VRipper-${{ github.event.release.tag_name }}.pkg dist/vripper-macos-${{ github.event.release.tag_name }}.x86_64.pkg
mv dist/VRipper-${{ github.event.release.tag_name }}.dmg dist/vripper-macos-${{ github.event.release.tag_name }}.x86_64.dmg
+3 -2
View File
@@ -22,9 +22,10 @@ Direct access to `vipergirls.to` domain.
<img src="https://github.com/stashapp/stash/raw/develop/docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="https://github.com/stashapp/stash/raw/develop/docs/readme_assets/mac_logo.svg" width="100%" height="75"> macOS (Intel) | <img src="https://github.com/stashapp/stash/raw/develop/docs/readme_assets/mac_logo.svg" width="100%" height="75"> macOS (Apple silicon) | <img src="https://github.com/stashapp/stash/raw/develop/docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="https://images.vexels.com/media/users/3/166401/isolated/preview/b82aa7ac3f736dd78570dd3fa3fa9e24-java-programming-language-icon-by-vexels.png" width="100%" height="75"> Java
:---:|:---:|:---:|:---:|:---:
[Installer (EXE)](https://github.com/death-claw/vripper-project/releases/download/6.5.0/vripper-windows-installer-6.5.0.exe) <br /> [Installer (MSI)](https://github.com/death-claw/vripper-project/releases/download/6.5.0/vripper-windows-installer-6.5.0.msi) <br /> [Portable (ZIP)](https://github.com/death-claw/vripper-project/releases/download/6.5.0/vripper-windows-portable-6.5.0.zip) | [Installer (DMG)](https://github.com/death-claw/vripper-project/releases/download/6.5.0/vripper-macos-6.5.0.x86_64.dmg) <br /> [Installer (PKG)](https://github.com/death-claw/vripper-project/releases/download/6.5.0/vripper-macos-6.5.0.x86_64.pkg) <br /> [Portable (ZIP)](https://github.com/death-claw/vripper-project/releases/download/6.5.0/vripper-macos-portable-6.5.0.x86_64.zip) | [Installer (DMG)](https://github.com/death-claw/vripper-project/releases/download/6.5.0/vripper-macos-6.5.0.arm64.dmg) <br /> [Installer (PKG)](https://github.com/death-claw/vripper-project/releases/download/6.5.0/vripper-macos-6.5.0.arm64.pkg) <br /> [Portable (ZIP)](https://github.com/death-claw/vripper-project/releases/download/6.5.0/vripper-macos-portable-6.5.0.arm64.zip) | [Linux (amd64) (DEB)](https://github.com/death-claw/vripper-project/releases/download/6.5.0/vripper-linux-6.5.0_amd64.deb) <br /> [Linux (x86_64) (RPM)](https://github.com/death-claw/vripper-project/releases/download/6.5.0/vripper-linux-6.5.0.x86_64.rpm) <br /> [Portable (ZIP)](https://github.com/death-claw/vripper-project/releases/download/6.5.0/vripper-linux-portable-6.5.0.zip) | [Java GUI (noarch)](https://github.com/death-claw/vripper-project/releases/download/6.5.0/vripper-noarch-gui-6.5.0.jar) <br /> [Java Web (noarch)](https://github.com/death-claw/vripper-project/releases/download/6.5.0/vripper-noarch-web-6.5.0.jar)
[Installer (EXE)](https://github.com/dev-claw/vripper-project/releases/download/6.5.6/vripper-windows-installer-6.5.6.exe) <br /> [Installer (MSI)](https://github.com/dev-claw/vripper-project/releases/download/6.5.6/vripper-windows-installer-6.5.6.msi) <br /> [Portable (ZIP)](https://github.com/dev-claw/vripper-project/releases/download/6.5.6/vripper-windows-portable-6.5.6.zip) | [Installer (DMG)](https://github.com/dev-claw/vripper-project/releases/download/6.5.6/vripper-macos-6.5.6.x86_64.dmg) <br /> [Installer (PKG)](https://github.com/dev-claw/vripper-project/releases/download/6.5.6/vripper-macos-6.5.6.x86_64.pkg) <br /> [Portable (ZIP)](https://github.com/dev-claw/vripper-project/releases/download/6.5.6/vripper-macos-portable-6.5.6.x86_64.zip) | [Installer (DMG)](https://github.com/dev-claw/vripper-project/releases/download/6.5.6/vripper-macos-6.5.6.arm64.dmg) <br /> [Installer (PKG)](https://github.com/dev-claw/vripper-project/releases/download/6.5.6/vripper-macos-6.5.6.arm64.pkg) <br /> [Portable (ZIP)](https://github.com/dev-claw/vripper-project/releases/download/6.5.6/vripper-macos-portable-6.5.6.arm64.zip) | [Linux (amd64) (DEB)](https://github.com/dev-claw/vripper-project/releases/download/6.5.6/vripper-linux-6.5.3_amd64.deb) <br /> [Linux (x86_64) (RPM)](https://github.com/dev-claw/vripper-project/releases/download/6.5.6/vripper-linux-6.5.6.x86_64.rpm) <br /> [Portable (ZIP)](https://github.com/dev-claw/vripper-project/releases/download/6.5.6/vripper-linux-portable-6.5.6.zip) | [Java GUI (noarch)](https://github.com/dev-claw/vripper-project/releases/download/6.5.6/vripper-noarch-gui-6.5.6.jar) <br /> [Java Web (noarch)](https://github.com/dev-claw/vripper-project/releases/download/6.5.6/vripper-noarch-web-6.5.6.jar)
Source code and previous versions are available on the [Releases page](https://github.com/death-claw/vripper-project/releases).
Source code and previous versions are available on
the [Releases page](https://github.com/dev-claw/vripper-project/releases).
Application data (application logs, settings and persisted data) is stored in:
* Windows --> `C:\USERS\<your Windows username>\vripper`
-1
View File
@@ -1,4 +1,3 @@
--icon icon.png
--java-options "-Dvripper.portable=false"
--linux-package-name vripper
--linux-app-release 1
-1
View File
@@ -1,4 +1,3 @@
--icon icon.icns
--java-options "-Dvripper.portable=false"
--mac-package-identifier me.mnlr.vripper.vripper-gui
--mac-package-name VRipper
+1 -2
View File
@@ -1,8 +1,7 @@
--icon icon.ico
--java-options "-Dvripper.portable=false"
--win-dir-chooser
--win-menu
--win-per-user-install
--win-shortcut
--win-shortcut-prompt
--win-update-url https://github.com/death-claw/vripper-project/releases
--win-update-url https://github.com/dev-claw/vripper-project/releases
+1 -1
View File
@@ -5,4 +5,4 @@
--name VRipper
--description "Image ripper tool for vipergirls"
--dest dist
--vendor "death-claw"
--vendor "dev-claw"
+10
View File
@@ -0,0 +1,10 @@
[Desktop Entry]
Name=VRipper
Comment=Image ripper tool for vipergirls
Exec=/opt/vripper/bin/VRipper
Icon=/opt/vripper/lib/VRipper.png
Terminal=false
Type=Application
Categories=Utility
MimeType=
StartupWMClass=me.vripper.gui.VripperGuiApplication

Before

Width:  |  Height:  |  Size: 303 KiB

After

Width:  |  Height:  |  Size: 303 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

+7 -7
View File
@@ -15,7 +15,7 @@
<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.0</revision>
<revision>6.5.6</revision>
<kotlin.version>2.0.21</kotlin.version>
<slf4j.version>2.0.16</slf4j.version>
<logback.version>1.5.12</logback.version>
@@ -31,13 +31,13 @@
<kotlinx-serialization-json.version>1.7.3</kotlinx-serialization-json.version>
<httpclient5.version>5.4</httpclient5.version>
<httpcore5.version>5.3</httpcore5.version>
<commons-codec.version>1.17.1</commons-codec.version>
<liquibase-core.version>4.29.2</liquibase-core.version>
<liquibase-slf4j.version>5.0.0</liquibase-slf4j.version>
<grpc.kotlin.version>1.4.1</grpc.kotlin.version>
<java.grpc.version>1.68.1</java.grpc.version>
<protobuf.version>3.25.5</protobuf.version>
<sqlite-jdbc.version>3.47.0.0</sqlite-jdbc.version>
<bucket4j_jdk17-core.version>8.14.0</bucket4j_jdk17-core.version>
</properties>
<build>
<plugins>
@@ -186,11 +186,6 @@
<groupId>org.apache.httpcomponents.core5</groupId>
<version>${httpcore5.version}</version>
</dependency>
<dependency>
<artifactId>commons-codec</artifactId>
<groupId>commons-codec</groupId>
<version>${commons-codec.version}</version>
</dependency>
<dependency>
<artifactId>liquibase-core</artifactId>
<groupId>org.liquibase</groupId>
@@ -221,6 +216,11 @@
<artifactId>protobuf-kotlin</artifactId>
<version>${protobuf.version}</version>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-core</artifactId>
<version>${bucket4j_jdk17-core.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>
-5
View File
@@ -75,10 +75,6 @@
<artifactId>caffeine</artifactId>
<groupId>com.github.ben-manes.caffeine</groupId>
</dependency>
<dependency>
<artifactId>commons-codec</artifactId>
<groupId>commons-codec</groupId>
</dependency>
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna-platform</artifactId>
@@ -111,7 +107,6 @@
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-core</artifactId>
<version>8.14.0</version>
</dependency>
</dependencies>
@@ -8,7 +8,6 @@ import me.vripper.data.repositories.impl.ImageRepositoryImpl
import me.vripper.data.repositories.impl.MetadataRepositoryImpl
import me.vripper.data.repositories.impl.PostRepositoryImpl
import me.vripper.data.repositories.impl.ThreadRepositoryImpl
import me.vripper.download.DownloadService
import me.vripper.event.EventBus
import me.vripper.host.*
import me.vripper.services.*
@@ -42,19 +41,19 @@ val coreModule = module {
DataTransaction(get(), get(), get(), get(), get(), get())
}
single<RetryPolicyService> {
RetryPolicyService(get(), get())
RetryPolicyService()
}
single<HTTPService> {
HTTPService(get(), get())
HTTPService()
}
single<VGAuthService> {
VGAuthService(get(), get(), get())
VGAuthService(get(), get())
}
single<ThreadCacheService> {
ThreadCacheService(get())
}
single<DownloadService> {
DownloadService(get(), get(), get(), get(), get())
DownloadService(get(), get(), get(), get())
}
single<DownloadSpeedService> {
DownloadSpeedService(get())
@@ -72,51 +71,51 @@ val coreModule = module {
MetadataService(get(), get())
}
single {
AcidimgHost(get(), get(), get())
AcidimgHost(get())
} bind Host::class
single {
DPicMeHost(get(), get(), get())
DPicMeHost()
} bind Host::class
single {
ImageBamHost(get(), get(), get())
ImageBamHost()
} bind Host::class
single {
ImageTwistHost(get(), get(), get())
ImageTwistHost()
} bind Host::class
single {
ImageVenueHost(get(), get(), get())
ImageVenueHost()
} bind Host::class
single {
ImageZillaHost(get(), get(), get())
ImageZillaHost()
} bind Host::class
single {
ImgboxHost(get(), get(), get())
ImgboxHost()
} bind Host::class
single {
ImgSpiceHost(get(), get(), get())
ImgSpiceHost()
} bind Host::class
single {
ImxHost(get(), get(), get())
ImxHost()
} bind Host::class
single {
PimpandhostHost(get(), get(), get())
PimpandhostHost()
} bind Host::class
single {
PixhostHost(get(), get(), get())
PixhostHost()
} bind Host::class
single {
PixRouteHost(get(), get(), get())
PixRouteHost()
} bind Host::class
single {
PixxxelsHost(get(), get(), get())
PixxxelsHost()
} bind Host::class
single {
PostImgHost(get(), get(), get())
PostImgHost()
} bind Host::class
single {
TurboImageHost(get(), get(), get())
TurboImageHost()
} bind Host::class
single {
ViprImHost(get(), get(), get())
ViprImHost()
} bind Host::class
}
@@ -1,29 +0,0 @@
package me.vripper.download
import kotlinx.coroutines.*
import me.vripper.entities.ImageEntity
import me.vripper.model.Settings
import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase
import org.apache.hc.client5.http.cookie.BasicCookieStore
import org.apache.hc.client5.http.protocol.HttpClientContext
import org.koin.core.component.KoinComponent
internal class ImageDownloadContext(val imageEntity: ImageEntity, val settings: Settings) : KoinComponent {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val jobs = mutableListOf<Job>()
val httpContext: HttpClientContext =
HttpClientContext.create().apply { cookieStore = BasicCookieStore() }
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) }
}
}
@@ -1,154 +0,0 @@
package me.vripper.download
import dev.failsafe.function.CheckedRunnable
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.DataTransaction
import me.vripper.services.VGAuthService
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.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.util.*
import kotlin.io.path.Path
import kotlin.io.path.pathString
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.url, 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)
}
}
@@ -1,10 +1,9 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.DataTransaction
import me.vripper.services.DownloadSpeedService
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.services.HTTPService
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.LoggerDelegate
@@ -12,33 +11,31 @@ import me.vripper.utilities.XpathUtils
import org.apache.hc.client5.http.classic.methods.HttpPost
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity
import org.apache.hc.core5.http.message.BasicNameValuePair
import org.w3c.dom.Document
import org.w3c.dom.Node
internal class AcidimgHost(
private val httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("acidimg.cc", 0, httpService, dataTransaction, downloadSpeedService) {
) : Host("acidimg.cc", 0) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String, document: Document, context: ImageDownloadContext
image: ImageEntity, context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
try {
log.debug(
String.format(
"Looking for xpath expression %s in %s", CONTINUE_BUTTON_XPATH, url
"Looking for xpath expression %s in %s", CONTINUE_BUTTON_XPATH, image.url
)
)
XpathUtils.getAsNode(document, CONTINUE_BUTTON_XPATH)
} catch (e: XpathException) {
throw HostException(e)
}
log.debug(String.format("Click button found for %s", url))
val httpPost = HttpPost(url).also {
it.addHeader("Referer", url)
log.debug(String.format("Click button found for %s", image.url))
val httpPost = HttpPost(image.url).also {
it.addHeader("Referer", image.url)
it.entity = UrlEncodedFormEntity(
listOf(
BasicNameValuePair(
@@ -61,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, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.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, url
"Xpath '%s' cannot be found in '%s'", IMG_XPATH, image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle = imgNode.attributes.getNamedItem("alt").textContent.trim()
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim()
Pair(
@@ -1,30 +1,25 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import java.util.*
internal class DPicMeHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("dpic.me", 1, httpService, dataTransaction, downloadSpeedService) {
internal class DPicMeHost : Host("dpic.me", 1) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -32,11 +27,11 @@ internal class DPicMeHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle =
Optional.ofNullable(imgNode.attributes.getNamedItem("alt"))
.map { e: Node -> e.textContent.trim() }
@@ -4,10 +4,11 @@ import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.runBlocking
import me.vripper.download.ImageDownloadContext
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.DownloadSpeedService
import me.vripper.services.HTTPService
import me.vripper.utilities.HtmlUtils
@@ -18,21 +19,23 @@ import org.apache.hc.client5.http.classic.methods.HttpHead
import org.apache.hc.core5.http.ClassicHttpResponse
import org.apache.hc.core5.http.Header
import org.jetbrains.exposed.sql.transactions.transaction
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.w3c.dom.Document
import java.io.BufferedOutputStream
import java.nio.file.Files
import java.nio.file.Path
import kotlin.Throws
internal abstract class Host(
val hostName: String,
val hostId: Byte,
private val httpService: HTTPService,
private val dataTransaction: DataTransaction,
private val downloadSpeedService: DownloadSpeedService
) {
) : KoinComponent {
private val log by LoggerDelegate()
private val httpService: HTTPService by inject()
private val dataTransaction: DataTransaction by inject()
private val downloadSpeedService: DownloadSpeedService by inject()
companion object {
private const val READ_BUFFER_SIZE = 8192
private val hosts: MutableMap<String, Byte> = mutableMapOf()
@@ -48,49 +51,49 @@ internal abstract class Host(
@Throws(HostException::class)
abstract fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String>
@Throws(HostException::class)
fun downloadInternal(url: String, context: ImageDownloadContext): DownloadedImage {
val headers = head(url, context)
fun downloadInternal(image: ImageEntity, context: ImageDownloadContext): DownloadedImage {
if (hostId == 8.toByte()) {
return downloadByHost(image, context)
}
val headers = head(image.url, context)
// is the body of type image ?
val imageMimeType = getImageMimeType(headers)
val downloadedImage = if (imageMimeType != null) {
// a direct link, awesome
val downloadedImage = fetch(url, context) {
val downloadedImage = fetch(image.url, context) {
handleImageDownload(it, context)
}
DownloadedImage(getDefaultImageName(url), downloadedImage.first, downloadedImage.second)
DownloadedImage(getDefaultImageName(image.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")) {
val document = fetch(url, context) {
HtmlUtils.clean(it.entity.content)
}
if (log.isDebugEnabled) {
log.debug("Cleaning $url response", url)
}
val resolvedImage = resolve(url, document, context)
val downloadImage: Pair<Path, ImageMimeType> =
fetch(resolvedImage.second, context) {
handleImageDownload(it, context)
}
DownloadedImage(resolvedImage.first, downloadImage.first, downloadImage.second)
downloadByHost(image, context)
} else {
throw HostException("Unable to download $url, can't process content type $value")
throw HostException("Unable to download ${image.url}, can't process content type $value")
}
} else {
throw HostException("Unexpected server response for $url, response have no content type")
throw HostException("Unexpected server response for ${image.url}, response have no content type")
}
}
return downloadedImage
}
private fun downloadByHost(image: ImageEntity, context: ImageDownloadContext): DownloadedImage {
val resolvedImage = resolve(image, context)
val downloadImage: Pair<Path, ImageMimeType> =
fetch(resolvedImage.second, context) {
handleImageDownload(it, context)
}
return DownloadedImage(resolvedImage.first, downloadImage.first, downloadImage.second)
}
private fun handleImageDownload(
response: ClassicHttpResponse,
context: ImageDownloadContext
@@ -184,6 +187,19 @@ internal abstract class Host(
}
}
fun fetchDocument(
url: String,
context: ImageDownloadContext
): Document {
return fetch(url, context) {
HtmlUtils.clean(it.entity.content)
}.also {
if (log.isDebugEnabled) {
log.debug("Cleaning $url response", url)
}
}
}
@OptIn(ExperimentalStdlibApi::class)
private fun getImageMimeType(headers: Array<Header>): ImageMimeType? {
@@ -1,35 +1,30 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.apache.hc.client5.http.impl.cookie.BasicClientCookie
import org.w3c.dom.Document
import org.w3c.dom.Node
import java.sql.Date
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.*
internal class ImageBamHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imagebam.com", 2, httpService, dataTransaction, downloadSpeedService) {
internal class ImageBamHost : Host("imagebam.com", 2) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val doc = try {
log.debug(String.format("Looking for xpath expression %s in %s", CONTINUE_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", CONTINUE_XPATH, image.url))
if (XpathUtils.getAsNode(document, CONTINUE_XPATH) != null) {
val clientCookie = BasicClientCookie("nsfw_inter", "1")
clientCookie.domain = "www.imagebam.com"
@@ -39,7 +34,7 @@ internal class ImageBamHost(
LocalDateTime.now().plusDays(3).atZone(ZoneId.systemDefault()).toInstant()
)
context.httpContext.cookieStore.addCookie(clientCookie)
fetch(url, context) {
fetch(image.url, context) {
HtmlUtils.clean(it.entity.content)
}
} else {
@@ -49,7 +44,7 @@ internal class ImageBamHost(
throw HostException(e)
}
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -57,11 +52,11 @@ internal class ImageBamHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle = Optional.ofNullable(imgNode.attributes.getNamedItem("alt"))
.map { e: Node -> e.textContent.trim { it <= ' ' } }
.orElse("")
@@ -1,30 +1,25 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import java.util.*
internal class ImageTwistHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imagetwist.com", 3, httpService, dataTransaction, downloadSpeedService) {
internal class ImageTwistHost : Host("imagetwist.com", 3) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -32,11 +27,11 @@ internal class ImageTwistHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle =
Optional.ofNullable(imgNode.attributes.getNamedItem("alt"))
.map { obj: Node -> obj.textContent }
@@ -1,39 +1,34 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
internal class ImageVenueHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imagevenue.com", 4, httpService, dataTransaction, downloadSpeedService) {
internal class ImageVenueHost : Host("imagevenue.com", 4) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val doc = try {
val document = fetchDocument(image.url, context)
log.debug(
String.format(
"Looking for xpath expression %s in %s",
CONTINUE_BUTTON_XPATH,
url
image.url
)
)
if (XpathUtils.getAsNode(document, CONTINUE_BUTTON_XPATH) != null) {
// Button detected. No need to actually click it, just make the call again.
fetch(url, context) {
fetch(image.url, context) {
HtmlUtils.clean(it.entity.content)
}
} else {
@@ -43,7 +38,7 @@ internal class ImageVenueHost(
throw HostException(e)
}
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -51,11 +46,11 @@ internal class ImageVenueHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle = imgNode.attributes.getNamedItem("alt").textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' }
Pair(
@@ -1,28 +1,23 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
internal class ImageZillaHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imagezilla.net", 5, httpService, dataTransaction, downloadSpeedService) {
internal class ImageZillaHost : Host("imagezilla.net", 5) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val titleNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -30,17 +25,17 @@ internal class ImageZillaHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
log.debug(String.format("Resolving name for %s", url))
log.debug(String.format("Resolving name for %s", image.url))
var title = titleNode.attributes.getNamedItem("title").textContent.trim()
titleNode.textContent.trim()
if (title.isEmpty()) {
title = getDefaultImageName(url)
title = getDefaultImageName(image.url)
}
return try {
Pair(title, url.replace("show", "images"))
Pair(title, image.url.replace("show", "images"))
} catch (e: Exception) {
throw HostException("Unexpected error occurred", e)
}
@@ -1,29 +1,24 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
internal class ImgSpiceHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imgspice.com", 7, httpService, dataTransaction, downloadSpeedService) {
internal class ImgSpiceHost : Host("imgspice.com", 7) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -31,11 +26,11 @@ internal class ImgSpiceHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle = imgNode.attributes.getNamedItem("alt").textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' }
Pair(
@@ -1,29 +1,24 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
internal class ImgboxHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imgbox.com", 6, httpService, dataTransaction, downloadSpeedService) {
internal class ImgboxHost : Host("imgbox.com", 6) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -31,11 +26,11 @@ internal class ImgboxHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle = imgNode.attributes.getNamedItem("title").textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' }
Pair(imgTitle, imgUrl)
@@ -1,88 +1,79 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.HtmlProcessorException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.utilities.HtmlUtils
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.ApplicationProperties.IMX_SUBDOMAINS
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.apache.hc.client5.http.classic.methods.HttpPost
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity
import org.apache.hc.core5.http.message.BasicNameValuePair
import org.w3c.dom.Document
import org.w3c.dom.Node
import java.io.IOException
import me.vripper.utilities.RequestLimit
import org.apache.hc.client5.http.classic.methods.HttpHead
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) {
companion object {
private val resolvedHosts: Cache<Long, String> =
Caffeine.newBuilder().expireAfterAccess(5, TimeUnit.MINUTES).build()
}
internal class ImxHost(
private val httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("imx.to", 8, httpService, dataTransaction, downloadSpeedService) {
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(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val httpsUrl = url.replace("http:", "https:")
var value: String? = null
try {
log.debug("Looking for xpath expression $CONTINUE_BUTTON_XPATH in $httpsUrl")
val contDiv = XpathUtils.getAsNode(document, CONTINUE_BUTTON_XPATH)
?: throw HostException("$CONTINUE_BUTTON_XPATH cannot be found")
val node = contDiv.attributes.getNamedItem("value")
if (node != null) {
value = node.textContent
log.debug("Resolving name and image url for ${image.url}")
val imgTitle = String.format("IMG_%04d", image.index + 1)
synchronized(image.postId.toString().intern()) {
val subDomain = resolvedHosts.getIfPresent(image.postId)
if (subDomain != null) {
val imgUrl = findPattern(image, subDomain)
return Pair(
imgTitle.ifEmpty { getDefaultImageName(imgUrl) }, imgUrl
)
} else {
IMX_SUBDOMAINS.forEach { subDomain ->
val imgUrl = findPattern(image, subDomain)
val result = runCatching {
RequestLimit.getPermit(1)
val httpHead = HttpHead(imgUrl).also { context.requests.add(it) }
failFastHttpClient.execute(httpHead) { response ->
if (response.code / 100 != 2) {
throw HostException("Invalid response code ${response.code}")
}
}
}
if (result.isSuccess) {
resolvedHosts.put(image.postId, subDomain)
return Pair(
imgTitle.ifEmpty { getDefaultImageName(imgUrl) }, imgUrl
)
}
}
}
} catch (e: XpathException) {
throw HostException(e)
}
if (value == null) {
throw HostException("Failed to obtain value attribute from continue input")
}
log.debug("Click button found for $httpsUrl")
val httpPost: HttpPost = HttpPost(httpsUrl).also {
it.entity = UrlEncodedFormEntity(listOf(BasicNameValuePair("imgContinue", value)))
}.also { context.requests.add(it) }
log.debug("Requesting {}", httpPost)
val doc = try {
httpService.client.execute(
httpPost, context.httpContext
) { response ->
log.debug("Cleaning response for {}", httpPost)
HtmlUtils.clean(response.entity.content)
}
} catch (e: IOException) {
throw HostException(e)
} catch (e: HtmlProcessorException) {
throw HostException(e)
}
val imgNode: Node = try {
log.debug("Looking for xpath expression $IMG_XPATH in $httpsUrl")
XpathUtils.getAsNode(doc, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
} ?: throw HostException(
"Xpath $IMG_XPATH cannot be found in $httpsUrl"
)
return try {
log.debug("Resolving name and image url for $httpsUrl")
val imgTitle = imgNode.attributes.getNamedItem("alt").textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' }
Pair(
imgTitle.ifEmpty { getDefaultImageName(imgUrl) }, imgUrl
)
} catch (e: Exception) {
throw HostException("Unexpected error occurred", e)
}
throw HostException("Unable to find full size image for ${image.url}")
}
companion object {
private const val CONTINUE_BUTTON_XPATH = "//*[@name='imgContinue']"
private const val IMG_XPATH = "//img[@class='centred']"
}
private fun findPattern(image: ImageEntity, subDomain: String): String = image.thumbUrl
.replace("http:", "https:")
.replace("imx.to", subDomain)
.replace("upload/small/", "i/")
.replace("u/t/", "i/")
.replace("t/", "i/")
}
@@ -1,33 +1,30 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.HtmlUtils
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import java.net.URI
import java.net.URISyntaxException
internal class PimpandhostHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("pimpandhost.com", 9, httpService, dataTransaction, downloadSpeedService) {
internal class PimpandhostHost : Host("pimpandhost.com", 9) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val newUrl: String
try {
newUrl = appendUri(url.replace("http://", "https://").replace("-medium(\\.html)?".toRegex(), ""), "size=original")
newUrl = appendUri(
image.url.replace("http://", "https://").replace("-medium(\\.html)?".toRegex(), ""),
"size=original"
)
} catch (e: Exception) {
throw HostException(e)
}
@@ -43,7 +40,7 @@ internal class PimpandhostHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
@@ -1,29 +1,24 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
internal class PixRouteHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("pixroute.com", 11, httpService, dataTransaction, downloadSpeedService) {
internal class PixRouteHost : Host("pixroute.com", 11) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -31,11 +26,11 @@ internal class PixRouteHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
Pair(imgNode.attributes.getNamedItem("alt").textContent.trim { it <= ' ' },
imgNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' })
} catch (e: Exception) {
@@ -1,29 +1,24 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
internal class PixhostHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("pixhost.to", 10, httpService, dataTransaction, downloadSpeedService) {
internal class PixhostHost : Host("pixhost.to", 10) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -31,11 +26,11 @@ internal class PixhostHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.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,28 +1,23 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
internal class PixxxelsHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("pixxxels.cc", 12, httpService, dataTransaction, downloadSpeedService) {
internal class PixxxelsHost : Host("pixxxels.cc", 12) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val imgNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -30,11 +25,11 @@ internal class PixxxelsHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
val titleNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, image.url))
XpathUtils.getAsNode(document, TITLE_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -42,11 +37,11 @@ internal class PixxxelsHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
TITLE_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle = titleNode.textContent.trim { it <= ' ' }
val imgUrl = imgNode.attributes.getNamedItem("href").textContent.trim { it <= ' ' }
Pair(
@@ -1,30 +1,25 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import java.util.*
internal class PostImgHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("postimg.cc", 13, httpService, dataTransaction, downloadSpeedService) {
internal class PostImgHost : Host("postimg.cc", 13) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val titleNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, image.url))
XpathUtils.getAsNode(document, TITLE_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -32,11 +27,11 @@ internal class PostImgHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
TITLE_XPATH,
url
image.url
)
)
val urlNode = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -44,14 +39,14 @@ internal class PostImgHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle = Optional.ofNullable(titleNode)
.map { node: Node -> node.textContent.trim { it <= ' ' } }
.orElseGet { getDefaultImageName(url) }
.orElseGet { getDefaultImageName(image.url) }
Pair(imgTitle, urlNode.attributes.getNamedItem("href").textContent.trim { it <= ' ' })
} catch (e: Exception) {
throw HostException("Unexpected error occurred", e)
@@ -1,45 +1,40 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
internal class TurboImageHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("turboimagehost.com", 14, httpService, dataTransaction, downloadSpeedService) {
internal class TurboImageHost : Host("turboimagehost.com", 14) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
var title: String?
title = try {
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", TITLE_XPATH, image.url))
val titleNode: Node? = XpathUtils.getAsNode(document, TITLE_XPATH)
log.debug(String.format("Resolving name for %s", url))
log.debug(String.format("Resolving name for %s", image.url))
titleNode?.textContent?.trim { it <= ' ' }
} catch (e: XpathException) {
throw HostException(e)
}
if (title.isNullOrEmpty()) {
title = getDefaultImageName(url)
title = getDefaultImageName(image.url)
}
val urlNode: Node = XpathUtils.getAsNode(document, IMG_XPATH)
?: throw HostException(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return Pair(title, urlNode.attributes.getNamedItem("src").textContent.trim { it <= ' ' })
@@ -1,30 +1,25 @@
package me.vripper.host
import me.vripper.download.ImageDownloadContext
import me.vripper.entities.ImageEntity
import me.vripper.exception.HostException
import me.vripper.exception.XpathException
import me.vripper.services.*
import me.vripper.services.DownloadService.ImageDownloadContext
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.XpathUtils
import org.w3c.dom.Document
import org.w3c.dom.Node
import java.util.*
internal class ViprImHost(
httpService: HTTPService,
dataTransaction: DataTransaction,
downloadSpeedService: DownloadSpeedService,
) : Host("vipr.im", 15, httpService, dataTransaction, downloadSpeedService) {
internal class ViprImHost : Host("vipr.im", 15) {
private val log by LoggerDelegate()
@Throws(HostException::class)
override fun resolve(
url: String,
document: Document,
image: ImageEntity,
context: ImageDownloadContext
): Pair<String, String> {
val document = fetchDocument(image.url, context)
val imgNode: Node = try {
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, url))
log.debug(String.format("Looking for xpath expression %s in %s", IMG_XPATH, image.url))
XpathUtils.getAsNode(document, IMG_XPATH)
} catch (e: XpathException) {
throw HostException(e)
@@ -32,11 +27,11 @@ internal class ViprImHost(
String.format(
"Xpath '%s' cannot be found in '%s'",
IMG_XPATH,
url
image.url
)
)
return try {
log.debug(String.format("Resolving name and image url for %s", url))
log.debug(String.format("Resolving name and image url for %s", image.url))
val imgTitle =
Optional.ofNullable(imgNode.attributes.getNamedItem("alt"))
.map { obj: Node -> obj.textContent }
@@ -0,0 +1,54 @@
package me.vripper.listeners
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filterIsInstance
import me.vripper.event.EventBus
import me.vripper.event.SettingsUpdateEvent
import me.vripper.services.*
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 metadataService: MetadataService by inject()
private val settingsService: SettingsService by inject()
private val vgAuthService: VGAuthService by inject()
private val downloadSpeedService: DownloadSpeedService by inject()
private val httpService: HTTPService by inject()
private val retryPolicyService: RetryPolicyService by inject()
private val threadCacheService: ThreadCacheService by inject()
private val downloadService: DownloadService by inject()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var job: Job? = null
fun start() {
job?.cancel()
job = coroutineScope.launch {
eventBus.events.filterIsInstance(SettingsUpdateEvent::class).collect {
httpService.client.close()
httpService.buildRequestConfig(it.settings.connectionSettings.timeout.toLong())
httpService.buildConnectionConfig(it.settings.connectionSettings.timeout.toLong())
httpService.buildConnectionPool()
httpService.buildClientBuilder()
vgAuthService.authenticate(it.settings)
retryPolicyService.maxAttempts = it.settings.connectionSettings.maxAttempts
threadCacheService.invalidate()
}
}
dataTransaction.setDownloadingToStopped()
dataTransaction.stopImagesByPostIdAndIsNotCompleted()
settingsService.init()
metadataService.fetchExisting()
downloadSpeedService.init()
downloadService.init()
}
fun stop() {
job?.cancel()
downloadService.halt()
downloadService.stop()
downloadSpeedService.halt()
}
}
@@ -1,17 +0,0 @@
package me.vripper.listeners
import me.vripper.services.DataTransaction
import me.vripper.services.MetadataService
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
open class OnStartupListener : KoinComponent {
private val dataTransaction: DataTransaction by inject()
private val metadataService: MetadataService by inject()
open fun run() {
dataTransaction.setDownloadingToStopped()
dataTransaction.stopImagesByPostIdAndIsNotCompleted()
metadataService.init()
}
}
@@ -3,7 +3,6 @@ package me.vripper.services
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.time.sample
import kotlinx.serialization.json.Json
import me.vripper.download.DownloadService
import me.vripper.entities.*
import me.vripper.event.*
import me.vripper.exception.PostParseException
@@ -170,10 +169,6 @@ internal class AppEndpointService(
}
}
override fun ready(): Boolean {
return true
}
override suspend fun rename(postId: Long, newName: String) {
taskRunner.submit {
synchronized(postId.toString().intern()) {
@@ -456,4 +451,8 @@ internal class AppEndpointService(
"Successfully imported $postsCount posts and $threadCount threads"
}
}
override fun connectionState(): String {
return ""
}
}
@@ -1,8 +1,9 @@
package me.vripper.download
package me.vripper.services
import dev.failsafe.Failsafe
import dev.failsafe.RetryPolicy
import kotlinx.coroutines.Runnable
import dev.failsafe.function.CheckedRunnable
import kotlinx.coroutines.*
import me.vripper.entities.ImageEntity
import me.vripper.entities.PostEntity
import me.vripper.entities.Status
@@ -10,26 +11,47 @@ 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.services.DataTransaction
import me.vripper.services.RetryPolicyService
import me.vripper.services.SettingsService
import me.vripper.services.VGAuthService
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 vgAuthService: VGAuthService,
private val eventBus: EventBus
) {
private val maxPoolSize: Int = 24
private val log by LoggerDelegate()
private val running: MutableMap<Byte, MutableList<ImageDownloadRunnable>> = mutableMapOf()
@@ -37,8 +59,202 @@ internal class DownloadService(
private val lock = ReentrantLock()
private val condition = lock.newCondition()
init {
Thread.ofVirtual().name("Download Loop").unstarted(Runnable {
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) {
@@ -59,12 +275,18 @@ internal class DownloadService(
candidates.clear()
try {
condition.await()
} catch (e: InterruptedException) {
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
}
}
}).start()
log.info("Scheduler have been shutdown")
})
downloadMonitorThread?.start()
}
fun halt() {
downloadMonitorThread?.interrupt()
}
fun stop(postIds: List<Long> = emptyList()) {
@@ -189,7 +411,7 @@ internal class DownloadService(
private fun candidateCount(): Map<Byte, Int> {
val map: MutableMap<Byte, Int> = mutableMapOf()
Host.getHosts().values.forEach { host: Byte ->
Host.Companion.getHosts().values.forEach { host: Byte ->
val imageDownloadRunnableList: List<ImageDownloadRunnable> = running.computeIfAbsent(
host
) { mutableListOf() }
@@ -19,9 +19,11 @@ internal class DownloadSpeedService(
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val bytesCount = AtomicLong(0)
private var job: Job? = null
private var queueStateUpdateJob: Job? = null
init {
coroutineScope.launch {
fun init() {
queueStateUpdateJob?.cancel()
queueStateUpdateJob = coroutineScope.launch {
eventBus.events.filterIsInstance(QueueStateEvent::class).collect {
if (it.queueState.running + it.queueState.remaining > 0) {
if (job == null || job?.isActive == false) {
@@ -45,6 +47,11 @@ internal class DownloadSpeedService(
}
}
fun halt() {
queueStateUpdateJob?.cancel()
job?.cancel()
}
fun reportDownloadedBytes(count: Long) {
bytesCount.addAndGet(count)
}
@@ -1,100 +1,74 @@
package me.vripper.services
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filterIsInstance
import me.vripper.event.EventBus
import me.vripper.event.SettingsUpdateEvent
import me.vripper.utilities.ApplicationProperties
import org.apache.hc.client5.http.config.ConnectionConfig
import org.apache.hc.client5.http.config.RequestConfig
import org.apache.hc.client5.http.cookie.BasicCookieStore
import org.apache.hc.client5.http.cookie.StandardCookieSpec
import org.apache.hc.client5.http.impl.DefaultRedirectStrategy
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient
import org.apache.hc.client5.http.impl.classic.HttpClients
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager
import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder
import org.apache.hc.client5.http.io.HttpClientConnectionManager
import org.apache.hc.core5.pool.PoolConcurrencyPolicy
import org.apache.hc.core5.pool.PoolReusePolicy
import org.apache.hc.core5.util.Timeout
internal class HTTPService(
private val eventBus: EventBus,
settingsService: SettingsService
) {
internal class HTTPService {
companion object {
const val USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/118.0"
val cookieStore = BasicCookieStore()
}
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private lateinit var pcm: PoolingHttpClientConnectionManager
private lateinit var rc: RequestConfig
private lateinit var cc: ConnectionConfig
lateinit var client: CloseableHttpClient
private var connectionTimeout = settingsService.settings.connectionSettings.timeout
private var pcm: HttpClientConnectionManager = BasicHttpClientConnectionManager()
private var rc: RequestConfig = RequestConfig.DEFAULT
private var cc: ConnectionConfig = ConnectionConfig.DEFAULT
private var connectionExpiryJob: Job? = null
var client: CloseableHttpClient = HttpClients.createDefault()
init {
buildRequestConfig()
buildConnectionConfig()
buildConnectionPool()
buildClientBuilder()
coroutineScope.launch {
while (isActive) {
pcm.closeExpired()
delay(60_000)
}
}
coroutineScope.launch {
eventBus
.events
.filterIsInstance(SettingsUpdateEvent::class)
.collect {
if (connectionTimeout != it.settings.connectionSettings.timeout) {
connectionTimeout = it.settings.connectionSettings.timeout
client.close()
pcm.close()
buildRequestConfig()
buildConnectionConfig()
buildConnectionPool()
buildClientBuilder()
}
}
}
}
private fun buildConnectionPool() {
fun buildConnectionPool() {
pcm.close()
connectionExpiryJob?.cancel()
pcm = PoolingHttpClientConnectionManagerBuilder.create()
.setPoolConcurrencyPolicy(PoolConcurrencyPolicy.LAX)
.setConnPoolPolicy(PoolReusePolicy.FIFO)
.setDefaultConnectionConfig(cc)
.setMaxConnTotal(Int.MAX_VALUE)
.setMaxConnPerRoute(Int.MAX_VALUE)
.build()
.build().also {
connectionExpiryJob = coroutineScope.launch {
while (isActive) {
it.closeExpired()
delay(60_000)
}
}
}
}
private fun buildRequestConfig() {
fun buildRequestConfig(connectionTimeout: Long) {
rc = RequestConfig.custom()
.setConnectionRequestTimeout(Timeout.ofSeconds(connectionTimeout.toLong()))
.setConnectionRequestTimeout(Timeout.ofSeconds(connectionTimeout))
.setCookieSpec(StandardCookieSpec.RELAXED)
.build()
}
private fun buildConnectionConfig() {
fun buildConnectionConfig(connectionTimeout: Long) {
cc = ConnectionConfig.custom()
.setConnectTimeout(Timeout.ofSeconds(connectionTimeout.toLong()))
.setSocketTimeout(Timeout.ofSeconds(connectionTimeout.toLong()))
.setConnectTimeout(Timeout.ofSeconds(connectionTimeout))
.setSocketTimeout(Timeout.ofSeconds(connectionTimeout))
.build()
}
private fun buildClientBuilder() {
fun buildClientBuilder() {
client = HttpClients.custom()
.setConnectionManager(pcm)
.setRedirectStrategy(DefaultRedirectStrategy.INSTANCE)
.setUserAgent(USER_AGENT)
.setUserAgent(ApplicationProperties.USER_AGENT)
.disableAutomaticRetries()
.setDefaultRequestConfig(rc)
.setDefaultCookieStore(cookieStore)
.build()
}
}
}
@@ -42,7 +42,7 @@ interface IAppEndpointService {
suspend fun loggedInUser(): String
suspend fun getVersion(): String
suspend fun renameToFirst(postIds: List<Long>)
fun ready(): Boolean
suspend fun dbMigration(): String
suspend fun initLogger()
fun connectionState(): String
}
@@ -8,7 +8,7 @@ internal class MetadataService(
private val settingsService: SettingsService,
) {
fun init() {
fun fetchExisting() {
if (!settingsService.settings.viperSettings.fetchMetadata) {
return
}
@@ -1,32 +1,12 @@
package me.vripper.services
import dev.failsafe.RetryPolicy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import me.vripper.event.EventBus
import me.vripper.event.SettingsUpdateEvent
import me.vripper.utilities.LoggerDelegate
import java.time.temporal.ChronoUnit
internal class RetryPolicyService(
val eventBus: EventBus, settingsService: SettingsService
) {
internal class RetryPolicyService {
private val log by LoggerDelegate()
private var maxAttempts: Int = settingsService.settings.connectionSettings.maxAttempts
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
init {
coroutineScope.launch {
eventBus.events.filterIsInstance(SettingsUpdateEvent::class).collect {
if (maxAttempts != it.settings.connectionSettings.maxAttempts) {
maxAttempts = it.settings.connectionSettings.maxAttempts
}
}
}
}
var maxAttempts: Int = 3
fun <T> buildRetryPolicy(message: String): RetryPolicy<T> {
return RetryPolicy.builder<T>().withDelay(2, 5, ChronoUnit.SECONDS).withMaxAttempts(maxAttempts)
@@ -1,5 +1,6 @@
package me.vripper.services
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
@@ -9,7 +10,7 @@ import me.vripper.exception.ValidationException
import me.vripper.model.Settings
import me.vripper.utilities.ApplicationProperties.VRIPPER_DIR
import me.vripper.utilities.LoggerDelegate
import org.apache.commons.codec.digest.DigestUtils
import me.vripper.utilities.md5Hex
import java.io.FileWriter
import java.nio.file.*
import kotlin.io.path.readText
@@ -28,16 +29,13 @@ class SettingsService(private val eventBus: EventBus) {
}
var settings = Settings()
init {
init()
}
private fun init() {
fun init() {
loadViperProxies()
restore()
eventBus.publishEvent(SettingsUpdateEvent(settings))
}
@OptIn(ExperimentalSerializationApi::class)
private fun loadViperProxies() {
try {
SettingsService::class.java.getResourceAsStream("/proxies.json")?.use {
@@ -84,7 +82,7 @@ class SettingsService(private val eventBus: EventBus) {
check(settings)
val viperSettings = if (settings.viperSettings.login) {
if (this.settings.viperSettings.password != settings.viperSettings.password) {
settings.viperSettings.copy(password = DigestUtils.md5Hex(settings.viperSettings.password))
settings.viperSettings.copy(password = md5Hex(settings.viperSettings.password))
} else {
settings.viperSettings
}
@@ -137,7 +135,7 @@ class SettingsService(private val eventBus: EventBus) {
fun check(settings: Settings) {
val path: Path = try {
Paths.get(settings.downloadSettings.downloadPath)
} catch (e: InvalidPathException) {
} catch (_: InvalidPathException) {
throw ValidationException(
String.format(
"%s is invalid", settings.downloadSettings.downloadPath
@@ -2,35 +2,28 @@ package me.vripper.services
import com.github.benmanes.caffeine.cache.Caffeine
import com.github.benmanes.caffeine.cache.LoadingCache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import me.vripper.event.EventBus
import me.vripper.event.SettingsUpdateEvent
import me.vripper.vgapi.ThreadItem
import me.vripper.vgapi.ThreadLookupAPIParser
import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeUnit
internal class ThreadCacheService(val eventBus: EventBus) {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun init() {
coroutineScope.launch {
eventBus.events.filterIsInstance(SettingsUpdateEvent::class).collect {
cache.invalidateAll()
}
}
}
internal class ThreadCacheService(val dataTransaction: DataTransaction) {
private val cache: LoadingCache<Long, ThreadItem> =
Caffeine.newBuilder().expireAfterWrite(20, TimeUnit.MINUTES).build { threadId ->
ThreadLookupAPIParser(threadId).parse()
val threadItem = ThreadLookupAPIParser(threadId).parse()
dataTransaction.findThreadByThreadId(threadItem.threadId).ifPresent {
if (threadItem.postItemList.isNotEmpty()) {
dataTransaction.update(it.copy(total = threadItem.postItemList.size))
}
}
threadItem
}
fun invalidate() {
cache.invalidateAll()
}
@Throws(ExecutionException::class)
operator fun get(threadId: Long): ThreadItem {
return cache[threadId]
@@ -1,13 +1,7 @@
package me.vripper.services
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import me.vripper.entities.PostEntity
import me.vripper.event.EventBus
import me.vripper.event.SettingsUpdateEvent
import me.vripper.event.VGUserLoginEvent
import me.vripper.exception.VripperException
import me.vripper.model.Settings
@@ -25,49 +19,21 @@ import org.apache.hc.core5.http.message.BasicNameValuePair
internal class VGAuthService(
private val cm: HTTPService,
private val settingsService: SettingsService,
private val eventBus: EventBus
private val eventBus: EventBus,
) {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val log by LoggerDelegate()
val context: HttpClientContext = HttpClientContext.create()
private val vgCookies: MutableList<Cookie> = mutableListOf()
var loggedUser = ""
var authenticated = false
val clickCookies: List<Cookie>
get() {
return if (authenticated) {
val useridCookie = context.cookieStore.cookies.first { it.name.equals("vg_userid") }.let { cookie ->
BasicClientCookie(cookie.name, cookie.value).apply {
domain = "viper.click"
}
}
val passwordCookie = context.cookieStore.cookies.first { it.name.equals("vg_password") }.let { cookie ->
BasicClientCookie(cookie.name, cookie.value).apply {
domain = "viper.click"
}
}
listOf(useridCookie, passwordCookie)
} else {
emptyList()
}
}
private var authenticated = false
init {
context.cookieStore = BasicCookieStore()
coroutineScope.launch {
eventBus.events.filterIsInstance(SettingsUpdateEvent::class).collect {
authenticate(it.settings)
}
}
authenticate(settingsService.settings)
}
private fun authenticate(settings: Settings) {
authenticated = false
fun authenticate(settings: Settings) {
if (!settings.viperSettings.login) {
log.debug("Authentication option is disabled")
context.cookieStore.clear()
authenticated = false
loggedUser = ""
synchronized(vgCookies) {
vgCookies.clear()
}
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
return
}
@@ -75,8 +41,11 @@ internal class VGAuthService(
val password = settings.viperSettings.password
if (username.isEmpty() || password.isEmpty()) {
log.error("Cannot authenticate with ViperGirls credentials, username or password is empty")
context.cookieStore.clear()
authenticated = false
loggedUser = ""
synchronized(vgCookies) {
vgCookies.clear()
}
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
return
}
@@ -90,8 +59,12 @@ internal class VGAuthService(
)
)
}
log.info("Authenticating: ${postAuth.uri}")
try {
val context = HttpClientContext.create().apply {
cookieStore =
BasicCookieStore()
}
cm.client.execute(postAuth, context) { response ->
if (response.code / 100 != 2) {
throw VripperException("Unexpected response code returned ${response.code}")
@@ -99,31 +72,72 @@ internal class VGAuthService(
val responseBody = EntityUtils.toString(response.entity)
log.debug("Authentication with ViperGirls response body:{}", responseBody)
}
if (context.cookieStore.cookies.stream().map { obj: Cookie -> obj.name }
.noneMatch { e: String -> e == "vg_userid" }) {
val userIdCookie = context.cookieStore.cookies.find { it.name == "vg_userid" }
val passwordCookie = context.cookieStore.cookies.find { it.name == "vg_password" }
if (userIdCookie == null || passwordCookie == null) {
log.error(
"Failed to authenticate user with {}, missing vg_userid cookie",
"Failed to authenticate user with {}, missing vg_userid/vg_password cookie",
settings.viperSettings.host
)
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
return
}
synchronized(vgCookies) {
vgCookies.clear()
vgCookies.add(BasicClientCookie(userIdCookie.name, userIdCookie.value).apply {
domain = userIdCookie.domain
})
vgCookies.add(BasicClientCookie(passwordCookie.name, passwordCookie.value).apply {
domain = passwordCookie.domain
})
}
} catch (e: Exception) {
context.cookieStore.clear()
loggedUser = ""
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
log.error(
"Failed to authenticate user with " + settings.viperSettings.host, e
)
authenticated = false
loggedUser = ""
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
return
}
authenticated = true
loggedUser = username
eventBus.publishEvent(VGUserLoginEvent(loggedUser))
log.info("Successfully logged in as: $loggedUser")
}
fun leaveThanks(postEntity: PostEntity) {
val context = createVgContext()
taskRunner.submit(
LeaveThanksTask(postEntity, authenticated, context)
)
}
fun createVgContext(): HttpClientContext {
val context = HttpClientContext().apply { cookieStore = BasicCookieStore() }
synchronized(vgCookies) {
if (authenticated && vgCookies.isNotEmpty()) {
vgCookies.forEach { c -> context.cookieStore.addCookie(c) }
}
}
return context
}
fun createClickContext(): HttpClientContext {
val context = HttpClientContext().apply { cookieStore = BasicCookieStore() }
synchronized(vgCookies) {
if (authenticated && vgCookies.isNotEmpty()) {
vgCookies.forEach { c ->
context.cookieStore.addCookie(
BasicClientCookie(
c.name,
c.value
).apply { domain = "viper.click" })
}
}
}
return context
}
}
@@ -1,11 +1,7 @@
package me.vripper.tasks
import me.vripper.download.DownloadService
import me.vripper.model.ThreadPostId
import me.vripper.services.DataTransaction
import me.vripper.services.MetadataService
import me.vripper.services.SettingsService
import me.vripper.services.ThreadCacheService
import me.vripper.services.*
import me.vripper.utilities.LoggerDelegate
import me.vripper.vgapi.PostItem
import me.vripper.vgapi.PostLookupAPIParser
@@ -26,6 +22,7 @@ internal class AddPostTask(private val items: List<ThreadPostId>) : KoinComponen
val toProcess = mutableListOf<PostItem>()
for ((threadId, postId) in items) {
if (dataTransaction.exists(postId)) {
log.info("Post $postId already loaded")
continue
}
@@ -60,6 +57,10 @@ internal class AddPostTask(private val items: List<ThreadPostId>) : KoinComponen
toProcess.add(postItem)
}
if (toProcess.isEmpty()) {
return
}
val posts = try {
dataTransaction.newPosts(toProcess.toList())
} catch (e: Exception) {
@@ -45,7 +45,7 @@ internal class FetchMetadataTask(
RequestLimit.getPermit(1)
log.debug("Requesting {}", httpGet.uri)
val response = httpService.client.execute(httpGet, vgAuthService.context) {
val response = httpService.client.execute(httpGet, vgAuthService.createVgContext()) {
if (it.code / 100 != 2) {
throw DownloadException("Unexpected response code '${it.code}' for $httpGet")
}
@@ -42,7 +42,7 @@ internal class LeaveThanksTask(
)
}
RequestLimit.getPermit(1)
log.debug("Requesting {}", postThanks.uri)
log.info("Posting {}", postThanks.uri)
cm.client.execute(postThanks, context) { response ->
if (response.code / 100 != 2) {
throw VripperException("Unexpected response code '${response.code}' for $postThanks")
@@ -0,0 +1,72 @@
package me.vripper.utilities
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.concurrent.locks.ReentrantLock
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.concurrent.withLock
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalEncodingApi::class)
object AesUtils {
val mutex = ReentrantLock()
val secureRandom: SecureRandom = SecureRandom()
val cipher: Cipher = Cipher.getInstance(AES_ALGORITHM_GCM)
const val SHA_CRYPT: String = "SHA-256"
const val AES_ALGORITHM: String = "AES"
const val AES_ALGORITHM_GCM: String = "AES/GCM/NoPadding"
const val IV_LENGTH_ENCRYPT: Int = 12
const val TAG_LENGTH_ENCRYPT: Int = 16
fun aesDecrypt(data: ByteArray, passPhrase: String): ByteArray {
val key = generateAesKeyFromPassphrase(passPhrase)
val iv = ByteArray(IV_LENGTH_ENCRYPT)
System.arraycopy(data, 0, iv, 0, iv.size)
val encryptedText = ByteArray(data.size - IV_LENGTH_ENCRYPT)
System.arraycopy(data, IV_LENGTH_ENCRYPT, encryptedText, 0, encryptedText.size)
val gcmSpec = GCMParameterSpec(TAG_LENGTH_ENCRYPT * 8, iv)
val decryptedBytes = mutex.withLock {
cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec)
cipher.doFinal(encryptedText)
}
return decryptedBytes
}
fun aesEncrypt(data: ByteArray, passPhrase: String): ByteArray {
val iv = ByteArray(IV_LENGTH_ENCRYPT)
secureRandom.nextBytes(iv)
val key = generateAesKeyFromPassphrase(passPhrase)
val gcmSpec = GCMParameterSpec(TAG_LENGTH_ENCRYPT * 8, iv)
val encryptedBytes = mutex.withLock {
cipher.init(Cipher.ENCRYPT_MODE, key, gcmSpec)
cipher.doFinal(data)
}
// Combine IV and encrypted text and encode them as Base64
val combinedIvAndCipherText = ByteArray(iv.size + encryptedBytes.size)
System.arraycopy(iv, 0, combinedIvAndCipherText, 0, iv.size)
System.arraycopy(encryptedBytes, 0, combinedIvAndCipherText, iv.size, encryptedBytes.size)
return combinedIvAndCipherText
}
fun generateAesKeyFromPassphrase(passPhrase: String): SecretKeySpec {
val sha256 = MessageDigest.getInstance(SHA_CRYPT)
val keyBytes = sha256.digest(passPhrase.toByteArray(StandardCharsets.UTF_8))
return SecretKeySpec(keyBytes, AES_ALGORITHM)
}
}
@@ -11,7 +11,9 @@ import java.net.http.HttpResponse
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.Path
import kotlin.io.path.exists
import kotlin.io.path.pathString
import kotlin.io.path.readLines
object ApplicationProperties {
val VERSION: String =
@@ -24,6 +26,11 @@ object ApplicationProperties {
private val json = Json {
ignoreUnknownKeys = true
}
const val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.0"
val USER_AGENT: String
const val DEFAULT_IMX_SUBDOMAINS = "i.imx.to,i001.imx.to,i002.imx.to,i004.imx.to,i006.imx.to,i003.imx.to"
val IMX_SUBDOMAINS: Set<String>
@Serializable
internal data class ReleaseResponse(@SerialName("tag_name") val tagName: String)
@@ -31,6 +38,19 @@ object ApplicationProperties {
init {
Files.createDirectories(VRIPPER_DIR)
System.setProperty("VRIPPER_DIR", VRIPPER_DIR.toRealPath().pathString)
val userAgentOverride = VRIPPER_DIR.resolve("user-agent.txt")
USER_AGENT = if (userAgentOverride.exists()) {
userAgentOverride.readLines().firstOrNull() ?: DEFAULT_USER_AGENT
} else {
DEFAULT_USER_AGENT
}
val imxSubdomainsOverride = VRIPPER_DIR.resolve("imx-subdomains.txt")
IMX_SUBDOMAINS = if (imxSubdomainsOverride.exists()) {
imxSubdomainsOverride.readLines().firstOrNull() ?: DEFAULT_IMX_SUBDOMAINS
} else {
DEFAULT_IMX_SUBDOMAINS
}.split(",").toSet()
}
fun latestVersion(): String {
@@ -7,7 +7,6 @@ import liquibase.database.DatabaseFactory
import liquibase.database.jvm.JdbcConnection
import liquibase.resource.ClassLoaderResourceAccessor
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.transactions.TransactionManager
import java.sql.Connection
object DatabaseManager {
@@ -19,11 +18,7 @@ object DatabaseManager {
.also { update(it.connector.invoke().connection as Connection) }
}
fun disconnect() {
database?.let { TransactionManager.closeAndUnregister(it) }
}
fun update(connection: Connection) {
private fun update(connection: Connection) {
connection.use { cn ->
val database: liquibase.database.Database = DatabaseFactory.getInstance()
.findCorrectDatabaseImplementation(JdbcConnection(cn))
@@ -0,0 +1,10 @@
package me.vripper.utilities
import java.security.MessageDigest
import javax.xml.bind.DatatypeConverter
fun md5Hex(data: String): String {
val md: MessageDigest = MessageDigest.getInstance("MD5")
md.update(data.toByteArray())
return DatatypeConverter.printHexBinary(md.digest()).lowercase()
}
@@ -11,7 +11,6 @@ import me.vripper.tasks.Tasks
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.RequestLimit
import org.apache.hc.client5.http.classic.methods.HttpGet
import org.apache.hc.client5.http.protocol.HttpClientContext
import org.apache.hc.core5.http.io.entity.EntityUtils
import org.apache.hc.core5.net.URIBuilder
import org.koin.core.component.KoinComponent
@@ -46,8 +45,7 @@ internal class PostLookupAPIParser(private val threadId: Long, private val postI
log.info("Requesting {}", httpGet.uri)
httpService.client.execute(
httpGet,
HttpClientContext.create()
.apply { vgAuthService.clickCookies.forEach { cookieStore.addCookie(it) } }
vgAuthService.createClickContext()
) { response ->
if (response.code / 100 != 2) {
throw DownloadException("Unexpected response code '${response.code}' for $httpGet")
@@ -11,8 +11,6 @@ import me.vripper.tasks.Tasks
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.RequestLimit
import org.apache.hc.client5.http.classic.methods.HttpGet
import org.apache.hc.client5.http.cookie.BasicCookieStore
import org.apache.hc.client5.http.protocol.HttpClientContext
import org.apache.hc.core5.http.io.entity.EntityUtils
import org.apache.hc.core5.net.URIBuilder
import org.koin.core.component.KoinComponent
@@ -48,10 +46,7 @@ internal class ThreadLookupAPIParser(private val threadId: Long) : KoinComponent
RequestLimit.getPermit(1)
log.info("Requesting {}", httpGet.uri)
cm.client.execute(
httpGet, HttpClientContext.create().apply {
cookieStore = BasicCookieStore()
vgAuthService.clickCookies.forEach { cookieStore.addCookie(it) }
}
httpGet, vgAuthService.createClickContext()
) { response ->
if (response.code / 100 != 2) {
throw DownloadException("Unexpected response code '${response.code}' for $httpGet")
+4 -4
View File
@@ -23,13 +23,13 @@
</rollingPolicy>
</appender>
<appender name="RING" class="me.vripper.RingAppender">
<appender name="RING" class="me.vripper.RingAppender"/>
</appender>
<logger name="Exposed" level="OFF"/>
<logger name="me.vripper" level="info"/>
<logger name="Exposed" level="off"/>
<logger name="org.springframework.web.socket.config.WebSocketMessageBrokerStats" level="off"/>
<Logger name="org.apache.hc.client5.http" level="off"/>
<Logger name="org.apache.hc.client5.http.wire" level="off"/>
<root level="INFO">
<appender-ref ref="STDOUT"/>
@@ -39,7 +39,7 @@ class VripperGuiApplication : App(
override fun start(stage: Stage) {
Thread.setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler { t, e ->
log.error("Thread $t threw an exception", e)
log.error("Thread $t threw an exception: ${e.message}", e)
})
if (widgetsController.currentSettings.darkMode) {
setUserAgentStylesheet(CupertinoDark().userAgentStylesheet)
@@ -24,31 +24,55 @@ class AboutFragment : Fragment("About") {
spacing = 15.0
imageview("icons/64x64.png")
vbox(spacing = 5.0) {
text("VRipper") {
text("VRipper ${ApplicationProperties.VERSION}") {
style {
fontWeight = FontWeight.BOLD
fontSize = Dimension(18.0, Dimension.LinearUnits.px)
}
}
text("Version ${ApplicationProperties.VERSION}")
text("Developed by dev-claw and VRipper working group")
hbox(spacing = 5.0) {
hyperlink {
imageview("icons/github-mark.png").apply {
isPreserveRatio = true
fitHeight = 32.0
if (widgetsController.currentSettings.darkMode) {
imageview("icons/github-mark-white.png").apply {
isPreserveRatio = true
fitHeight = 32.0
}
} else {
imageview("icons/github-mark.png").apply {
isPreserveRatio = true
fitHeight = 32.0
}
}
action {
openLink("https://github.com/dev-claw/vripper-project")
}
}
hyperlink {
imageview("icons/buymeacoffee-logo.png").apply {
isPreserveRatio = true
fitHeight = 32.0
}
form {
fieldset("Donation") {
minWidth = 400.0
field {
hyperlink {
imageview("icons/buymeacoffee-logo.png").apply {
isPreserveRatio = true
fitHeight = 32.0
}
action {
openLink("https://buymeacoffee.com/devclaw")
}
}
}
action {
openLink("https://buymeacoffee.com/devclaw")
field("ETH:") {
textfield("0xDdac82B16dC5E3D742fc915ffF583D8548A301cA") {
isEditable = false
}
}
field("BTC:") {
textfield("bc1qcqudnkrndwyadsjwrxww42svkf8trnzx3c8vlr") {
isEditable = false
}
}
}
}
@@ -17,7 +17,7 @@ class ConnectionSettingsFragment : Fragment("Connection Settings") {
init {
coroutineScope.launch {
async { connectionSettings = settingsController.findConnectionSettings() }.await()
connectionSettings = settingsController.findConnectionSettings()
runLater {
with(root) {
form {
@@ -21,16 +21,15 @@ class DownloadSettingsFragment : Fragment("Download Settings") {
init {
coroutineScope.launch {
async {
downloadSettings = settingsController.findDownloadSettings()
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
}.await()
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 {
@@ -2,34 +2,25 @@ package me.vripper.gui.components.fragments
import atlantafx.base.theme.Styles
import atlantafx.base.util.IntegerStringConverter
import io.grpc.ConnectivityState
import javafx.beans.property.SimpleBooleanProperty
import javafx.beans.property.SimpleStringProperty
import javafx.geometry.Pos
import javafx.scene.control.RadioButton
import javafx.scene.control.Spinner
import javafx.scene.control.ToggleGroup
import javafx.scene.layout.VBox
import kotlinx.coroutines.*
import kotlinx.coroutines.runBlocking
import me.vripper.gui.VripperGuiApplication
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.listener.GuiStartupLister
import me.vripper.gui.services.GrpcEndpointService
import me.vripper.gui.utils.ActiveUICoroutines
import me.vripper.services.IAppEndpointService
import me.vripper.utilities.DatabaseManager
import me.vripper.gui.utils.AppEndpointManager
import me.vripper.listeners.AppManager
import tornadofx.*
class SessionFragment : Fragment("Change Session") {
private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val widgetsController: WidgetsController by inject()
private val grpcEndpointService: GrpcEndpointService by di("remoteAppEndpointService")
private val appEndpointService: IAppEndpointService by di("localAppEndpointService")
private val toggleGroup = ToggleGroup()
private val disableConfirm = SimpleBooleanProperty(false)
private val message = SimpleStringProperty()
override val root = VBox().apply {
alignment = Pos.CENTER
padding = insets(all = 5)
@@ -60,99 +51,57 @@ class SessionFragment : Fragment("Change Session") {
IntegerStringConverter.createFor(this)
})
}
field("Passcode") {
enableWhen { remoteRadio.selectedProperty() }
passwordfield(widgetsController.currentSettings.remoteSessionModel.passcodeProperty) {}
}
}
}
borderpane {
left {
padding = insets(all = 5.0)
label(message) {
disableWhen { message.isBlank() }
}
}
right {
padding = insets(all = 5.0)
button("Ok") {
enableWhen { toggleGroup.selectedToggleProperty().isNotNull.and(disableConfirm.not()) }
enableWhen { toggleGroup.selectedToggleProperty().isNotNull }
addClass(Styles.ACCENT)
isDefaultButton = true
action {
val selectedToggle = toggleGroup.selectedToggle
if (selectedToggle == null) {
VripperGuiApplication.APP_INSTANCE.stop()
runBlocking {
GuiEventBus.publishEvent(GuiEventBus.ChangingSession)
AppManager.stop()
grpcEndpointService.disconnect()
}
when ((selectedToggle as RadioButton).id) {
"localSession" -> {
coroutineScope.launch {
runLater {
disableConfirm.set(true)
message.set("Starting...")
}
runBlocking {
widgetsController.currentSettings.localSession = true
GuiEventBus.publishEvent(GuiEventBus.ChangingSession)
while (ActiveUICoroutines.all().isNotEmpty()) {
delay(200)
}
grpcEndpointService.disconnect()
DatabaseManager.connect()
GuiStartupLister().run()
AppEndpointManager.set(GuiEventBus.LocalSession)
AppManager.start()
GuiEventBus.publishEvent(GuiEventBus.LocalSession)
runLater {
close()
}
}
}
"remoteSession" -> {
coroutineScope.launch {
runLater {
disableConfirm.set(true)
message.set("Connecting...")
}
if (widgetsController.currentSettings.localSession) {
appEndpointService.stopAll()
}
runBlocking {
widgetsController.currentSettings.localSession = false
GuiEventBus.publishEvent(GuiEventBus.ChangingSession)
while (ActiveUICoroutines.all().isNotEmpty()) {
delay(200)
}
DatabaseManager.disconnect()
grpcEndpointService.disconnect()
AppEndpointManager.set(GuiEventBus.RemoteSession)
grpcEndpointService.connect(
widgetsController.currentSettings.remoteSessionModel.host,
widgetsController.currentSettings.remoteSessionModel.port
widgetsController.currentSettings.remoteSessionModel.port,
widgetsController.currentSettings.remoteSessionModel.passcode,
)
repeat(10) {
if (grpcEndpointService.connectionState() == ConnectivityState.READY) {
GuiEventBus.publishEvent(GuiEventBus.RemoteSession)
runLater {
close()
}
cancel()
}
delay(333)
}
runLater {
runBlocking {
GuiEventBus.publishEvent(GuiEventBus.RemoteSessionFailure)
}
disableConfirm.set(false)
message.set("Unable to connect")
}
GuiEventBus.publishEvent(GuiEventBus.RemoteSession)
}
}
else -> VripperGuiApplication.APP_INSTANCE.stop()
}
runLater {
close()
}
}
}
}
}
}
}
override fun onUndock() {
coroutineScope.cancel()
}
}
@@ -21,10 +21,8 @@ class SystemSettingsFragment : Fragment("System Settings") {
init {
coroutineScope.launch {
async {
systemSettings = settingsController.findSystemSettings()
systemSettingsModel.tempPath = systemSettings.tempPath
}.await()
systemSettings = settingsController.findSystemSettings()
systemSettingsModel.tempPath = systemSettings.tempPath
runLater {
with(root) {
form {
@@ -20,16 +20,14 @@ class ViperSettingsFragment : Fragment("Viper Settings") {
init {
coroutineScope.launch {
async {
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())
}.await()
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 {
@@ -7,50 +7,26 @@ import javafx.scene.control.ButtonType
import javafx.scene.control.ContentDisplay
import javafx.scene.layout.Priority
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.catch
import me.vripper.gui.components.Shared
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.event.GuiEventBus
import me.vripper.gui.utils.ActiveUICoroutines
import me.vripper.services.IAppEndpointService
import me.vripper.utilities.LoggerDelegate
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
class ActionBarView : View() {
private val logger by LoggerDelegate()
private val downloadActiveProperty = SimpleBooleanProperty(true)
private val postController: PostController by inject()
private val postsTableView: PostsTableView by inject()
private val actionBarController: ActionBarController by inject()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val grpcEndpointService: IAppEndpointService by di("remoteAppEndpointService")
private val localEndpointService: IAppEndpointService by di("localAppEndpointService")
private val running = SimpleIntegerProperty(0)
override val root = toolbar {}
init {
coroutineScope.launch {
GuiEventBus.events.collect { event ->
when (event) {
is GuiEventBus.LocalSession -> {
connect(localEndpointService)
}
is GuiEventBus.RemoteSession -> {
connect(grpcEndpointService)
}
is GuiEventBus.ChangingSession -> {
ActiveUICoroutines.actionBar.forEach { it.cancelAndJoin() }
ActiveUICoroutines.actionBar.clear()
}
}
}
}
with(root) {
id = "action_toolbar"
padding = insets(all = 5)
@@ -127,18 +103,13 @@ class ActionBarView : View() {
}
}
downloadActiveProperty.bind(running.greaterThan(0))
}
private fun connect(appEndpointService: IAppEndpointService) {
coroutineScope.launch {
appEndpointService.onQueueStateUpdate().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.collect {
actionBarController.onQueueStateUpdate.collect {
runLater {
running.set(it.running)
}
}
}.also { ActiveUICoroutines.actionBar.add(it) }
}
}
}
@@ -19,12 +19,9 @@ import me.vripper.gui.components.cells.ProgressTableCell
import me.vripper.gui.components.cells.StatusTableCell
import me.vripper.gui.controller.ImageController
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.model.ImageModel
import me.vripper.gui.utils.ActiveUICoroutines
import me.vripper.gui.utils.Preview
import me.vripper.gui.utils.openLink
import me.vripper.services.IAppEndpointService
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
@@ -35,40 +32,14 @@ class ImagesTableView : View("Photos") {
private val tableView: TableView<ImageModel>
private val imageController: ImageController by inject()
private val widgetsController: WidgetsController by inject()
private val localAppEndpointService: IAppEndpointService by di("localAppEndpointService")
private val remoteAppEndpointService: IAppEndpointService by di("remoteAppEndpointService")
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) {}
init {
if (widgetsController.currentSettings.localSession) {
imageController.appEndpointService = localAppEndpointService
} else {
imageController.appEndpointService = remoteAppEndpointService
}
coroutineScope.launch {
GuiEventBus.events.collect { event ->
when (event) {
is GuiEventBus.LocalSession -> {
imageController.appEndpointService = localAppEndpointService
}
is GuiEventBus.RemoteSession -> {
imageController.appEndpointService = remoteAppEndpointService
}
is GuiEventBus.ChangingSession -> {
ActiveUICoroutines.images.forEach { it.cancelAndJoin() }
ActiveUICoroutines.images.clear()
}
}
}
}
with(root) {
tableView = tableview(items) {
isTableMenuButtonVisible = true
@@ -271,8 +242,10 @@ class ImagesTableView : View("Photos") {
}
fun setPostId(postId: Long?) {
ActiveUICoroutines.images.forEach { it.cancel() }
ActiveUICoroutines.images.clear()
runBlocking {
jobs.forEach { it.cancelAndJoin() }
jobs.clear()
}
runLater {
items.clear()
}
@@ -280,15 +253,14 @@ class ImagesTableView : View("Photos") {
return
}
coroutineScope.launch {
val list = coroutineScope.async {
imageController.findImages(postId)
}.await()
val list = imageController.findImages(postId)
runLater {
items.addAll(list)
tableView.sort()
tableView.placeholder = Label("No content in table")
}
}
coroutineScope.launch {
imageController.onUpdateImages(postId).collect { image ->
runLater {
@@ -303,7 +275,7 @@ class ImagesTableView : View("Photos") {
)
}
}
}.also { ActiveUICoroutines.images.add(it) }
}.also { jobs.add(it) }
coroutineScope.launch {
imageController.onStopped().collect {
@@ -315,6 +287,6 @@ class ImagesTableView : View("Photos") {
}
}
}
}.also { ActiveUICoroutines.images.add(it) }
}.also { jobs.add(it) }
}
}
@@ -4,13 +4,13 @@ import javafx.scene.control.ProgressIndicator.INDETERMINATE_PROGRESS
import javafx.scene.effect.DropShadow
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filterIsInstance
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.listener.GuiStartupLister
import me.vripper.gui.services.GrpcEndpointService
import me.vripper.gui.utils.AppEndpointManager
import me.vripper.gui.utils.ClipboardManager
import me.vripper.gui.utils.Watcher
import me.vripper.listeners.AppManager
import me.vripper.utilities.DatabaseManager
import tornadofx.*
@@ -23,45 +23,24 @@ class LoadingView : View("VRipper") {
override val root = borderpane {}
init {
DatabaseManager.connect()
ClipboardManager.init()
coroutineScope.launch {
GuiEventBus.events.filterIsInstance(GuiEventBus.ApplicationInitialized::class).collect {
if (widgetsController.currentSettings.localSession) {
DatabaseManager.connect()
GuiStartupLister().run()
runLater {
replaceWith(find<AppView>())
}
AppEndpointManager.set(GuiEventBus.LocalSession)
AppManager.start()
} else {
AppEndpointManager.set(GuiEventBus.RemoteSession)
grpcEndpointService.connect(
widgetsController.currentSettings.remoteSessionModel.host,
widgetsController.currentSettings.remoteSessionModel.port
widgetsController.currentSettings.remoteSessionModel.port,
widgetsController.currentSettings.remoteSessionModel.passcode,
)
repeat(10) {
if (grpcEndpointService.ready()) {
return@repeat
}
delay(333)
}
runLater {
replaceWith(find<AppView>())
runBlocking {
GuiEventBus.publishEvent(GuiEventBus.RemoteSessionFailure)
}
}
if (!grpcEndpointService.ready()) {
val sessionView = find<SessionFragment>()
runLater {
sessionView.openModal()?.apply {
setOnCloseRequest {
VripperGuiApplication.APP_INSTANCE.stop()
}
minWidth = 100.0
minHeight = 100.0
}
}
}
}
runLater {
replaceWith(find<AppView>())
}
if (it.args.isNotEmpty()) {
Watcher.notify(it.args[0])
}
@@ -13,8 +13,6 @@ import me.vripper.gui.controller.LogController
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.model.LogModel
import me.vripper.gui.utils.ActiveUICoroutines
import me.vripper.services.IAppEndpointService
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
@@ -23,12 +21,10 @@ class LogTableView : View() {
private val logController: LogController by inject()
private val widgetsController: WidgetsController by inject()
private val localAppEndpointService: IAppEndpointService by di("localAppEndpointService")
private val remoteAppEndpointService: IAppEndpointService by di("remoteAppEndpointService")
private val tableView: TableView<LogModel>
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val items: ObservableList<LogModel> = FXCollections.observableArrayList()
private var maxLogEvent = 0
private var maxLogEvent = 100
override val root = vbox {}
@@ -156,72 +152,55 @@ class LogTableView : View() {
tableView.prefHeightProperty().bind(root.heightProperty())
tableView.placeholder = Label("Loading")
tableView.sortOrder.add(tableView.columns.first { it.id == "time" })
}
override fun onDock() {
coroutineScope.launch {
GuiEventBus.events.collect { event ->
when (event) {
is GuiEventBus.LocalSession -> {
runLater {
tableView.placeholder = Label("No Content in table")
launch {
GuiEventBus.events.collect {
when (it) {
GuiEventBus.LocalSession, GuiEventBus.RemoteSession -> {
while (isActive) {
val result = runCatching { logController.getMaxEventLog() }
if (result.isSuccess) {
maxLogEvent = result.getOrNull()!!
break
}
}
while (isActive) {
val result = runCatching { logController.initLogger() }
if (result.isSuccess) {
break
}
}
}
logController.appEndpointService = localAppEndpointService
connect()
}
is GuiEventBus.RemoteSession -> {
runLater {
tableView.placeholder = Label("No Content in table")
}
logController.appEndpointService = remoteAppEndpointService
connect()
}
is GuiEventBus.RemoteSessionFailure -> {
runLater {
GuiEventBus.ChangingSession -> runLater {
items.clear()
tableView.placeholder = Label("Connection Failure")
}
}
is GuiEventBus.ChangingSession -> {
ActiveUICoroutines.logs.forEach { it.cancelAndJoin() }
ActiveUICoroutines.logs.clear()
runLater {
tableView.placeholder = Label("Loading")
items.clear()
}
}
}
}
}
}
private fun connect() {
runBlocking {
maxLogEvent = logController.appEndpointService.getSettings().systemSettings.maxEventLog
}
coroutineScope.launch {
logController.onNewLog().collect {
runLater {
items.sortWith(Comparator.comparing { it.sequence })
while (items.isNotEmpty() && (items.size >= maxLogEvent)) {
items.removeFirst()
else -> {}
}
items.add(it)
tableView.sort()
}
}
}.also { ActiveUICoroutines.logs.add(it) }
coroutineScope.launch {
logController.appEndpointService.onUpdateSettings().collect {
maxLogEvent = it.systemSettings.maxEventLog
launch {
logController.newLogs.collect {
runLater {
items.sortWith(Comparator.comparing { it.sequence })
while (items.isNotEmpty() && (items.size >= maxLogEvent)) {
items.removeFirst()
}
items.add(it)
tableView.sort()
}
}
}
}.also { ActiveUICoroutines.logs.add(it) }
runLater {
runBlocking {
logController.initLogger()
launch {
logController.updateSettings.collect {
maxLogEvent = it.systemSettings.maxEventLog
}
}
}
}
@@ -7,59 +7,34 @@ import javafx.scene.input.KeyCode
import javafx.scene.input.KeyCodeCombination
import javafx.scene.input.KeyCombination
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.catch
import me.vripper.gui.VripperGuiApplication
import me.vripper.gui.components.fragments.AboutFragment
import me.vripper.gui.components.fragments.AddLinksFragment
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.WidgetsController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.utils.ActiveUICoroutines
import me.vripper.gui.utils.openLink
import me.vripper.services.IAppEndpointService
import me.vripper.utilities.ApplicationProperties
import me.vripper.utilities.LoggerDelegate
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
class MenuBarView : View() {
private val logger by LoggerDelegate()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val downloadActiveProperty = SimpleBooleanProperty(false)
private val postsTableView: PostsTableView by inject()
private val widgetsController: WidgetsController by inject()
private val postController: PostController by inject()
private val grpcEndpointService: IAppEndpointService by di("remoteAppEndpointService")
private val localEndpointService: IAppEndpointService by di("localAppEndpointService")
private val actionBarController: ActionBarController by inject()
private lateinit var appEndpointService: IAppEndpointService
private val running = SimpleIntegerProperty(0)
override val root = menubar {}
init {
coroutineScope.launch {
GuiEventBus.events.collect { event ->
when (event) {
is GuiEventBus.LocalSession -> {
appEndpointService = localEndpointService
connect(localEndpointService)
}
is GuiEventBus.RemoteSession -> {
appEndpointService = grpcEndpointService
connect(grpcEndpointService)
}
is GuiEventBus.ChangingSession -> {
ActiveUICoroutines.menuBar.forEach { it.cancelAndJoin() }
ActiveUICoroutines.menuBar.clear()
}
}
}
}
with(root) {
menu("File") {
item("Add links", KeyCodeCombination(KeyCode.L, KeyCombination.CONTROL_DOWN)).apply {
@@ -167,7 +142,8 @@ class MenuBarView : View() {
title = "Database Migration"
) {
coroutineScope.launch {
val message = appEndpointService.dbMigration()
val message =
runCatching { appEndpointService.dbMigration() }.getOrDefault("Migration failed")
runLater {
information(
header = "",
@@ -223,18 +199,13 @@ class MenuBarView : View() {
}
}
downloadActiveProperty.bind(running.greaterThan(0))
}
private fun connect(appEndpointService: IAppEndpointService) {
coroutineScope.launch {
appEndpointService.onQueueStateUpdate().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.collect {
actionBarController.onQueueStateUpdate.collect {
runLater {
running.set(it.running)
}
}
}.also { ActiveUICoroutines.menuBar.add(it) }
}
}
}
@@ -2,21 +2,18 @@ package me.vripper.gui.components.views
import javafx.collections.FXCollections
import javafx.scene.control.TabPane
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import me.vripper.gui.controller.PostController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.model.PostModel
import me.vripper.gui.utils.ActiveUICoroutines
import me.vripper.utilities.LoggerDelegate
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
class PostInfoView : View() {
private val logger by LoggerDelegate()
private val postController: PostController by inject()
private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val imagesTableView: ImagesTableView by inject()
@@ -27,12 +24,6 @@ class PostInfoView : View() {
override val root = tabpane()
init {
coroutineScope.launch {
GuiEventBus.events.filterIsInstance(GuiEventBus.ChangingSession::class).collect {
ActiveUICoroutines.postInfo.forEach { it.cancelAndJoin() }
ActiveUICoroutines.postInfo.clear()
}
}
with(root) {
id = "postinfo_panel"
tabClosingPolicy = TabPane.TabClosingPolicy.UNAVAILABLE
@@ -87,8 +78,6 @@ class PostInfoView : View() {
}
fun setPostId(postId: Long?) {
ActiveUICoroutines.postInfo.forEach { it.cancel() }
ActiveUICoroutines.postInfo.clear()
imagesTableView.setPostId(postId)
if (postId == null) {
postModel.apply {
@@ -112,13 +101,7 @@ class PostInfoView : View() {
return
}
coroutineScope.launch {
val model: PostModel? = async {
try {
postController.find(postId)
} catch (e: Exception) {
null
}
}.await()
val model: PostModel? = postController.find(postId)
if (model == null) {
return@launch
}
@@ -144,10 +127,7 @@ class PostInfoView : View() {
}
}
coroutineScope.launch {
postController.onUpdatePosts().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.filter {
postController.updatePostsFlow.filter {
it.postId == postModel.postId
}.collect { post ->
runLater {
@@ -164,13 +144,10 @@ class PostInfoView : View() {
postModel.folderName = post.folderName
}
}
}.also { ActiveUICoroutines.postInfo.add(it) }
}
coroutineScope.launch {
postController.onUpdateMetadata().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.filter {
postController.updateMetadataFlow.filter {
it.postId == postModel.postId
}.collect {
runLater {
@@ -178,6 +155,6 @@ class PostInfoView : View() {
postModel.postedBy = it.data.postedBy
}
}
}.also { ActiveUICoroutines.postInfo.add(it) }
}
}
}
@@ -11,9 +11,13 @@ import javafx.scene.input.KeyCode
import javafx.scene.input.KeyEvent
import javafx.scene.input.MouseButton
import javafx.util.Callback
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
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
@@ -24,26 +28,19 @@ import me.vripper.gui.controller.PostController
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.model.PostModel
import me.vripper.gui.services.ClipboardService
import me.vripper.gui.utils.ActiveUICoroutines
import me.vripper.gui.utils.Preview
import me.vripper.gui.utils.openFileDirectory
import me.vripper.gui.utils.openLink
import me.vripper.services.IAppEndpointService
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
import kotlin.io.path.Path
class PostsTableView : View() {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val postController: PostController by inject()
private val widgetsController: WidgetsController by inject()
private val clipboardService: ClipboardService by inject()
private val mainView: MainView by inject()
private val localAppEndpointService: IAppEndpointService by di("localAppEndpointService")
private val remoteAppEndpointService: IAppEndpointService by di("remoteAppEndpointService")
val tableView: TableView<PostModel>
var items: SortedFilteredList<PostModel> = SortedFilteredList()
@@ -52,7 +49,6 @@ class PostsTableView : View() {
override val root = vbox {}
init {
items.filterWhen(Shared.searchInput) { query, item ->
item.title.contains(query, ignoreCase = true)
|| item.postId.toString().contains(query)
@@ -61,36 +57,6 @@ class PostsTableView : View() {
|| item.status.contains(query, ignoreCase = true)
|| item.path.contains(query, ignoreCase = true)
}
coroutineScope.launch {
GuiEventBus.events.collect { event ->
when (event) {
is GuiEventBus.LocalSession -> {
postController.appEndpointService = localAppEndpointService
connect()
}
is GuiEventBus.RemoteSession -> {
postController.appEndpointService = remoteAppEndpointService
connect()
}
is GuiEventBus.RemoteSessionFailure -> {
runLater {
items.clear()
tableView.placeholder = Label("Connection Failure")
}
}
is GuiEventBus.ChangingSession -> {
ActiveUICoroutines.posts.forEach { it.cancelAndJoin() }
ActiveUICoroutines.posts.clear()
runLater {
tableView.placeholder = Label("Loading")
}
}
}
}
}
with(root) {
tableView = tableview(items) {
@@ -398,72 +364,83 @@ class PostsTableView : View() {
}
tableView.prefHeightProperty().bind(root.heightProperty())
tableView.placeholder = Label("Loading")
coroutineScope.launch {
launch {
GuiEventBus.events.collect {
when (it) {
GuiEventBus.LocalSession, GuiEventBus.RemoteSession -> {
val postModelList = postController.findAllPosts().toList()
runLater {
items.addAll(postModelList)
tableView.sort()
tableView.placeholder = Label("No content in table")
}
}
GuiEventBus.ChangingSession -> runLater {
tableView.placeholder = Label("Loading")
items.clear()
}
else -> {}
}
}
}
launch {
postController.updateMetadataFlow.collect {
runLater {
val postModel = items.find { it.postId == it.postId } ?: return@runLater
postModel.altTitles = FXCollections.observableArrayList(it.data.resolvedNames)
postModel.postedBy = it.data.postedBy
}
}
}
launch {
postController.deletedPostsFlow.collect {
runLater {
items.items.removeIf { p -> p.postId == it }
tableView.sort()
}
}
}
launch {
postController.updatePostsFlow.collect { post ->
runLater {
val postModel = items.find { it.postId == post.postId } ?: 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
)
postModel.path = post.getDownloadFolder()
postModel.folderName = post.folderName
}
}
}
launch {
postController.newPostsFlow.collect {
runLater {
items.addAll(it)
tableView.sort()
}
}
}
}
}
private fun isCurrentTab(): Boolean = mainView.root.selectionModel.selectedItem.id == "download-tab"
private fun connect() {
coroutineScope.launch {
val postModelList = async { postController.findAllPosts() }.await()
runLater {
items.clear()
items.addAll(postModelList)
tableView.sort()
tableView.placeholder = Label("No content in table")
clipboardService.init(postController.appEndpointService)
}
}
coroutineScope.launch {
postController.onNewPosts().collect {
runLater {
items.addAll(it)
tableView.sort()
}
}
}.also { ActiveUICoroutines.posts.add(it) }
coroutineScope.launch {
postController.onUpdatePosts().collect { post ->
runLater {
val postModel = items.find { it.postId == post.postId } ?: 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
)
postModel.path = post.getDownloadFolder()
postModel.folderName = post.folderName
}
}
}.also { ActiveUICoroutines.posts.add(it) }
coroutineScope.launch {
postController.onDeletePosts().collect {
runLater {
items.items.removeIf { p -> p.postId == it }
tableView.sort()
}
}
}.also { ActiveUICoroutines.posts.add(it) }
coroutineScope.launch {
postController.onUpdateMetadata().collect {
runLater {
val postModel = items.find { it.postId == it.postId } ?: return@runLater
postModel.altTitles = FXCollections.observableArrayList(it.data.resolvedNames)
postModel.postedBy = it.data.postedBy
}
}
}.also { ActiveUICoroutines.posts.add(it) }
}
private fun rename(post: PostModel) {
find<RenameFragment>(
mapOf(
@@ -477,13 +454,6 @@ class PostsTableView : View() {
}
}
fun renameSelected() {
val selectedItem = tableView.selectionModel.selectedItem
if (selectedItem != null) {
rename(selectedItem)
}
}
fun bulkRenameSelected() {
val selectedItems = tableView.selectionModel.selectedItems
coroutineScope.launch {
@@ -6,21 +6,16 @@ import javafx.beans.property.SimpleStringProperty
import javafx.geometry.Orientation
import javafx.geometry.Pos
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.catch
import me.vripper.gui.controller.StatusBarController
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.utils.ActiveUICoroutines
import me.vripper.services.IAppEndpointService
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.formatSI
import tornadofx.*
class StatusBarView : View("Status bar") {
private val logger by LoggerDelegate()
private val widgetsController: WidgetsController by inject()
private val statusBarController: StatusBarController by inject()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val grpcEndpointService: IAppEndpointService by di("remoteAppEndpointService")
private val localEndpointService: IAppEndpointService by di("localAppEndpointService")
private val remoteText = SimpleStringProperty()
private val loggedUser = SimpleStringProperty()
private val tasksRunning = SimpleBooleanProperty(false)
@@ -31,121 +26,82 @@ class StatusBarView : View("Status bar") {
init {
coroutineScope.launch {
GuiEventBus.events.collect { event ->
runLater {
tasksRunning.set(false)
downloadSpeed.set(0L.formatSI())
running.set(0)
pending.set(0)
error.set(0)
}
when (event) {
is GuiEventBus.LocalSession -> {
connect(localEndpointService)
}
is GuiEventBus.RemoteSession -> {
connect(grpcEndpointService)
}
is GuiEventBus.RemoteSessionFailure -> {
runLater {
remoteText.set("Unable to connect to ${widgetsController.currentSettings.remoteSessionModel.host}:${widgetsController.currentSettings.remoteSessionModel.port}")
GuiEventBus.events.collect {
when (it) {
GuiEventBus.LocalSession, GuiEventBus.RemoteSession -> {
while (isActive) {
val result = runCatching { statusBarController.loggedInUser() }
if (result.isSuccess) {
runLater {
loggedUser.set(result.getOrNull())
}
break
}
delay(1000)
}
}
is GuiEventBus.ChangingSession -> {
ActiveUICoroutines.statusBar.forEach { it.cancelAndJoin() }
ActiveUICoroutines.statusBar.clear()
runLater {
remoteText.set("Connecting to ${widgetsController.currentSettings.remoteSessionModel.host}:${widgetsController.currentSettings.remoteSessionModel.port}")
}
}
else -> {}
}
}
}
}
private fun connect(endpointService: IAppEndpointService) {
coroutineScope.launch {
val user = async { endpointService.loggedInUser() }.await()
runLater {
loggedUser.set(user)
}
}
coroutineScope.launch {
endpointService.onVGUserUpdate().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.collect {
statusBarController.vgUserUpdate.collect {
runLater {
loggedUser.set(it)
}
}
}.also { ActiveUICoroutines.statusBar.add(it) }
}
coroutineScope.launch {
endpointService.onTasksRunning().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.collect {
statusBarController.tasksRunning.collect {
runLater {
tasksRunning.set(it)
}
}
}.also { ActiveUICoroutines.statusBar.add(it) }
}
coroutineScope.launch {
endpointService.onDownloadSpeed().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.collect {
statusBarController.downloadSpeed.collect {
runLater {
downloadSpeed.set(it.speed.formatSI())
}
}
}.also { ActiveUICoroutines.statusBar.add(it) }
}
coroutineScope.launch {
endpointService.onQueueStateUpdate().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.collect {
statusBarController.queueStateUpdate.collect {
runLater {
running.set(it.running)
pending.set(it.remaining)
}
}
}.also { ActiveUICoroutines.statusBar.add(it) }
}
coroutineScope.launch {
endpointService.onErrorCountUpdate().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.collect {
statusBarController.errorCountUpdate.collect {
runLater {
error.set(it.count)
}
}
}.also { ActiveUICoroutines.statusBar.add(it) }
if (widgetsController.currentSettings.localSession) {
runLater {
remoteText.set("")
}
} else {
coroutineScope.launch {
if (grpcEndpointService.ready()) {
val version = grpcEndpointService.getVersion()
runLater {
remoteText.set("Connected to ${widgetsController.currentSettings.remoteSessionModel.host}:${widgetsController.currentSettings.remoteSessionModel.port} v$version")
}
} else {
runLater {
remoteText.set("Unable to connect to ${widgetsController.currentSettings.remoteSessionModel.host}:${widgetsController.currentSettings.remoteSessionModel.port}")
}
}
coroutineScope.launch {
while (isActive) {
val text = when (statusBarController.connectionState()) {
"CONNECTING" -> "Connecting to ${widgetsController.currentSettings.remoteSessionModel.host}:${widgetsController.currentSettings.remoteSessionModel.port}"
"READY" -> "Connected to ${widgetsController.currentSettings.remoteSessionModel.host}:${widgetsController.currentSettings.remoteSessionModel.port} ${statusBarController.getVersion()}"
"TRANSIENT_FAILURE" -> "Failing to connect to ${widgetsController.currentSettings.remoteSessionModel.host}:${widgetsController.currentSettings.remoteSessionModel.port}"
"IDLE" -> "Idle connection to ${widgetsController.currentSettings.remoteSessionModel.host}:${widgetsController.currentSettings.remoteSessionModel.port}"
"SHUTDOWN" -> "Connection shutdown to ${widgetsController.currentSettings.remoteSessionModel.host}:${widgetsController.currentSettings.remoteSessionModel.port}"
else -> ""
}
runLater {
remoteText.set(text)
}
delay(1000)
}
}
}
@@ -7,17 +7,19 @@ import javafx.collections.ObservableList
import javafx.scene.control.*
import javafx.scene.input.KeyCode
import javafx.scene.input.KeyEvent
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.javafx.asFlow
import kotlinx.coroutines.launch
import me.vripper.gui.components.fragments.ThreadSelectionTableFragment
import me.vripper.gui.controller.ThreadController
import me.vripper.gui.controller.WidgetsController
import me.vripper.gui.event.GuiEventBus
import me.vripper.gui.model.ThreadModel
import me.vripper.gui.utils.ActiveUICoroutines
import me.vripper.gui.utils.openLink
import me.vripper.services.IAppEndpointService
import org.kordamp.ikonli.feather.Feather
import org.kordamp.ikonli.javafx.FontIcon
import tornadofx.*
@@ -28,38 +30,12 @@ class ThreadTableView : View() {
private val threadController: ThreadController by inject()
private val widgetsController: WidgetsController by inject()
private val mainView: MainView by inject()
private val localAppEndpointService: IAppEndpointService by di("localAppEndpointService")
private val remoteAppEndpointService: IAppEndpointService by di("remoteAppEndpointService")
private val tableView: TableView<ThreadModel>
private val items: ObservableList<ThreadModel> = FXCollections.observableArrayList()
override val root = vbox {}
init {
coroutineScope.launch {
GuiEventBus.events.collect { event ->
when (event) {
is GuiEventBus.LocalSession -> {
threadController.appEndpointService = localAppEndpointService
connect()
}
is GuiEventBus.RemoteSession -> {
threadController.appEndpointService = remoteAppEndpointService
connect()
}
is GuiEventBus.ChangingSession -> {
ActiveUICoroutines.threads.forEach { it.cancelAndJoin() }
ActiveUICoroutines.threads.clear()
runLater {
tableView.placeholder = Label("Loading")
}
}
}
}
}
with(root) {
tableView = tableview(items) {
isTableMenuButtonVisible = true
@@ -179,52 +155,65 @@ class ThreadTableView : View() {
})
tableView.prefHeightProperty().bind(root.heightProperty())
tableView.placeholder = Label("Loading")
}
fun connect() {
coroutineScope.launch {
val list = async {
threadController.findAll()
}.await()
runLater {
items.clear()
items.addAll(list)
tableView.placeholder = Label("No content in table")
launch {
GuiEventBus.events.collect {
when (it) {
GuiEventBus.LocalSession, GuiEventBus.RemoteSession -> {
val list = threadController.findAll().toList()
runLater {
items.clear()
items.addAll(list)
tableView.placeholder = Label("No content in table")
}
}
GuiEventBus.ChangingSession -> runLater {
tableView.placeholder = Label("Loading")
items.clear()
}
else -> {}
}
}
}
launch {
threadController.newThread.collect {
runLater {
items.add(it)
}
}
}
launch {
threadController.updateThread.collect { thread ->
runLater {
val threadModel = items.find { it.threadId == thread.threadId } ?: return@runLater
threadModel.total = thread.total
threadModel.title = thread.title
}
}
}
launch {
threadController.deleteThread.collect { threadId ->
runLater {
tableView.items.removeIf { it.threadId == threadId }
}
}
}
launch {
threadController.clearThreads.collect {
runLater {
tableView.items.clear()
}
}
}
}
coroutineScope.launch {
threadController.onNewThread().collect {
runLater {
items.add(it)
}
}
}.also { ActiveUICoroutines.threads.add(it) }
coroutineScope.launch {
threadController.onUpdateThread().collect { thread ->
runLater {
val threadModel = items.find { it.threadId == thread.threadId } ?: return@runLater
threadModel.total = thread.total
threadModel.title = thread.title
}
}
}.also { ActiveUICoroutines.threads.add(it) }
coroutineScope.launch {
threadController.onDeleteThread().collect { threadId ->
runLater {
tableView.items.removeIf { it.threadId == threadId }
}
}
}.also { ActiveUICoroutines.threads.add(it) }
coroutineScope.launch {
threadController.onClearThreads().collect {
runLater {
tableView.items.clear()
}
}
}.also { ActiveUICoroutines.threads.add(it) }
}
private fun isCurrentTab(): Boolean = mainView.root.selectionModel.selectedItem.id == "thread-tab"
@@ -0,0 +1,14 @@
package me.vripper.gui.controller
import me.vripper.gui.utils.AppEndpointManager.localAppEndpointService
import me.vripper.gui.utils.AppEndpointManager.remoteAppEndpointService
import me.vripper.gui.utils.ChannelFlowBuilder
import tornadofx.Controller
class ActionBarController : Controller() {
val onQueueStateUpdate = ChannelFlowBuilder.build(
localAppEndpointService::onQueueStateUpdate,
remoteAppEndpointService::onQueueStateUpdate
)
}
@@ -1,19 +1,18 @@
package me.vripper.gui.controller
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.retryWhen
import me.vripper.gui.model.ImageModel
import me.vripper.gui.utils.AppEndpointManager.currentAppEndpointService
import me.vripper.model.Image
import me.vripper.services.IAppEndpointService
import me.vripper.utilities.LoggerDelegate
import tornadofx.Controller
class ImageController : Controller() {
private val logger by LoggerDelegate()
lateinit var appEndpointService: IAppEndpointService
suspend fun findImages(postId: Long): List<ImageModel> {
return appEndpointService.findImagesByPostId(postId).map(::mapper)
return runCatching { currentAppEndpointService().findImagesByPostId(postId).map(::mapper) }.getOrDefault(
emptyList()
)
}
private fun mapper(it: Image): ImageModel {
@@ -39,14 +38,7 @@ class ImageController : Controller() {
}
fun onUpdateImages(postId: Long) =
appEndpointService.onUpdateImagesByPostId(postId).catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}
fun onStopped() = appEndpointService.onStopped().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}
currentAppEndpointService().onUpdateImagesByPostId(postId).retryWhen { _, _ -> delay(1000);true }
fun onStopped() = currentAppEndpointService().onStopped().retryWhen { _, _ -> delay(1000);true }
}
@@ -1,18 +1,26 @@
package me.vripper.gui.controller
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import me.vripper.gui.model.LogModel
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.LogEntry
import me.vripper.services.IAppEndpointService
import me.vripper.utilities.LoggerDelegate
import tornadofx.Controller
class LogController : Controller() {
private val logger by LoggerDelegate()
lateinit var appEndpointService: IAppEndpointService
val newLogs = ChannelFlowBuilder.build(
{ localAppEndpointService.onNewLog().map(::mapper) },
{ remoteAppEndpointService.onNewLog().map(::mapper) }
)
val updateSettings =
ChannelFlowBuilder.build(
localAppEndpointService::onUpdateSettings,
remoteAppEndpointService::onUpdateSettings
)
private fun mapper(it: LogEntry): LogModel {
return LogModel(
@@ -26,12 +34,11 @@ class LogController : Controller() {
)
}
fun onNewLog() = appEndpointService.onNewLog().map(::mapper).catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
suspend fun initLogger() {
currentAppEndpointService().initLogger()
}
suspend fun initLogger() {
appEndpointService.initLogger()
suspend fun getMaxEventLog(): Int {
return currentAppEndpointService().getSettings().systemSettings.maxEventLog
}
}
@@ -1,58 +1,93 @@
package me.vripper.gui.controller
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
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.gui.utils.ChannelFlowBuilder.toFlow
import me.vripper.model.Post
import me.vripper.services.IAppEndpointService
import me.vripper.utilities.LoggerDelegate
import me.vripper.utilities.formatSI
import tornadofx.Controller
import java.time.format.DateTimeFormatter
class PostController : Controller() {
private val logger by LoggerDelegate()
private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss")
lateinit var appEndpointService: IAppEndpointService
val updatePostsFlow =
ChannelFlowBuilder.build(
localAppEndpointService::onUpdatePosts,
remoteAppEndpointService::onUpdatePosts
)
val newPostsFlow = ChannelFlowBuilder.build(
{
localAppEndpointService.onNewPosts().map { post ->
mapper(post)
}
}, {
remoteAppEndpointService.onNewPosts().map { post ->
mapper(post)
}
}
)
val deletedPostsFlow =
ChannelFlowBuilder.build(
localAppEndpointService::onDeletePosts,
remoteAppEndpointService::onDeletePosts
)
val updateMetadataFlow = ChannelFlowBuilder.build(
localAppEndpointService::onUpdateMetadata,
remoteAppEndpointService::onUpdateMetadata,
)
suspend fun scan(postLinks: String) {
appEndpointService.scanLinks(postLinks)
runCatching { currentAppEndpointService().scanLinks(postLinks) }
}
suspend fun start(postIdList: List<Long>) {
appEndpointService.restartAll(postIdList)
runCatching { currentAppEndpointService().restartAll(postIdList) }
}
suspend fun startAll() {
appEndpointService.restartAll()
runCatching { currentAppEndpointService().restartAll() }
}
suspend fun delete(postIdList: List<Long>) {
appEndpointService.remove(postIdList)
runCatching { currentAppEndpointService().remove(postIdList) }
}
suspend fun stop(postIdList: List<Long>) {
appEndpointService.stopAll(postIdList)
runCatching { currentAppEndpointService().stopAll(postIdList) }
}
suspend fun clearPosts(): List<Long> {
return appEndpointService.clearCompleted()
return runCatching { return currentAppEndpointService().clearCompleted() }.getOrDefault(emptyList())
}
suspend fun stopAll() {
appEndpointService.stopAll()
runCatching { currentAppEndpointService().stopAll() }
}
suspend fun find(postId: Long): PostModel {
return mapper(appEndpointService.findPost(postId))
suspend fun find(postId: Long): PostModel? {
return runCatching { mapper(currentAppEndpointService().findPost(postId)) }.getOrNull()
}
suspend fun findAllPosts(): List<PostModel> {
return appEndpointService.findAllPosts().map(::mapper)
fun findAllPosts(): Flow<PostModel> {
return toFlow { currentAppEndpointService().findAllPosts().map(::mapper) }
}
suspend fun rename(postId: Long, value: String) {
runCatching { currentAppEndpointService().rename(postId, value) }
}
suspend fun renameToFirst(postIds: List<Long>) {
runCatching { currentAppEndpointService().renameToFirst(postIds) }
}
private fun mapper(post: Post): PostModel {
@@ -84,39 +119,4 @@ class PostController : Controller() {
fun progress(total: Int, done: Int): Double {
return if (done == 0 && total == 0) 0.0 else (done.toDouble() / total)
}
suspend fun rename(postId: Long, value: String) {
appEndpointService.rename(postId, value)
}
suspend fun renameToFirst(postIds: List<Long>) {
appEndpointService.renameToFirst(postIds)
}
fun onNewPosts() =
appEndpointService.onNewPosts().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}.map { post ->
mapper(post)
}
fun onUpdatePosts() =
appEndpointService.onUpdatePosts().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}
fun onDeletePosts() =
appEndpointService.onDeletePosts().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}
fun onUpdateMetadata() =
appEndpointService.onUpdateMetadata().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}
}
@@ -4,29 +4,36 @@ import me.vripper.gui.model.settings.ConnectionSettingsModel
import me.vripper.gui.model.settings.DownloadSettingsModel
import me.vripper.gui.model.settings.SystemSettingsModel
import me.vripper.gui.model.settings.ViperSettingsModel
import me.vripper.gui.utils.AppEndpointManager
import me.vripper.model.*
import me.vripper.services.IAppEndpointService
import tornadofx.Controller
class SettingsController : Controller() {
private val widgetsController: WidgetsController by inject()
private val appEndpointService: IAppEndpointService by di(if (widgetsController.currentSettings.localSession) "localAppEndpointService" else "remoteAppEndpointService")
suspend fun findDownloadSettings(): DownloadSettings {
return appEndpointService.getSettings().downloadSettings
return runCatching {
AppEndpointManager.currentAppEndpointService().getSettings().downloadSettings
}.getOrDefault(
DownloadSettings()
)
}
suspend fun findConnectionSettings(): ConnectionSettings {
return appEndpointService.getSettings().connectionSettings
return runCatching {
AppEndpointManager.currentAppEndpointService().getSettings().connectionSettings
}.getOrDefault(ConnectionSettings())
}
suspend fun findViperGirlsSettings(): ViperSettings {
return appEndpointService.getSettings().viperSettings
return runCatching { AppEndpointManager.currentAppEndpointService().getSettings().viperSettings }.getOrDefault(
ViperSettings()
)
}
suspend fun findSystemSettings(): SystemSettings {
return appEndpointService.getSettings().systemSettings
return runCatching { AppEndpointManager.currentAppEndpointService().getSettings().systemSettings }.getOrDefault(
SystemSettings()
)
}
suspend fun saveNewSettings(
@@ -35,45 +42,47 @@ class SettingsController : Controller() {
viperSettingsModel: ViperSettingsModel,
systemSettingsModel: SystemSettingsModel
) {
appEndpointService.saveSettings(
Settings(
downloadSettings = DownloadSettings(
downloadSettingsModel.downloadPath,
downloadSettingsModel.autoStart,
downloadSettingsModel.autoQueueThreshold,
downloadSettingsModel.forceOrder,
downloadSettingsModel.forumSubfolder,
downloadSettingsModel.threadSubLocation,
downloadSettingsModel.clearCompleted,
downloadSettingsModel.appendPostId
),
connectionSettings = ConnectionSettings(
connectionSettingsModel.maxThreads,
connectionSettingsModel.maxTotalThreads,
connectionSettingsModel.timeout,
connectionSettingsModel.maxAttempts,
),
viperSettings = ViperSettings(
viperSettingsModel.login,
viperSettingsModel.username,
viperSettingsModel.password,
viperSettingsModel.thanks,
viperSettingsModel.host,
viperSettingsModel.requestLimit,
viperSettingsModel.fetchMetadata,
),
systemSettings =
SystemSettings(
systemSettingsModel.tempPath,
systemSettingsModel.enable,
systemSettingsModel.pollingRate,
systemSettingsModel.logEntries
)
runCatching {
AppEndpointManager.currentAppEndpointService().saveSettings(
Settings(
downloadSettings = DownloadSettings(
downloadSettingsModel.downloadPath,
downloadSettingsModel.autoStart,
downloadSettingsModel.autoQueueThreshold,
downloadSettingsModel.forceOrder,
downloadSettingsModel.forumSubfolder,
downloadSettingsModel.threadSubLocation,
downloadSettingsModel.clearCompleted,
downloadSettingsModel.appendPostId
),
connectionSettings = ConnectionSettings(
connectionSettingsModel.maxThreads,
connectionSettingsModel.maxTotalThreads,
connectionSettingsModel.timeout,
connectionSettingsModel.maxAttempts,
),
viperSettings = ViperSettings(
viperSettingsModel.login,
viperSettingsModel.username,
viperSettingsModel.password,
viperSettingsModel.thanks,
viperSettingsModel.host,
viperSettingsModel.requestLimit,
viperSettingsModel.fetchMetadata,
),
systemSettings =
SystemSettings(
systemSettingsModel.tempPath,
systemSettingsModel.enable,
systemSettingsModel.pollingRate,
systemSettingsModel.logEntries
)
)
)
)
}
}
suspend fun getProxies(): List<String> {
return appEndpointService.getProxies()
return runCatching { AppEndpointManager.currentAppEndpointService().getProxies() }.getOrDefault(emptyList())
}
}
@@ -0,0 +1,46 @@
package me.vripper.gui.controller
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 tornadofx.Controller
class StatusBarController : Controller() {
suspend fun loggedInUser(): String {
return currentAppEndpointService().loggedInUser()
}
fun connectionState(): String {
return currentAppEndpointService().connectionState()
}
suspend fun getVersion(): String {
return currentAppEndpointService().getVersion()
}
val vgUserUpdate = ChannelFlowBuilder.build(
localAppEndpointService::onVGUserUpdate,
remoteAppEndpointService::onVGUserUpdate,
)
val tasksRunning = ChannelFlowBuilder.build(
localAppEndpointService::onTasksRunning,
remoteAppEndpointService::onTasksRunning,
)
val downloadSpeed = ChannelFlowBuilder.build(
localAppEndpointService::onDownloadSpeed,
remoteAppEndpointService::onDownloadSpeed,
)
val queueStateUpdate = ChannelFlowBuilder.build(
localAppEndpointService::onQueueStateUpdate,
remoteAppEndpointService::onQueueStateUpdate,
)
val errorCountUpdate = ChannelFlowBuilder.build(
localAppEndpointService::onErrorCountUpdate,
remoteAppEndpointService::onErrorCountUpdate
)
}
@@ -1,24 +1,47 @@
package me.vripper.gui.controller
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import me.vripper.entities.ThreadEntity
import me.vripper.gui.model.ThreadModel
import me.vripper.gui.model.ThreadSelectionModel
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.gui.utils.ChannelFlowBuilder.toFlow
import me.vripper.model.ThreadPostId
import me.vripper.services.IAppEndpointService
import me.vripper.utilities.LoggerDelegate
import org.koin.core.component.KoinComponent
import tornadofx.Controller
class ThreadController : KoinComponent, Controller() {
private val logger by LoggerDelegate()
lateinit var appEndpointService: IAppEndpointService
suspend fun findAll(): List<ThreadModel> {
return appEndpointService.findAllThreads().map(::threadModelMapper)
val newThread = ChannelFlowBuilder.build(
{
localAppEndpointService.onNewThread().map(::threadModelMapper)
},
{
remoteAppEndpointService.onNewThread().map(::threadModelMapper)
},
)
val updateThread = ChannelFlowBuilder.build(
localAppEndpointService::onUpdateThread,
remoteAppEndpointService::onUpdateThread,
)
val deleteThread = ChannelFlowBuilder.build(
localAppEndpointService::onDeleteThread,
remoteAppEndpointService::onDeleteThread,
)
val clearThreads = ChannelFlowBuilder.build(
localAppEndpointService::onClearThreads,
remoteAppEndpointService::onClearThreads,
)
fun findAll(): Flow<ThreadModel> {
return toFlow { currentAppEndpointService().findAllThreads().map(::threadModelMapper) }
}
private fun threadModelMapper(it: ThreadEntity): ThreadModel {
@@ -31,15 +54,15 @@ class ThreadController : KoinComponent, Controller() {
}
suspend fun delete(threadIdList: List<Long>) {
appEndpointService.threadRemove(threadIdList)
currentAppEndpointService().threadRemove(threadIdList)
}
suspend fun clearAll() {
appEndpointService.threadClear()
currentAppEndpointService().threadClear()
}
suspend fun grab(threadId: Long): List<ThreadSelectionModel> =
appEndpointService.grab(threadId).map { postItem ->
currentAppEndpointService().grab(threadId).map { postItem ->
ThreadSelectionModel(
postItem.number,
postItem.title,
@@ -51,33 +74,12 @@ class ThreadController : KoinComponent, Controller() {
)
}
suspend fun download(selectedItems: List<ThreadSelectionModel>) {
appEndpointService.download(selectedItems.map {
currentAppEndpointService().download(selectedItems.map {
ThreadPostId(
it.threadId,
it.postId
)
})
}
fun onNewThread() = appEndpointService.onNewThread().map(::threadModelMapper).catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}
fun onUpdateThread() = appEndpointService.onUpdateThread().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}
fun onDeleteThread() = appEndpointService.onDeleteThread().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}
fun onClearThreads() = appEndpointService.onClearThreads().catch {
logger.error("gRPC error", it)
currentCoroutineContext().cancel(null)
}
}
@@ -235,6 +235,9 @@ class WidgetsController : Controller() {
currentSettings.remoteSessionModel.portProperty.onChange {
WidgetSettings.update(currentSettings)
}
currentSettings.remoteSessionModel.passcodeProperty.onChange {
WidgetSettings.update(currentSettings)
}
currentSettings.cachePathProperty.onChange {
WidgetSettings.update(currentSettings)
}
@@ -1,20 +1,22 @@
package me.vripper.gui.event
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
object GuiEventBus {
private val _events = MutableSharedFlow<Any>()
private val _events = MutableSharedFlow<GUIEvent>(0, Int.MAX_VALUE, BufferOverflow.DROP_OLDEST)
val events = _events.asSharedFlow()
suspend fun publishEvent(event: Any) {
suspend fun publishEvent(event: GUIEvent) {
_events.emit(event)
}
data class ApplicationInitialized(val args: List<String>)
object ChangingSession
object LocalSession
object RemoteSession
object RemoteSessionFailure
data class ApplicationInitialized(val args: List<String>) : GUIEvent
object ChangingSession : GUIEvent
object LocalSession : GUIEvent
object RemoteSession : GUIEvent
}
sealed interface GUIEvent
@@ -1,10 +0,0 @@
package me.vripper.gui.listener
import me.vripper.listeners.OnStartupListener
class GuiStartupLister : OnStartupListener() {
override fun run() {
super.run()
}
}
@@ -8,10 +8,14 @@ import tornadofx.setValue
class RemoteSessionModel(
host: String,
port: Int,
passcode: String,
) {
val hostProperty = SimpleStringProperty(host)
var host: String by hostProperty
val portProperty = SimpleIntegerProperty(port)
var port: Int by portProperty
val passcodeProperty = SimpleStringProperty(passcode)
var passcode: String by passcodeProperty
}
@@ -159,6 +159,7 @@ class WidgetsViewModel(
val remoteSessionModel = RemoteSessionModel(
remoteSession.host,
remoteSession.port
remoteSession.port,
remoteSession.passPhrase,
)
}
@@ -0,0 +1,22 @@
package me.vripper.gui.services
import io.grpc.*
import io.grpc.ForwardingClientCall.SimpleForwardingClientCall
class ClientE2eEncryptingInterceptor(private val passPhrase: String) : ClientInterceptor {
override fun <ReqT, RespT> interceptCall(
method: MethodDescriptor<ReqT?, RespT?>,
callOptions: CallOptions?, next: Channel
): ClientCall<ReqT, RespT> {
return object : SimpleForwardingClientCall<ReqT, RespT>(
next.newCall(
method.toBuilder(
ClientRequestEncryptor(method.getRequestMarshaller(), passPhrase),
ClientResponseDecryptor(method.getResponseMarshaller(), passPhrase)
).build(),
callOptions
)
) {}
}
}
@@ -0,0 +1,22 @@
package me.vripper.gui.services
import io.grpc.MethodDescriptor
import me.vripper.utilities.AesUtils
import java.io.ByteArrayInputStream
import java.io.InputStream
class ClientRequestEncryptor<T>(
private val protosMarshaller: MethodDescriptor.Marshaller<T>,
private val passPhrase: String
) :
MethodDescriptor.Marshaller<T> {
override fun stream(value: T): InputStream {
return ByteArrayInputStream(AesUtils.aesEncrypt(protosMarshaller.stream(value).readBytes(), passPhrase))
}
override fun parse(stream: InputStream): T {
return protosMarshaller.parse(stream)
}
}
@@ -0,0 +1,22 @@
package me.vripper.gui.services
import io.grpc.MethodDescriptor
import me.vripper.utilities.AesUtils
import java.io.ByteArrayInputStream
import java.io.InputStream
class ClientResponseDecryptor<T>(
private val protoMarshaller: MethodDescriptor.Marshaller<T>,
private val passPhrase: String
) :
MethodDescriptor.Marshaller<T> {
override fun stream(value: T): InputStream {
return protoMarshaller.stream(value)
}
override fun parse(encryptedStream: InputStream): T {
return protoMarshaller.parse(ByteArrayInputStream(AesUtils.aesDecrypt(encryptedStream.readBytes(), passPhrase)))
}
}
@@ -1,61 +0,0 @@
package me.vripper.gui.services
import javafx.scene.input.Clipboard
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterIsInstance
import me.vripper.gui.event.GuiEventBus
import me.vripper.model.Settings
import me.vripper.services.IAppEndpointService
import me.vripper.utilities.LoggerDelegate
import tornadofx.Controller
import tornadofx.runLater
class ClipboardService : Controller() {
private val logger by LoggerDelegate()
private var current: String? = null
private var coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var pollJob: Job? = null
private var subscribeJob: Job? = null
fun init(appEndpointService: IAppEndpointService) {
subscribeJob?.cancel()
subscribeJob = coroutineScope.launch {
appEndpointService.onUpdateSettings().catch { logger.error("gRPC error", it) }.collect {
run(it, appEndpointService)
}
}
coroutineScope.launch {
GuiEventBus.events.filterIsInstance(GuiEventBus.ChangingSession::class).collect {
subscribeJob?.cancel()
}
}
runBlocking {
run(appEndpointService.getSettings(), appEndpointService)
}
}
private fun run(settings: Settings, appEndpointService: IAppEndpointService) {
pollJob?.cancel()
if (settings.systemSettings.enableClipboardMonitoring) {
pollJob = coroutineScope.launch {
var value: String? = null
while (isActive) {
runLater {
val clipboard = Clipboard.getSystemClipboard()
if (clipboard.hasString()) {
value = clipboard.string
}
}
if (!value.isNullOrBlank() && value != current) {
current = value
appEndpointService.scanLinks(value)
}
delay(settings.systemSettings.clipboardPollingRate.toLong())
}
}
} else {
current = null
}
}
}
@@ -20,7 +20,7 @@ import me.vripper.services.IAppEndpointService
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
class GrpcEndpointService : IAppEndpointService {
internal class GrpcEndpointService : IAppEndpointService {
private var channel: ManagedChannel? = null
private var endpointServiceCoroutineStub: EndpointServiceGrpcKt.EndpointServiceCoroutineStub? = null
@@ -48,11 +48,9 @@ class GrpcEndpointService : IAppEndpointService {
override suspend fun findPost(postId: Long): Post =
mapper(endpointServiceCoroutineStub!!.findPost(Id.newBuilder().setId(postId).build()))
override suspend fun findAllPosts(): List<Post> =
endpointServiceCoroutineStub!!.findAllPosts(EmptyRequest.getDefaultInstance()).postsList.map(::mapper)
override suspend fun rename(postId: Long, newName: String) {
endpointServiceCoroutineStub!!.rename(
Rename.newBuilder().setPostId(postId).setName(newName).build()
@@ -356,8 +354,14 @@ class GrpcEndpointService : IAppEndpointService {
)
}
fun connect(host: String, port: Int) {
channel = ManagedChannelBuilder.forAddress(host, port).maxInboundMessageSize(134217728).usePlaintext().build()
fun connect(host: String, port: Int, passPhrase: String) {
disconnect()
channel = ManagedChannelBuilder
.forAddress(host, port)
.maxInboundMessageSize(Integer.MAX_VALUE)
.usePlaintext()
.intercept(ClientE2eEncryptingInterceptor(passPhrase))
.build()
endpointServiceCoroutineStub = EndpointServiceGrpcKt.EndpointServiceCoroutineStub(channel!!)
}
@@ -369,9 +373,5 @@ class GrpcEndpointService : IAppEndpointService {
}
}
fun connectionState(): ConnectivityState = channel?.getState(true) ?: ConnectivityState.SHUTDOWN
override fun ready(): Boolean {
return connectionState() == ConnectivityState.READY
}
}
override fun connectionState(): String = channel?.getState(true)?.name ?: ConnectivityState.SHUTDOWN.name
}
@@ -1,25 +0,0 @@
package me.vripper.gui.utils
import kotlinx.coroutines.Job
object ActiveUICoroutines {
val posts: MutableList<Job> = mutableListOf()
val actionBar: MutableList<Job> = mutableListOf()
val images: MutableList<Job> = mutableListOf()
val logs: MutableList<Job> = mutableListOf()
val menuBar: MutableList<Job> = mutableListOf()
val postInfo: MutableList<Job> = mutableListOf()
val statusBar: MutableList<Job> = mutableListOf()
val threads: MutableList<Job> = mutableListOf()
fun all() = listOf(
posts,
actionBar,
images,
logs,
menuBar,
postInfo,
statusBar,
threads
).flatten()
}
@@ -0,0 +1,32 @@
package me.vripper.gui.utils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import me.vripper.gui.event.GUIEvent
import me.vripper.gui.event.GuiEventBus
import me.vripper.services.IAppEndpointService
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.core.qualifier.named
object AppEndpointManager : KoinComponent {
val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val localAppEndpointService: IAppEndpointService by inject(named("localAppEndpointService"))
val remoteAppEndpointService: IAppEndpointService by inject(named("remoteAppEndpointService"))
private lateinit var current: GUIEvent
fun set(event: GUIEvent) {
this.current = event
}
fun currentAppEndpointService(): IAppEndpointService {
return when (current) {
GuiEventBus.LocalSession -> localAppEndpointService
GuiEventBus.RemoteSession -> remoteAppEndpointService
else -> throw IllegalStateException("Unknown current state: $current")
}
}
}
@@ -0,0 +1,53 @@
package me.vripper.gui.utils
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.cancellable
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.retryWhen
import me.vripper.gui.event.GuiEventBus
object ChannelFlowBuilder {
fun <T> build(localFlow: () -> Flow<T>, remoteFlow: () -> Flow<T>): Flow<T> {
return channelFlow {
var job: Job? = null
fun collect(localSession: Boolean) {
job = if (localSession) {
launch {
localFlow().cancellable().collect { if (isActive) send(it) }
}
} else {
launch {
remoteFlow().cancellable().retryWhen { _, _ ->
delay(1000)
true
}.collect { if (isActive) send(it) }
}
}
}
launch {
GuiEventBus
.events
.collect {
if (job != null && job.isActive) {
job.cancelAndJoin()
}
when (it) {
GuiEventBus.LocalSession -> collect(true)
GuiEventBus.RemoteSession -> collect(false)
else -> {}
}
}
}
}
}
fun <T> toFlow(source: suspend () -> List<T>): Flow<T> {
return channelFlow {
source().forEach { if (isActive) send(it) }
}.retryWhen { _, _ -> delay(1000); true }
}
}
@@ -0,0 +1,73 @@
package me.vripper.gui.utils
import javafx.scene.input.Clipboard
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.retryWhen
import me.vripper.gui.event.GuiEventBus
import me.vripper.model.Settings
import me.vripper.utilities.LoggerDelegate
import org.koin.core.component.KoinComponent
import tornadofx.runLater
object ClipboardManager : KoinComponent {
private val logger by LoggerDelegate()
private var current: String? = null
private var coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var pollJob: Job? = null
private var settingsUpdateJob: Job? = null
fun init() {
coroutineScope.launch {
GuiEventBus.events.collect {
when (it) {
GuiEventBus.LocalSession, GuiEventBus.RemoteSession -> {
logger.info("Clipboard manager initialized")
while (isActive) {
val result = runCatching { AppEndpointManager.currentAppEndpointService().getSettings() }
if (result.isSuccess) {
update(result.getOrNull()!!)
break
}
delay(1000)
}
settingsUpdateJob?.cancelAndJoin()
settingsUpdateJob = launch {
AppEndpointManager.currentAppEndpointService().onUpdateSettings()
.retryWhen { _, _ -> delay(1000); true }.collect {
update(it)
}
}
}
else -> {}
}
}
}
}
private fun update(settings: Settings) {
pollJob?.cancel()
if (settings.systemSettings.enableClipboardMonitoring) {
logger.info("Polling clipboard every ${settings.systemSettings.clipboardPollingRate}ms")
pollJob = coroutineScope.launch {
var value: String? = null
while (isActive) {
runLater {
val clipboard = Clipboard.getSystemClipboard()
if (clipboard.hasString()) {
value = clipboard.string
}
}
if (!value.isNullOrBlank() && value != current) {
current = value
runCatching { AppEndpointManager.currentAppEndpointService().scanLinks(value) }
}
delay(settings.systemSettings.clipboardPollingRate.toLong())
}
}
} else {
logger.info("Clipboard monitoring deactivated")
current = null
}
}
}
@@ -61,11 +61,11 @@ object Watcher : KoinComponent {
log.info("Handling thread ${it.context()}")
if (WidgetSettings.loadSettings().localSession) {
val link = localAppEndpointService.getSettings().viperSettings.host + "/threads/${it.context()}"
localAppEndpointService.scanLinks(link)
runCatching { localAppEndpointService.scanLinks(link) }
} else {
val link =
remoteAppEndpointService.getSettings().viperSettings.host + "/threads/${it.context()}"
remoteAppEndpointService.scanLinks(link)
runCatching { remoteAppEndpointService.scanLinks(link) }
}
path.resolve(it.context().toString()).deleteIfExists()
}
@@ -123,6 +123,7 @@ object WidgetSettings {
data class RemoteSession(
val host: String = "",
val port: Int = 30000,
val passPhrase: String = "",
)
@Serializable
@@ -212,7 +213,8 @@ object WidgetSettings {
),
RemoteSession(
currentSettings.remoteSessionModel.host,
currentSettings.remoteSessionModel.port
currentSettings.remoteSessionModel.port,
currentSettings.remoteSessionModel.passcode
),
PostsTableColumnsWidth(
currentSettings.postsColumnsWidthModel.preview,
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -1,6 +1,6 @@
package me.vripper
import me.vripper.listeners.OnStartupListener
import me.vripper.listeners.AppManager
import me.vripper.utilities.DatabaseManager
import org.koin.core.context.startKoin
import org.springframework.boot.autoconfigure.SpringBootApplication
@@ -10,10 +10,10 @@ import org.springframework.boot.builder.SpringApplicationBuilder
class VripperWebApplication
fun main(args: Array<String>) {
DatabaseManager.connect()
startKoin {
modules(coreModule)
}
OnStartupListener().run()
DatabaseManager.connect()
AppManager.start()
SpringApplicationBuilder(VripperWebApplication::class.java).listeners(AppListener()).run(*args)
}
@@ -2,23 +2,37 @@ package me.vripper.web.grpc
import io.grpc.Server
import io.grpc.ServerBuilder
import io.grpc.ServerInterceptors
import me.vripper.utilities.LoggerDelegate
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import javax.annotation.PostConstruct
import javax.annotation.PreDestroy
@Component
class GrpcServer(
@Value("\${grpc.enabled}") private val enabled: Boolean,
@Value("\${grpc.port}") private val port: Int,
@Value("\${grpc.passphrase}") private val passPhrase: String,
grpcServerAppEndpointService: GrpcServerAppEndpointService
) {
private val log by LoggerDelegate()
private val server: Server =
ServerBuilder.forPort(port).addService(grpcServerAppEndpointService).build()
ServerBuilder
.forPort(port)
.addService(
ServerInterceptors.intercept(
ServerInterceptors.useMarshalledMessages(
grpcServerAppEndpointService.bindService(),
ServerRequestDecryptor(passPhrase),
ServerResponseEncryptor(passPhrase)
)
)
)
.build()
@PostConstruct
fun start() {
@@ -29,4 +43,9 @@ class GrpcServer(
log.info("gRPC is disabled, remote connection to this instance is not possible")
}
}
@PreDestroy
fun destroy() {
server.shutdown()
}
}
@@ -0,0 +1,18 @@
package me.vripper.web.grpc
import io.grpc.MethodDescriptor
import me.vripper.utilities.AesUtils
import java.io.ByteArrayInputStream
import java.io.InputStream
class ServerRequestDecryptor(private val passPhrase: String) : MethodDescriptor.Marshaller<InputStream> {
override fun stream(encryptedStream: InputStream): InputStream {
return ByteArrayInputStream(AesUtils.aesDecrypt(encryptedStream.readBytes(), passPhrase))
}
override fun parse(stream: InputStream?): InputStream? {
return stream
}
}
@@ -0,0 +1,18 @@
package me.vripper.web.grpc
import io.grpc.MethodDescriptor
import me.vripper.utilities.AesUtils
import java.io.ByteArrayInputStream
import java.io.InputStream
class ServerResponseEncryptor(private val passPhrase: String) : MethodDescriptor.Marshaller<InputStream> {
override fun stream(serializedProto: InputStream): InputStream {
return ByteArrayInputStream(AesUtils.aesEncrypt(serializedProto.readBytes(), passPhrase))
}
override fun parse(stream: InputStream?): InputStream? {
return stream
}
}
@@ -1,3 +1,3 @@
spring.liquibase.enabled=false
grpc.enabled=false
grpc.enabled=true
grpc.port=30000